Initialize clean CSR inventory repository
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
*.sqlite3
|
||||||
|
staticfiles
|
||||||
|
backups
|
||||||
|
media
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.log
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
tls
|
||||||
|
traefik/dynamic/tls.yml
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
DEBUG=false
|
||||||
|
SECRET_KEY=replace-with-a-long-random-value
|
||||||
|
ALLOWED_HOSTS=inventory.example.org,127.0.0.1,localhost
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://inventory.example.org
|
||||||
|
SECURE_COOKIES=true
|
||||||
|
SECURE_SSL_REDIRECT=true
|
||||||
|
TIME_ZONE=Europe/Berlin
|
||||||
|
POSTGRES_DB=csr_inventory
|
||||||
|
POSTGRES_USER=csr_inventory
|
||||||
|
POSTGRES_PASSWORD=replace-with-a-strong-password
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
APP_HOST=inventory.example.org
|
||||||
|
# Public domain: letsencrypt. LAN: docker-compose.lan.yml disables ACME.
|
||||||
|
TRAEFIK_CERT_RESOLVER=letsencrypt
|
||||||
|
ACME_EMAIL=admin@example.org
|
||||||
|
MAX_UPLOAD_SIZE=10485760
|
||||||
|
LOG_LEVEL=WARNING
|
||||||
|
API_ANON_RATE=20/min
|
||||||
|
API_CLIENT_RATE=300/min
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
39
.github/workflows/tests.yml
vendored
Normal file
39
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Tests
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
postgres:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: csr_inventory
|
||||||
|
POSTGRES_USER: csr_inventory
|
||||||
|
POSTGRES_PASSWORD: ci-only-password
|
||||||
|
ports: ["5432:5432"]
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U csr_inventory -d csr_inventory"
|
||||||
|
--health-interval 5s --health-timeout 5s --health-retries 12
|
||||||
|
env:
|
||||||
|
DEBUG: "true"
|
||||||
|
SECURE_SSL_REDIRECT: "false"
|
||||||
|
POSTGRES_DB: csr_inventory
|
||||||
|
POSTGRES_USER: csr_inventory
|
||||||
|
POSTGRES_PASSWORD: ci-only-password
|
||||||
|
POSTGRES_HOST: localhost
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- run: pip install -r requirements.txt
|
||||||
|
- run: python manage.py check
|
||||||
|
- run: python manage.py makemigrations --check --dry-run
|
||||||
|
- run: python manage.py test --noinput
|
||||||
|
- run: python scripts/test_backup_scripts.py
|
||||||
|
- run: python scripts/test_local_tls.py
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
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
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
media/*
|
||||||
|
!media/imports/
|
||||||
|
media/imports/*
|
||||||
|
!media/imports/.gitkeep
|
||||||
|
staticfiles/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
backups/
|
||||||
|
tls/
|
||||||
|
traefik/dynamic/tls.yml
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.13-slim AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system app && adduser --system --ingroup app app \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends libpq5 postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
RUN chmod +x /app/scripts/entrypoint.sh /app/scripts/backup.sh /app/scripts/restore.sh \
|
||||||
|
&& mkdir -p /app/staticfiles /app/media \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
||||||
|
CMD ["gunicorn", "csr_inventory.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--threads", "2", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
297
README.md
Normal file
297
README.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# CS-R Geräteverwaltung
|
||||||
|
|
||||||
|
Produktionsnahe interne Geräteverwaltung als kompakter Django-Monolith. Die Anwendung ersetzt die bisherige Excel-Liste und ist für den Betrieb auf einem kleinen Linux-VPS ausgelegt.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- Rollenbasierte Anmeldung mit `ADMIN`, `EDITOR` und `VIEWER`
|
||||||
|
- Schneller Quick-Add für einzelne Geräte oder Serien mit automatisch fortlaufenden, niemals wiederverwendeten Gerätenummern
|
||||||
|
- Serverseitige Suche, kombinierbare Filter, Sortierung und Pagination
|
||||||
|
- Kompakte Gerätetabelle mit ein-/ausblendbaren Spalten und Inline-Bearbeitung
|
||||||
|
- Detail-Drawer und vollständige Bearbeitungsansicht
|
||||||
|
- Optimistic Concurrency gegen unbemerktes Überschreiben
|
||||||
|
- Globale, deaktivierbare Auswahlwerte und Mitarbeiterverwaltung
|
||||||
|
- Benutzerdefinierte Felder ohne neue Datenbankmigration
|
||||||
|
- Transaktionaler XLSX-Import mit Vorschau und Spaltenzuordnung
|
||||||
|
- CSV-, XLSX- und ODS-Export für LibreOffice, auch für gefilterte Bestände
|
||||||
|
- Bearbeitbare Statusfarben mit klar unterscheidbarer Farbpalette
|
||||||
|
- CPU-Livevorschläge aus einer Intel-/AMD-Masterliste mit Hersteller- und Serienfiltern
|
||||||
|
- Vollständige Änderungshistorie und gerätebezogenes Audit-Log
|
||||||
|
- Abgesicherte JSON-API mit getrennten Lese-/Schreibschlüsseln, Ablaufdatum und API-Audit
|
||||||
|
- Archivierung und Wiederherstellung statt versehentlichem Löschen
|
||||||
|
- PostgreSQL-Trigramm- und JSONB-Indizes für große Bestände
|
||||||
|
- Docker Compose mit PostgreSQL, Django/Gunicorn und Traefik/HTTPS
|
||||||
|
- Healthchecks, persistente Volumes und einfache Backup-/Restore-Skripte
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
Voraussetzungen: Python 3.12 oder neuer und PostgreSQL 17, oder Docker Compose.
|
||||||
|
Die Anwendung verwendet auch lokal PostgreSQL. Für bestehende SQLite-Daten siehe
|
||||||
|
[Docker-Migration zu PostgreSQL](docs/postgresql-migration.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD und POSTGRES_HOST
|
||||||
|
# in der Prozessumgebung setzen (lokal typischerweise POSTGRES_HOST=localhost).
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py seed_initial_data
|
||||||
|
python manage.py createsuperuser
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne PostgreSQL-Verbindung startet die Datenbankfunktion nicht; es wird keine
|
||||||
|
SQLite-Datenbank mehr automatisch geöffnet oder angelegt. `.env` wird von Docker
|
||||||
|
Compose geladen, bei direktem Python-Start müssen die Variablen gesetzt sein.
|
||||||
|
Der [Migrationsleitfaden](docs/postgresql-migration.md) enthält auch einen lokalen
|
||||||
|
Docker-Start ohne HTTPS-Domain.
|
||||||
|
|
||||||
|
Testdaten können gezielt erzeugt werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py generate_test_devices 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation auf Netcup/Linux
|
||||||
|
|
||||||
|
### 1. Server vorbereiten
|
||||||
|
|
||||||
|
Empfohlen sind Ubuntu 24.04 oder Debian 12, mindestens 2 GB RAM, eine feste IPv4/IPv6-Adresse und eine Domain. In DNS einen A- und optional AAAA-Record der gewünschten Subdomain auf den Server zeigen lassen. Die Ports 22, 80 und 443 in der Firewall öffnen.
|
||||||
|
|
||||||
|
Docker gemäß der offiziellen Docker-Anleitung installieren. Anschließend prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Projekt und Umgebungsvariablen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <REPOSITORY-URL> csr-inventory
|
||||||
|
cd csr-inventory
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
In `.env` mindestens ändern:
|
||||||
|
|
||||||
|
- `SECRET_KEY`: langer Zufallswert, z. B. mit `openssl rand -base64 48`
|
||||||
|
- `POSTGRES_PASSWORD`: starkes, einmaliges Datenbankpasswort
|
||||||
|
- `APP_HOST`: Domain ohne Protokoll/Port, z. B. `geraete.example.org`
|
||||||
|
- `ACME_EMAIL`: Kontaktadresse für öffentliche TLS-Zertifikate
|
||||||
|
- `ALLOWED_HOSTS`: dieselbe Domain sowie `127.0.0.1,localhost` für den internen Healthcheck
|
||||||
|
- `CSRF_TRUSTED_ORIGINS`: vollständige HTTPS-URL
|
||||||
|
|
||||||
|
Die Datei `.env` niemals in Git einchecken.
|
||||||
|
|
||||||
|
### 3. Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f web traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Start wartet der Webdienst auf PostgreSQL, führt Migrationen und das idempotente Seed-Command aus und sammelt statische Dateien. Traefik fordert anschließend automatisch ein TLS-Zertifikat an.
|
||||||
|
|
||||||
|
### 4. Ersten Administrator anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach unter `https://IHRE-DOMAIN/accounts/login/` anmelden. Weitere Konten und ihre Rollen `ADMIN`, `EDITOR` oder `VIEWER` werden direkt im Bereich „Benutzer“ verwaltet. Es gibt keine öffentliche Registrierung.
|
||||||
|
|
||||||
|
## Aktualisierung
|
||||||
|
|
||||||
|
Vor jeder Aktualisierung ein Backup erstellen. Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f web
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrationen und Seed-Daten werden beim Containerstart automatisch angewendet.
|
||||||
|
|
||||||
|
## Sichere API für interne Skripte
|
||||||
|
|
||||||
|
Die API läuft unter derselben HTTPS-Domain wie die Anwendung, zum Beispiel
|
||||||
|
`https://inventory.example.org/api/v1/`. Eine zusätzliche FastAPI-Anwendung oder
|
||||||
|
eine zweite Subdomain ist für diesen internen Anwendungsfall nicht nötig. Das hält
|
||||||
|
Betrieb, Rechteprüfung und Änderungsprotokoll an einer Stelle.
|
||||||
|
|
||||||
|
Browser-Sitzung und API sind bewusst getrennt: Auch ein im Browser angemeldeter
|
||||||
|
Benutzer kann die API ohne API-Schlüssel nicht lesen. Schlüssel werden nur gehasht
|
||||||
|
gespeichert, sind widerrufbar, laufen standardmäßig nach 90 Tagen ab und ihr
|
||||||
|
Klartext wird nur beim Anlegen einmal angezeigt.
|
||||||
|
|
||||||
|
Einen reinen Leseschlüssel für ein aktives Konto erstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web python manage.py create_api_key ausgabedokumente JohannesHundshammer --read-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Einen Schreibschlüssel für ein Konto mit Rolle `EDITOR` oder `ADMIN` erstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web python manage.py create_api_key hardware-test-team2 JohannesHundshammer --write --expires-days 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Den ausgegebenen Schlüssel sofort in einem Secret-Store oder in einer nur für den
|
||||||
|
jeweiligen Benutzer lesbaren Umgebungsdatei speichern. Niemals in Quellcode, Git,
|
||||||
|
Tickets oder Logdateien schreiben. Im Django-Admin unter `/admin/` können
|
||||||
|
Administratoren Schlüssel sperren und die letzte Nutzung prüfen.
|
||||||
|
|
||||||
|
Beispiel: Gerät 30 lesen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail --silent \
|
||||||
|
-H "Authorization: Api-Key $CSR_API_KEY" \
|
||||||
|
https://inventory.example.org/api/v1/devices/30/
|
||||||
|
```
|
||||||
|
|
||||||
|
Für eine Änderung zuerst `revision` lesen und als `expected_revision`
|
||||||
|
zurücksenden. Bestehende Clients können weiterhin `updated_at` als
|
||||||
|
`expected_updated_at` senden. Veraltete Versionen werden mit HTTP 409 abgewiesen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail --silent --request PATCH \
|
||||||
|
-H "Authorization: Api-Key $CSR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data '{"cpu":"Intel Core i5-8500","ram_gb":16,"hwid":"ABC-123","expected_revision":1}' \
|
||||||
|
https://inventory.example.org/api/v1/devices/30/
|
||||||
|
```
|
||||||
|
|
||||||
|
Mehrere Geräte atomar ändern (höchstens 500 pro Anfrage):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail --silent --request POST \
|
||||||
|
-H "Authorization: Api-Key $CSR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data '{"device_numbers":[30,31,32],"changes":{"status":2,"recipient":5}}' \
|
||||||
|
https://inventory.example.org/api/v1/devices/bulk-update/
|
||||||
|
```
|
||||||
|
|
||||||
|
Verfügbare Auswahl-IDs liefert `GET /api/v1/choices/`. Die Geräteliste unter
|
||||||
|
`GET /api/v1/devices/` unterstützt `number`, `number_prefix`, `status`, `type`,
|
||||||
|
`employee`, `changed_since`, `archived`, `page` und `page_size` (maximal 100).
|
||||||
|
|
||||||
|
Die API nimmt ausschließlich bekannte Gerätefelder an. Benutzerpasswörter,
|
||||||
|
Passwort-Hashes und Kontoverwaltung sind absichtlich nicht Teil der API. Jede
|
||||||
|
Feldänderung enthält in der Änderungshistorie sowohl das zugeordnete Konto als
|
||||||
|
auch den Namen des verwendeten API-Clients.
|
||||||
|
|
||||||
|
## Backup und Wiederherstellung
|
||||||
|
|
||||||
|
Backup-Verzeichnis anlegen und Skripte ausführbar machen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/*.sh
|
||||||
|
./scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Ergebnis liegt beispielsweise unter `backups/backup_2026-09-19_0200.sql.gz`. Backups regelmäßig auf ein anderes System kopieren.
|
||||||
|
|
||||||
|
Wiederherstellung zuerst in einer Testumgebung prüfen. Für eine leere Zieldatenbank:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore.sh backups/backup_2026-09-19_0200.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel für einen täglichen Cronjob um 02:00 Uhr:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 2 * * * cd /opt/csr-inventory && ./scripts/backup.sh >> /var/log/csr-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests und Prüfungen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test
|
||||||
|
python manage.py check
|
||||||
|
python manage.py makemigrations --check --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Befehle verwenden PostgreSQL. CI prüft PostgreSQL 17 einschließlich
|
||||||
|
gleichzeitiger Bearbeitungen. Ohne PostgreSQL können reine Unit-Tests explizit
|
||||||
|
mit `python manage.py test --settings=csr_inventory.test_settings` ausgeführt
|
||||||
|
werden; PostgreSQL-Sperrtests werden dabei übersprungen. Für Tests `DEBUG=true`
|
||||||
|
und `SECURE_SSL_REDIRECT=false` in der Prozessumgebung setzen.
|
||||||
|
|
||||||
|
Docker-Prüfung auf einem Rechner mit Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
curl -f https://IHRE-DOMAIN/health/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Importformat
|
||||||
|
|
||||||
|
Der XLSX-Import erkennt typische deutsche Spaltennamen automatisch. Unterstützte Core-Spalten sind:
|
||||||
|
|
||||||
|
`Nr.`, `Regal`, `Status`, `Datum`, `Empfänger`, `Ort`, `Ticket`, `Typ`, `Preis`, `Akku`, `WLAN`, `LAN`, `Zoll`, `RAM`, `SSD`, `HDD`, `CPU`, `HWID`, `Modell`, `Bearbeiter`, `Spender`, `Kommentar`.
|
||||||
|
|
||||||
|
Vor dem Import erscheint eine Vorschau mit Spaltenzuordnung. Bereits vorhandene Gerätenummern führen zum vollständigen Abbruch; es bleibt kein Teilimport zurück. Mitarbeiter werden über ihr Kürzel zugeordnet.
|
||||||
|
|
||||||
|
## Betrieb und Sicherheit
|
||||||
|
|
||||||
|
- Nur Traefik veröffentlicht Ports; PostgreSQL ist nicht von außen erreichbar.
|
||||||
|
- Excel-Uploads werden nicht öffentlich über Traefik ausgeliefert.
|
||||||
|
- CSRF-Schutz, sichere Cookies, HSTS, serverseitige Rollenprüfung und Django-Passworthashing sind im Produktionsmodus aktiv.
|
||||||
|
- Die API akzeptiert keine Browser-Sitzungen, liefert nur JSON, begrenzt Seitengrößen und drosselt wiederholte Anfragen.
|
||||||
|
- Technische Fehler werden nur in Container-Logs geschrieben: `docker compose logs web`.
|
||||||
|
- Für mindestens monatliche Sicherheitsupdates die Basisimages neu bauen: `docker compose build --pull`.
|
||||||
|
- Für große Datenbestände nach umfangreichen Importen PostgreSQL analysieren: `docker compose exec db vacuumdb -U "$POSTGRES_USER" -d "$POSTGRES_DB" --analyze`.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- Django Templates und leichtes Vanilla-JavaScript; kein separater Frontend-Build
|
||||||
|
- Django ORM und Migrationen
|
||||||
|
- PostgreSQL 17 mit `pg_trgm`
|
||||||
|
- Gunicorn als WSGI-Server
|
||||||
|
- Traefik als Reverse Proxy mit automatischem HTTPS
|
||||||
|
- Keine Microservices, kein Redis, kein Elasticsearch
|
||||||
|
|
||||||
|
## Traefik und LAN-Zertifikate
|
||||||
|
|
||||||
|
Traefik verwendet einen File-Provider ohne Docker-Socket. Statische Dateien liefert WhiteNoise aus. Öffentliche Domains nutzen Let's Encrypt; für private IP-Adressen siehe [Traefik-Betrieb](docs/traefik.md).
|
||||||
|
|
||||||
|
## Geräteablauf und Aufgaben
|
||||||
|
|
||||||
|
Jedes Gerät hat einen eigenen Arbeitsschritt: Eingang → Inspektion →
|
||||||
|
bei Bedarf Reparatur → Lagerung → Ausgabe. Bearbeiter können auf der
|
||||||
|
Geräteseite Schritte mit Notiz dokumentieren und Aufgaben mit Fälligkeit
|
||||||
|
und zuständiger Person anlegen. Offene Aufgaben stehen unter „Aufgaben“.
|
||||||
|
Vor Lagerung oder Ausgabe müssen sie erledigt sein; für die Ausgabe ist
|
||||||
|
ein Empfänger erforderlich. Eine Rückgabe beginnt wieder beim Eingang.
|
||||||
|
Änderungen erscheinen in der Ablaufhistorie und im Auditprotokoll. Bei Reparatur, Lagerung und Ausgabe wird der bisherige Bestandsstatus automatisch auf Defekt, Fertig beziehungsweise Ausgegeben gesetzt.
|
||||||
|
|
||||||
|
Die Gerätetypen Laptop, AiO, Desktop, Bildschirm und Drucker zeigen
|
||||||
|
nur passende Hardwarefelder. Bei einem Typwechsel werden nicht mehr
|
||||||
|
passende Werte unter `custom_values.legacy_hardware` erhalten und aus
|
||||||
|
den aktiven Feldern entfernt. `inventory_device` enthält nur gemeinsame
|
||||||
|
Gerätedaten. Laptop, Desktop, AiO, Monitor und Drucker besitzen eigene
|
||||||
|
Spezifikationstabellen; die bisherige Computertabelle bleibt für frei
|
||||||
|
definierte Gerätetypen. Monitore haben unter anderem Diagonale, Auflösung
|
||||||
|
und Anschlüsse, Laptops RAM, SSD/HDD, CPU, Akku und Anschlüsse. Drucker
|
||||||
|
haben WLAN/LAN, Anschlüsse, Druckverfahren, Duplex und Farbdruck.
|
||||||
|
Migration 0012 verschiebt bestehende Computerwerte in die passenden Tabellen.
|
||||||
|
Export, Filter und API behalten die bisherigen Feldnamen und ergänzen die
|
||||||
|
neuen Typfelder. Details stehen in [Datenbankschema](docs/datenbankschema.md).
|
||||||
|
|
||||||
|
Über „Ablauf“ in der Hauptnavigation sind alle Arbeitsschritte direkt
|
||||||
|
erreichbar. Auf dem Board lassen sich Aufgaben anlegen und Geräte zum
|
||||||
|
nächsten Schritt bewegen. Ein Klick auf eine Tabellenzeile öffnet die
|
||||||
|
Geräteseite; Ablauf und Aufgaben stehen dort vor den Stammdaten.
|
||||||
|
In der Inspektion werden Mängel und passende technische Daten erfasst,
|
||||||
|
bei der Reparatur die durchgeführten Arbeiten und in der Lagerung der
|
||||||
|
Empfänger. Befunde und Reparaturen bleiben mit Zeit und Bearbeiter als
|
||||||
|
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.
|
||||||
175
changes.md
Normal file
175
changes.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 23. September 2026
|
||||||
|
|
||||||
|
### PostgreSQL und Docker
|
||||||
|
|
||||||
|
- PostgreSQL ist jetzt die Standarddatenbank für Entwicklung und Produktion. Die Anwendung wechselt bei fehlender Konfiguration nicht mehr automatisch zu SQLite.
|
||||||
|
- Ein separater Docker-Dienst `migration` stellt die Werkzeuge für Export, Schema-Migration und Datenimport bereit.
|
||||||
|
- `docker-compose.dev.yml` ermöglicht den lokalen Betrieb unter `http://localhost:8000` ohne HTTPS-Domain. Der Webport ist nur an localhost gebunden.
|
||||||
|
- Erforderliche PostgreSQL-Variablen werden in Docker Compose auf fehlende Werte geprüft.
|
||||||
|
- SQLite bleibt ausdrücklich für isolierte Unit-Tests und temporäre Migrationskopien verfügbar.
|
||||||
|
|
||||||
|
### Übernahme bestehender SQLite-Daten
|
||||||
|
|
||||||
|
- `scripts/export_sqlite.py` erstellt einen konsistenten temporären Snapshot und exportiert dessen Daten. Die Quelldatenbank wird schreibgeschützt geöffnet und bleibt unverändert.
|
||||||
|
- Vorhandene Exportdateien werden nicht überschrieben.
|
||||||
|
- Das Kommando `import_sqlite_export` übernimmt den Export transaktional in eine vorbereitete, leere PostgreSQL-Datenbank. Vorhandene Inventardaten, Benutzer oder Gruppen führen zum Abbruch.
|
||||||
|
- Die beiden von einer früheren Migration angelegten Standard-Gerätetypen werden als Ausgangsdaten erkannt und innerhalb der Importtransaktion ersetzt.
|
||||||
|
- Benutzer, Passwort-Hashes, Rollen, API-Schlüssel-Hashes, Geräte, Archivstatus, Zusatzfelder, Einstellungen und Historie werden übernommen. Sitzungen werden nicht übertragen; eine erneute Anmeldung ist erforderlich.
|
||||||
|
- ID-Sequenzen und die Gerätenummernfolge werden beim Import berücksichtigt. Upload-Dateien müssen separat in das Medienvolume kopiert werden.
|
||||||
|
- Die vollständigen Befehle stehen im [Leitfaden zur PostgreSQL-Migration](docs/postgresql-migration.md).
|
||||||
|
|
||||||
|
### Sicherheit und Datenintegrität
|
||||||
|
|
||||||
|
- **Gleichzeitige Bearbeitung:** Geräte besitzen eine ganzzahlige `revision`. Browser-Formulare senden `expected_revision`; veraltete Versionen werden abgewiesen. Die Detailansicht sperrt den Datensatz während der Prüfung und Speicherung innerhalb einer Transaktion.
|
||||||
|
- **API-Kompatibilität:** PATCH-Anfragen unterstützen `expected_revision`. Bestehende Clients können weiterhin `expected_updated_at` verwenden. Zeitstempel steigen auch bei geringer Auflösung der Systemuhr strikt an.
|
||||||
|
- **Datenbankschema:** Migration `0006_device_revision` ergänzt das Versionsfeld und passt die Aktualisierung des Zeitstempels an. Bestehende Geräte erhalten zunächst Revision 1.
|
||||||
|
- **Archivierung und Wiederherstellung:** Datensatzänderung und Audit-Eintrag erfolgen gemeinsam in einer Transaktion mit Datensatzsperre.
|
||||||
|
- **Bestätigungsdialoge:** Mitarbeiternamen und andere Dialogtexte werden als Text eingefügt und nicht mehr als HTML interpretiert.
|
||||||
|
- **Tabellenexporte:** XLSX-Textwerte werden ausdrücklich als Textzellen geschrieben. Potenziell als Formeln interpretierte CSV-Textwerte erhalten ein vorangestelltes Apostroph. Numerische Werte bleiben Zahlen.
|
||||||
|
- **Eingabeprüfung:** Ungültige numerische Filter, fehlerhafte JSON-Strukturen und ungültige Spalteneinstellungen werden abgefangen. Nicht endliche Dezimalwerte wie `NaN` und `Infinity` werden bei der entsprechenden Werteingabe abgewiesen. Ganzzahlfelder akzeptieren keine stillschweigend abgeschnittenen Nachkommastellen mehr.
|
||||||
|
|
||||||
|
### Bedienung und Konfiguration
|
||||||
|
|
||||||
|
- Inline-Auswahllisten für Mitarbeiter und Auswahlwerte sind nicht mehr auf die ersten 15 Einträge beschränkt.
|
||||||
|
- Die aktuelle Auswahl bleibt im Inline-Editor erhalten, auch wenn sie deaktiviert wurde oder nicht in der Antwort enthalten ist.
|
||||||
|
- Das Verlassen eines unveränderten Inline-Feldes löst keinen Speichervorgang mehr aus.
|
||||||
|
- Der Seed-Befehl legt nur fehlende Einträge an. Angepasste Mitarbeiternamen, Statusfarben, Sortierungen und deaktivierte Einträge werden beim Containerstart nicht mehr überschrieben.
|
||||||
|
- Die geänderten JavaScript-Abschnitte für Bestätigungsdialoge und Inline-Bearbeitung wurden lesbarer strukturiert.
|
||||||
|
|
||||||
|
### Backup und Wiederherstellung
|
||||||
|
|
||||||
|
- Datenbankexport und Komprimierung werden getrennt auf Erfolg geprüft. Ein fehlgeschlagener `pg_dump` erzeugt keine als erfolgreich gemeldete Sicherung mehr.
|
||||||
|
- Sicherungen werden zunächst in einem temporären Verzeichnis erstellt, auf gültige Komprimierung geprüft und erst danach als fertige Datei abgelegt.
|
||||||
|
- Die Backup- und Restore-Skripte führen `.env` nicht mehr als Shellcode aus. Verbindungswerte werden innerhalb des Datenbankcontainers verwendet.
|
||||||
|
- Vor einer Wiederherstellung wird das Archiv vollständig entpackt und geprüft. SQL-Fehler brechen die Wiederherstellung innerhalb einer gemeinsamen Transaktion ab.
|
||||||
|
- Temporäre Dateien werden aufgeräumt; unter POSIX werden restriktive Dateirechte verwendet.
|
||||||
|
- `.gitattributes` legt LF-Zeilenenden für Shellskripte fest.
|
||||||
|
|
||||||
|
### Prüfungen und aktueller Stand
|
||||||
|
|
||||||
|
- **Python:** 53 Tests ausgeführt; 52 erfolgreich, ein PostgreSQL-Sperrtest mangels PostgreSQL-Laufzeit übersprungen. Der zuvor fehlgeschlagene Test zur Erkennung veralteter Änderungen besteht jetzt.
|
||||||
|
- **Frontend:** DOM-basierte Regressionstests für sichere Dialogtexte, erhaltene Auswahlwerte, unveränderte Felder und Speicherung mit Versionsprüfung erfolgreich.
|
||||||
|
- **Backup:** Vier Prüfungen für erfolgreichen Export/Import, fehlgeschlagenen Datenbankexport, beschädigte Archive und Fehler beim Restore erfolgreich.
|
||||||
|
- **Django:** Systemprüfungen erfolgreich; keine fehlenden Modellmigrationen erkannt.
|
||||||
|
- **Konfiguration:** YAML-Syntax der Docker-Compose-Dateien und des CI-Workflows geprüft; JavaScript-Syntax geprüft.
|
||||||
|
- **Datenexport:** Die vorhandenen SQLite-Daten wurden probeweise exportiert und in ein frisches temporäres SQLite-Schema geladen: 109 Datensätze aus neun Modellen. Die Prüfsumme der ursprünglichen SQLite-Datei blieb unverändert.
|
||||||
|
- **CI vorbereitet:** Der neue Workflow führt die Tests mit PostgreSQL 17 sowie die Backup- und Frontend-Prüfungen aus.
|
||||||
|
|
||||||
|
**Stand der ursprünglichen Vorbereitung:** Auf dem Windows-Rechner war Docker nicht installiert. Die folgenden Schritte wurden anschließend auf dem Server durchgeführt; die lokale SQLite-Datenbank blieb dabei unverändert.
|
||||||
|
|
||||||
|
### Serverinstallation auf 192.168.188.45
|
||||||
|
|
||||||
|
- Das Projekt wurde unter `/home/denis/csr-inventory` mit PostgreSQL, Gunicorn und Caddy installiert.
|
||||||
|
- Alle **53 Tests bestanden auf PostgreSQL**, einschließlich des zuvor lokal übersprungenen Sperrtests. Die Django-Produktionsprüfung meldete keine Beanstandungen.
|
||||||
|
- Die 109 Datensätze der lokalen Projektdatenbank wurden übernommen, darunter zwei Geräte und die vorhandenen Benutzerkonten.
|
||||||
|
- Die bisher laufende andere Inventarsoftware wurde nach Sicherung von Datenbank, Konfiguration und Uploads entfernt. Ihre Volumes und Quelldateien bleiben für eine mögliche Wiederherstellung erhalten.
|
||||||
|
- Die neue Anwendung ist unter **https://192.168.188.45/** erreichbar. Port 8001 leitet auf HTTPS weiter.
|
||||||
|
- Eine LAN-Konfiguration für Caddy ergänzt lokale Zertifikate und den Zugriff per IP-Adresse. Das öffentliche Stammzertifikat liegt unter `docs/homelab-root-ca.crt`; es wurde nicht automatisch in den Windows-Zertifikatsspeicher importiert.
|
||||||
|
- Tägliche Datenbank-Backups um 02:00 Uhr Berliner Zeit wurden über einen systemd-Timer eingerichtet. Eine Sicherung wurde erfolgreich in einer separaten Prüfdatenbank wiederhergestellt.
|
||||||
|
- HTTPS, Healthcheck, Loginseite, statische Dateien, API-Zugriffsschutz und die Weiterleitung wurden geprüft. Andere Anwendungen auf dem Server laufen unverändert weiter.
|
||||||
|
- Zugriff, Zertifikatsinstallation und Wartung sind in [docs/serverbetrieb.md](docs/serverbetrieb.md) beschrieben.
|
||||||
|
|
||||||
|
## 26. September 2026 – Wechsel zu Traefik
|
||||||
|
|
||||||
|
- Caddy durch Traefik v3.7.13 im Projekt und auf dem Server ersetzt.
|
||||||
|
- Router und Sicherheitsheader werden über Dateien konfiguriert; kein Docker-Socket und kein öffentliches Dashboard.
|
||||||
|
- HTTPS-Adresse und Weiterleitungen von Port 80 und 8001 bleiben erhalten. Statische Dateien liefert WhiteNoise aus.
|
||||||
|
- Die bisherige Stamm-CA wurde übernommen; bereits eingerichtetes Clientvertrauen bleibt gültig.
|
||||||
|
- Ein systemd-Timer prüft täglich die LAN-Zertifikate. Erneuerungen werden ohne Proxy-Neustart geladen; der CA-Schlüssel bleibt auf dem Host.
|
||||||
|
- Konfiguration, CA-Zustand und PostgreSQL wurden vor der Umstellung gesichert. Anwendung und Datenbank wurden nicht neu erstellt.
|
||||||
|
- Vier Zertifikatstests bestanden. HTTPS, Loginseite, CSS, Logo, API-Zugriffsschutz, Weiterleitungen und das Nachladen eines erneuerten Zertifikats wurden geprüft.
|
||||||
|
- Compose-Dateien, CI und Betriebsdokumentation aktualisiert; Details in [docs/traefik.md](docs/traefik.md).
|
||||||
|
|
||||||
|
## 26. September 2026 – Geräteablauf und Aufgaben
|
||||||
|
|
||||||
|
- Ein Gerät durchläuft Eingang, Inspektion, gegebenenfalls Reparatur,
|
||||||
|
Lagerung und Ausgabe. Übergänge werden mit Benutzer, Zeit und Notiz
|
||||||
|
protokolliert. Der Bestandsstatus wird bei Reparatur, Lagerung und Ausgabe
|
||||||
|
automatisch abgeglichen.
|
||||||
|
- Aufgaben haben Status, Fälligkeitsdatum und optionale Zuständigkeit.
|
||||||
|
Offene Aufgaben sperren Lagerung und Ausgabe. Die Aufgabenübersicht
|
||||||
|
zeigt offene und erledigte Arbeiten.
|
||||||
|
- Eingabe, Detailansicht und Tabellenzeilen zeigen Hardwarefelder passend
|
||||||
|
zum Gerätetyp; ein Drucker hat keine RAM-Eingabe mehr.
|
||||||
|
- Schema-Migrationen 0007/0008 ergänzen Ablauf und Aufgaben und ordnen
|
||||||
|
bestehende Geräte anhand ihres Status ein. Nicht passende ältere
|
||||||
|
Hardwarewerte bleiben unter `custom_values.legacy_hardware` erhalten.
|
||||||
|
- Bestehende Benutzer, Geräte und Nummern bleiben erhalten. Die
|
||||||
|
Hardware-Spalten der Basistabelle bleiben für bestehende Exporte und
|
||||||
|
API-Clients vorhanden; bei unpassenden Typen sind sie leer.
|
||||||
|
|
||||||
|
- Auf Server 192.168.188.45 nach PostgreSQL-Sicherung migriert und ausgerollt.
|
||||||
|
Alle 58 Tests bestanden mit PostgreSQL; 2 Geräte und 4 Konten blieben erhalten.
|
||||||
|
|
||||||
|
## 26. September 2026 – Datenbank und Bedienung überarbeitet
|
||||||
|
|
||||||
|
- Gemeinsame Gerätedaten und typabhängige Hardwaredaten getrennt. Computer,
|
||||||
|
Bildschirme und Drucker erhalten jeweils eine eigene Spezifikationstabelle.
|
||||||
|
Migration 0009 übernimmt die bisherigen Werte vor dem Entfernen der alten
|
||||||
|
Hardwarespalten. Export, Suche und API verwenden weiter dieselben Feldnamen.
|
||||||
|
- Zusätzliche Datenbankregeln sichern gültige Ablauf- und Aufgabenstände ab;
|
||||||
|
Indizes beschleunigen häufige Aufgaben- und Verlaufsabfragen (Migration 0010).
|
||||||
|
- „Ablauf“ ist jetzt direkt über die Hauptnavigation erreichbar. Das Board
|
||||||
|
zeigt Geräte nach Arbeitsschritt und erlaubt neue Aufgaben sowie Übergänge
|
||||||
|
direkt auf der Karte. Tabellenzeilen öffnen die vollständige Geräteseite;
|
||||||
|
dort stehen Aufgaben und Ablauf vor dem Bearbeitungsformular.
|
||||||
|
|
||||||
|
## 26. September 2026 – Eingaben je Arbeitsschritt
|
||||||
|
|
||||||
|
- In der Inspektion können Mängel und die zum Gerätetyp passenden technischen
|
||||||
|
Daten direkt auf dem Ablauf-Board erfasst werden. Die Hardwarewerte werden
|
||||||
|
in den bestehenden typbezogenen Spezifikationstabellen gespeichert.
|
||||||
|
- Für Reparaturen werden die durchgeführten Arbeiten mit Zeit und Bearbeiter
|
||||||
|
als eigene Einträge dokumentiert. Inspektionsbefunde sind ebenso historisiert.
|
||||||
|
- In der Lagerung kann der Empfänger direkt eingetragen werden. Der vorhandene
|
||||||
|
Empfänger wird wiederverwendet oder neu angelegt; vor der Ausgabe bleibt
|
||||||
|
ein Empfänger verpflichtend.
|
||||||
|
- Migration 0011 ergänzt die Tabellen `inventory_inspectionrecord` und
|
||||||
|
`inventory_repairrecord`. Bestehende Geräte und Ablaufdaten bleiben erhalten.
|
||||||
|
|
||||||
|
- Die Ablauf-Übersicht zählt offene Aufgaben aus den bereits geladenen
|
||||||
|
Aufgabendaten. Dadurch entfällt eine teure Aggregation pro Arbeitsschritt.
|
||||||
|
|
||||||
|
## 28. September 2026 – Getrennte Gerätetyp-Tabellen
|
||||||
|
|
||||||
|
- Eigene 1:1-Spezifikationstabellen für Laptop, Desktop und AiO ergänzt.
|
||||||
|
Bildschirm/Monitor und Drucker behalten ihre getrennten Tabellen; die
|
||||||
|
allgemeine Computertabelle dient nur noch frei definierten Typen.
|
||||||
|
- Monitore haben nun Auflösung und Anschlüsse. Drucker haben Anschlüsse,
|
||||||
|
Druckverfahren, Duplex und Farbdruck. Die Computer-Typen haben passende
|
||||||
|
Hardwarefelder einschließlich Anschlüssen.
|
||||||
|
- Formulare, Inspektions-Board, API, Suche, Filter sowie Excel-Import und
|
||||||
|
-Export verwenden die neuen Felder und Tabellen.
|
||||||
|
- Migration 0012 überträgt bestehende Laptop-, Desktop- und AiO-Werte.
|
||||||
|
Geräte, Aufgaben, Benutzer und Ablaufhistorien bleiben erhalten.
|
||||||
|
|
||||||
|
- Für Datenbankwerkzeuge ist PostgreSQL über `127.0.0.1:15432` auf dem
|
||||||
|
Server und einen SSH-Tunnel erreichbar. Port 15432 ist nicht im LAN offen.
|
||||||
|
|
||||||
|
## 28. September 2026 – Dynamische Geräteformulare
|
||||||
|
|
||||||
|
- Bei „Neues Gerät“ erscheinen die passenden technischen Felder unmittelbar
|
||||||
|
nach Auswahl des Gerätetyps. Modell und technische Daten werden auch bei der
|
||||||
|
Mehrfachanlage in den jeweiligen Spezifikationstabellen gespeichert.
|
||||||
|
- Auf der Geräteseite wechseln die sichtbaren Hardwarefelder ebenfalls sofort
|
||||||
|
beim Typwechsel, ohne Aktualisieren der Seite. Nicht passende Eingaben werden
|
||||||
|
beim Speichern serverseitig ignoriert; bestehende Werte bleiben bei einem
|
||||||
|
Typwechsel in den bisherigen Verlaufsdaten erhalten.
|
||||||
|
- Übersicht, Geräteliste, Ablauf und Aufgaben aktualisieren sich nach Änderungen
|
||||||
|
in einem anderen offenen Tab automatisch. Beim Zurückkehren in einen älteren
|
||||||
|
Tab werden die Daten ebenfalls neu geladen, solange keine Eingabe bearbeitet wird.
|
||||||
|
- Das vorhandene CS-R-Logo ist jetzt das Favicon auf allen Seiten, einschließlich
|
||||||
|
Anmeldung und Fehlerseite.
|
||||||
|
- Ein Hell-/Dunkelmodus-Schalter steht in der Seitenleiste, auf Mobilgeräten in
|
||||||
|
der Kopfzeile und auf der Anmeldeseite bereit. Die Auswahl bleibt im Browser
|
||||||
|
gespeichert; ohne Auswahl gilt die Systemeinstellung.
|
||||||
|
|
||||||
|
## 29. September 2026 – Repository aufgeräumt
|
||||||
|
|
||||||
|
- Das alte Projektarchiv wurde in den lokalen, nicht versionierten Ordner
|
||||||
|
`backups/` verschoben. Es bleibt als Sicherung erhalten.
|
||||||
|
- Git- und Docker-Ausschlüsse für lokale Datenbanken, Uploads, Archive,
|
||||||
|
Caches und erzeugte Dateien wurden ergänzt. Quelltexte verwenden in Git
|
||||||
|
einheitliche LF-Zeilenenden.
|
||||||
1
csr_inventory/__init__.py
Normal file
1
csr_inventory/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
144
csr_inventory/settings.py
Normal file
144
csr_inventory/settings.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "development-only-change-me")
|
||||||
|
DEBUG = env_bool("DEBUG", True)
|
||||||
|
if not DEBUG and SECRET_KEY == "development-only-change-me":
|
||||||
|
raise ImproperlyConfigured("SECRET_KEY muss im Produktionsbetrieb gesetzt sein.")
|
||||||
|
ALLOWED_HOSTS = [value.strip() for value in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if value.strip()]
|
||||||
|
CSRF_TRUSTED_ORIGINS = [value.strip() for value in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") if value.strip()]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"rest_framework_api_key",
|
||||||
|
"inventory",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "csr_inventory.urls"
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"inventory.context_processors.navigation_context",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
WSGI_APPLICATION = "csr_inventory.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": os.getenv("POSTGRES_DB", "csr_inventory"),
|
||||||
|
"USER": os.getenv("POSTGRES_USER", "csr_inventory"),
|
||||||
|
"PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
|
||||||
|
"HOST": os.getenv("POSTGRES_HOST", "localhost"),
|
||||||
|
"PORT": os.getenv("POSTGRES_PORT", "5432"),
|
||||||
|
"CONN_MAX_AGE": 60,
|
||||||
|
"OPTIONS": {"connect_timeout": 5},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "de-de"
|
||||||
|
TIME_ZONE = os.getenv("TIME_ZONE", "Europe/Berlin")
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
MEDIA_URL = "/media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
LOGIN_URL = "login"
|
||||||
|
LOGIN_REDIRECT_URL = "inventory:device-list"
|
||||||
|
LOGOUT_REDIRECT_URL = "login"
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", str(10 * 1024 * 1024)))
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE = DATA_UPLOAD_MAX_MEMORY_SIZE
|
||||||
|
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
SESSION_COOKIE_SECURE = env_bool("SECURE_COOKIES", not DEBUG)
|
||||||
|
CSRF_COOKIE_SECURE = env_bool("SECURE_COOKIES", not DEBUG)
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
CSRF_COOKIE_HTTPONLY = False
|
||||||
|
CSRF_COOKIE_SAMESITE = "Lax"
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
SECURE_REFERRER_POLICY = "same-origin"
|
||||||
|
X_FRAME_OPTIONS = "DENY"
|
||||||
|
SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG
|
||||||
|
SECURE_HSTS_PRELOAD = not DEBUG
|
||||||
|
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", not DEBUG)
|
||||||
|
|
||||||
|
STORAGES = {
|
||||||
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" if DEBUG
|
||||||
|
else "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Die API verwendet absichtlich keine Browser-Sitzung. Ohne einen gültigen,
|
||||||
|
# widerrufbaren API-Schlüssel wird jede API-Anfrage abgewiesen.
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": ["inventory.api_auth.InventoryAPIKeyAuthentication"],
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": ["inventory.api_auth.InventoryAPIPermission"],
|
||||||
|
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
|
||||||
|
"DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"],
|
||||||
|
"DEFAULT_PAGINATION_CLASS": "inventory.api_pagination.PagedDeviceResults",
|
||||||
|
"PAGE_SIZE": 50,
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
"inventory.api_auth.APIClientRateThrottle",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
|
"anon": os.getenv("API_ANON_RATE", "20/min"),
|
||||||
|
"api_client": os.getenv("API_CLIENT_RATE", "300/min"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {"standard": {"format": "{levelname} {asctime} {name}: {message}", "style": "{"}},
|
||||||
|
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "standard"}},
|
||||||
|
"root": {"handlers": ["console"], "level": os.getenv("LOG_LEVEL", "WARNING")},
|
||||||
|
}
|
||||||
10
csr_inventory/sqlite_migration_settings.py
Normal file
10
csr_inventory/sqlite_migration_settings.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Used only by scripts/export_sqlite.py on its temporary database snapshot."""
|
||||||
|
import os
|
||||||
|
from .settings import * # noqa: F403
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": os.environ["SQLITE_MIGRATION_SNAPSHOT"],
|
||||||
|
}
|
||||||
|
}
|
||||||
12
csr_inventory/test_settings.py
Normal file
12
csr_inventory/test_settings.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Explicit SQLite fallback for unit tests when PostgreSQL is unavailable.
|
||||||
|
|
||||||
|
Production and the default test command always use PostgreSQL.
|
||||||
|
"""
|
||||||
|
from .settings import * # noqa: F403
|
||||||
|
|
||||||
|
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
|
||||||
|
SECURE_SSL_REDIRECT = False
|
||||||
|
STORAGES = {
|
||||||
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
|
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||||
|
}
|
||||||
15
csr_inventory/urls.py
Normal file
15
csr_inventory/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/v1/", include("inventory.api_urls")),
|
||||||
|
path("accounts/login/", auth_views.LoginView.as_view(), name="login"),
|
||||||
|
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||||
|
path("", include("inventory.urls")),
|
||||||
|
]
|
||||||
|
|
||||||
|
handler403 = "inventory.error_views.permission_denied"
|
||||||
|
handler404 = "inventory.error_views.not_found"
|
||||||
|
handler500 = "inventory.error_views.server_error"
|
||||||
5
csr_inventory/wsgi.py
Normal file
5
csr_inventory/wsgi.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "csr_inventory.settings")
|
||||||
|
application = get_wsgi_application()
|
||||||
9
deploy/csr-inventory-tls.service
Normal file
9
deploy/csr-inventory-tls.service
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Renew CS-R inventory LAN TLS certificate
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/home/denis/csr-inventory
|
||||||
|
EnvironmentFile=/home/denis/csr-inventory/.env
|
||||||
|
ExecStart=/usr/bin/python3 /home/denis/csr-inventory/scripts/renew_local_tls.py --hostname ${APP_HOST}
|
||||||
|
UMask=0077
|
||||||
9
deploy/csr-inventory-tls.timer
Normal file
9
deploy/csr-inventory-tls.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily CS-R inventory TLS certificate check
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 01:30:00 Europe/Berlin
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
10
docker-compose.dev.yml
Normal file
10
docker-compose.dev.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
environment:
|
||||||
|
DEBUG: "true"
|
||||||
|
SECURE_COOKIES: "false"
|
||||||
|
SECURE_SSL_REDIRECT: "false"
|
||||||
|
ALLOWED_HOSTS: "localhost,127.0.0.1"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "http://localhost:8000,http://127.0.0.1:8000"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
6
docker-compose.lan.yml
Normal file
6
docker-compose.lan.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
environment:
|
||||||
|
TRAEFIK_CERT_RESOLVER: ""
|
||||||
|
ports:
|
||||||
|
- "8001:80"
|
||||||
93
docker-compose.yml
Normal file
93
docker-compose.yml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:15432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- media_data:/app/media
|
||||||
|
- static_data:/app/staticfiles
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; r=urllib.request.Request('http://127.0.0.1:8000/health/', headers={'X-Forwarded-Proto':'https'}); urllib.request.urlopen(r, timeout=3)"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7.13
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
web:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
APP_HOST: ${APP_HOST:?Set APP_HOST in .env}
|
||||||
|
TRAEFIK_CERT_RESOLVER: ${TRAEFIK_CERT_RESOLVER:-letsencrypt}
|
||||||
|
command:
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.websecure.http3=true
|
||||||
|
- --entrypoints.traefik.address=:8080
|
||||||
|
- --ping=true
|
||||||
|
- --api.dashboard=false
|
||||||
|
- --providers.file.directory=/etc/traefik/dynamic
|
||||||
|
- --providers.file.watch=true
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-}
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.storage=/acme/acme.json
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
||||||
|
- ./tls/certs:/certs:ro
|
||||||
|
- traefik_acme:/acme
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
migration:
|
||||||
|
profiles: ["tools"]
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
# Offline tools need access to the host backup directory. The web service
|
||||||
|
# continues to run as the unprivileged app user.
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["python"]
|
||||||
|
command: ["manage.py", "check"]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- .:/legacy:ro
|
||||||
|
- ./backups:/transfer
|
||||||
|
- media_data:/app/media
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
media_data:
|
||||||
|
static_data:
|
||||||
|
traefik_acme:
|
||||||
29
docs/datenbankschema.md
Normal file
29
docs/datenbankschema.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Geräteschema
|
||||||
|
|
||||||
|
`inventory_device` enthält die gemeinsamen Felder: Inventarnummer, Gerätetyp,
|
||||||
|
Modell, Status, Arbeitsschritt, Datum, Regal, Bearbeiter, Empfänger, Ort,
|
||||||
|
Ticket, Preis und Notizen. Jeder Gerätedatensatz besitzt genau eine passende
|
||||||
|
Spezifikation in einer 1:1-Tabelle. Die Anwendung legt diese beim Erstellen
|
||||||
|
an und entfernt die alte Spezifikation bei einem Typwechsel.
|
||||||
|
|
||||||
|
| Gerätetyp | Tabelle | Typische Felder |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Laptop | `inventory_laptopspecification` | CPU, HWID, RAM, SSD, HDD, Bildschirmgröße, Akku, WLAN, LAN, Anschlüsse |
|
||||||
|
| Desktop | `inventory_desktopspecification` | CPU, HWID, RAM, SSD, HDD, WLAN, LAN, Anschlüsse |
|
||||||
|
| AiO | `inventory_allinonespecification` | CPU, HWID, RAM, SSD, HDD, Bildschirmgröße, WLAN, LAN, Anschlüsse |
|
||||||
|
| Bildschirm/Monitor | `inventory_monitorspecification` | Bildschirmgröße, Auflösung, Anschlüsse |
|
||||||
|
| Drucker/Printer | `inventory_printerspecification` | WLAN, LAN, Anschlüsse, Druckverfahren, Duplex, Farbdruck |
|
||||||
|
| Frei definierter Computertyp | `inventory_computerspecification` | Allgemeine Computerhardware als Fallback |
|
||||||
|
|
||||||
|
Die Werte für `Anschlüsse` sind Freitext, etwa `2× HDMI, DisplayPort,
|
||||||
|
USB-C`; damit sind auch unbekannte Anschlussarten erfassbar. Gerätetypen
|
||||||
|
werden derzeit über ihre Bezeichnung zugeordnet. Weitere frei definierte
|
||||||
|
Typen verwenden die allgemeine Computertabelle, bis ein eigenes Schema
|
||||||
|
ergänzt wird.
|
||||||
|
|
||||||
|
Inspektionsbefunde und Reparaturarbeiten stehen unabhängig davon in
|
||||||
|
`inventory_inspectionrecord` und `inventory_repairrecord`. Aufgaben liegen
|
||||||
|
in `inventory_devicetask`, dokumentierte Arbeitsschritte in
|
||||||
|
`inventory_workflowevent`. Migration 0012 verschiebt vorhandene Laptop-,
|
||||||
|
Desktop- und AiO-Werte aus der allgemeinen Computertabelle. Bestehende
|
||||||
|
Inventarnummern, Aufgaben und Benutzer bleiben erhalten.
|
||||||
11
docs/homelab-root-ca.crt
Normal file
11
docs/homelab-root-ca.crt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBojCCAUmgAwIBAgIQUS4ubv+VPmHfsOZnHA55kDAKBggqhkjOPQQDAjAwMS4w
|
||||||
|
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X
|
||||||
|
DTI2MDkyMzE3MjEyMVoXDTM2MDgwMTE3MjEyMVowMDEuMCwGA1UEAxMlQ2FkZHkg
|
||||||
|
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
|
||||||
|
SM49AwEHA0IABNfvDgBuLlx110lhzZllEhvq9OJ9y/BxFUJnR8RK5idzm1R9H/U/
|
||||||
|
PlszTxrP5QkxaJgBgjecOJS+19ziBEYdBXyjRTBDMA4GA1UdDwEB/wQEAwIBBjAS
|
||||||
|
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSUYvaXe7IkfVqEf49jgl5npdiw
|
||||||
|
BTAKBggqhkjOPQQDAgNHADBEAiBjTG79jTLkoYy5yhXPPbegqDlpt0+sWrp71ZZc
|
||||||
|
f/hkXgIgLG0JFVISRSCzm+bM5DM/iAwX4nyNrGKoVGuRaNtlD6w=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
113
docs/postgresql-migration.md
Normal file
113
docs/postgresql-migration.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# SQLite → PostgreSQL mit Docker
|
||||||
|
|
||||||
|
Die Anwendung verwendet standardmäßig PostgreSQL. Es gibt keinen automatischen
|
||||||
|
Rückfall auf SQLite. Die folgenden Befehle sind vorbereitet; sie wurden auf dem
|
||||||
|
aktuellen Windows-Rechner ohne Docker nicht ausgeführt.
|
||||||
|
|
||||||
|
## Vorbereiten
|
||||||
|
|
||||||
|
Docker mit Compose muss verfügbar sein. Die Befehle im Projektverzeichnis ausführen.
|
||||||
|
Die bisherige Anwendung während Export, Import und Prüfung stoppen, damit keine
|
||||||
|
Änderungen nach dem Export verloren gehen. `db.sqlite3` und das Verzeichnis `media`
|
||||||
|
behalten und zusätzlich extern sichern. Niemals `docker compose down -v` ausführen,
|
||||||
|
wenn Volumes noch benötigte Daten enthalten.
|
||||||
|
|
||||||
|
`.env.example` nach `.env` kopieren und mindestens `SECRET_KEY`,
|
||||||
|
`POSTGRES_PASSWORD`, Domain, erlaubte Hosts und CSRF-Origin ersetzen.
|
||||||
|
`POSTGRES_HOST=db` beibehalten. Keine Zugangsdaten in Git ablegen.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose --profile tools build web migration
|
||||||
|
docker compose stop web traefik
|
||||||
|
docker compose up -d --wait db
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Zieldatenbank muss leer sein. Vor dem Import den Webdienst noch **nicht**
|
||||||
|
starten: Sein Startskript legt sonst bereits Seed-Daten an. Das Importkommando
|
||||||
|
verweigert einen Import, sobald Benutzer, Gruppen oder Inventardaten vorhanden sind.
|
||||||
|
Die zwei von Migration 0003 angelegten Standard-Gerätetypen werden als leere
|
||||||
|
Ausgangsbasis erkannt und innerhalb der Importtransaktion durch die Exportdaten
|
||||||
|
ersetzt. Ein vorhandenes PostgreSQL-System niemals zum Zweck des Imports leeren; stattdessen
|
||||||
|
eine neue Datenbank bzw. ein separates Compose-Projekt verwenden.
|
||||||
|
|
||||||
|
## Exportieren, Schema anlegen, importieren
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm migration scripts/export_sqlite.py /legacy/db.sqlite3 /transfer/sqlite-export.json
|
||||||
|
docker compose run --rm migration manage.py migrate --noinput
|
||||||
|
docker compose run --rm migration manage.py import_sqlite_export /transfer/sqlite-export.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Export liest SQLite über eine schreibgeschützte Verbindung, erstellt einen
|
||||||
|
konsistenten temporären Snapshot und aktualisiert nur dessen Schema. Die originale
|
||||||
|
SQLite-Datei bleibt unverändert. Existiert die Exportdatei bereits, wird sie nicht
|
||||||
|
überschrieben; für einen weiteren Export einen neuen Dateinamen verwenden.
|
||||||
|
|
||||||
|
Der Export enthält Benutzer und Passwort-Hashes, Rollen, API-Schlüssel-Hashes,
|
||||||
|
Geräte, Archivstatus, Zusatzfelder, Einstellungen, Nummernfolge und Historie.
|
||||||
|
Content Types und Standardberechtigungen werden durch Migrationen angelegt und
|
||||||
|
über natürliche Schlüssel zugeordnet. Sitzungen werden nicht übernommen; Benutzer
|
||||||
|
müssen sich erneut anmelden. Der Import läuft transaktional und setzt die
|
||||||
|
PostgreSQL-ID-Sequenzen durch Djangos `loaddata` zurück. Die Gerätenummernfolge
|
||||||
|
wird mindestens auf die höchste vorhandene Gerätenummer gesetzt.
|
||||||
|
|
||||||
|
`backups/sqlite-export.json` enthält vertrauliche Daten und ist kein öffentliches
|
||||||
|
Download-Artefakt. Der Export setzt auf Linux Dateirechte `0600`. Der offline
|
||||||
|
gestartete Migrationscontainer läuft für den Zugriff auf das Backup-Verzeichnis
|
||||||
|
als root; der Webcontainer läuft weiterhin als unprivilegierter `app`-Benutzer.
|
||||||
|
|
||||||
|
## Uploads übernehmen
|
||||||
|
|
||||||
|
Der Datenbankexport enthält Dateiverweise, nicht die Dateien selbst. Bei einem
|
||||||
|
**neuen, leeren** `media_data`-Volume die bisherigen Uploads kopieren:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm --entrypoint sh migration -c 'cp -a /legacy/media/. /app/media/ && chown -R app:app /app/media'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein bereits verwendetes Medienvolume zuerst sichern und seine Inhalte separat
|
||||||
|
abgleichen; der Kopierbefehl darf nicht ungeprüft über vorhandene Dateien laufen.
|
||||||
|
|
||||||
|
## Prüfen und starten
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm migration manage.py check
|
||||||
|
docker compose run --rm migration manage.py makemigrations --check --dry-run
|
||||||
|
docker compose run --rm -e DEBUG=true -e SECURE_SSL_REDIRECT=false migration manage.py test --noinput
|
||||||
|
docker compose up -d --build web traefik
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests verwenden eine separate PostgreSQL-Testdatenbank. Danach Anmeldung,
|
||||||
|
Geräteanzahl einschließlich Archiv, höchste Gerätenummer, Rollen, Historie und
|
||||||
|
Upload-Verweise mit dem bisherigen System vergleichen. Ein PostgreSQL-Backup
|
||||||
|
anlegen und testweise in eine separate leere Datenbank wiederherstellen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Erst nach erfolgreicher Prüfung das alte System endgültig stilllegen. Für einen
|
||||||
|
Rückwechsel den neuen Webdienst stoppen und die alte Anwendung mit ihrer
|
||||||
|
unveränderten SQLite-Datei starten. Neue PostgreSQL-Änderungen werden nicht
|
||||||
|
automatisch zurück nach SQLite übertragen.
|
||||||
|
|
||||||
|
## Lokaler Docker-Betrieb ohne HTTPS-Domain
|
||||||
|
|
||||||
|
Nach dem Import alternativ nur Datenbank und Webdienst starten:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build db web
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung ist dann unter `http://localhost:8000` erreichbar. Der Port ist nur
|
||||||
|
an localhost gebunden. Diese Entwicklungsüberschreibung nicht auf dem VPS nutzen.
|
||||||
|
|
||||||
|
## Bearbeitungsversionen
|
||||||
|
|
||||||
|
Geräte besitzen nun eine ganzzahlige `revision`. Browser-Formulare senden
|
||||||
|
`expected_revision`; API-PATCH-Anfragen können ebenfalls dieses Feld verwenden.
|
||||||
|
Bestehende API-Clients mit `expected_updated_at` bleiben kompatibel. Veraltete
|
||||||
|
Versionen werden abgewiesen. Zeitstempel steigen auch bei geringer Auflösung der
|
||||||
|
Systemuhr strikt an. Migration `0006` setzt bestehende Geräte auf Revision 1.
|
||||||
160
docs/serverbetrieb.md
Normal file
160
docs/serverbetrieb.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Serverbetrieb auf 192.168.188.45
|
||||||
|
|
||||||
|
Stand: 26. September 2026. Die Anwendung wurde auf dem Debian-Server `homelab`
|
||||||
|
installiert und gegen PostgreSQL geprüft.
|
||||||
|
|
||||||
|
## Zugriff
|
||||||
|
|
||||||
|
- Anwendung: **https://192.168.188.45/**
|
||||||
|
- Anmeldung: https://192.168.188.45/accounts/login/
|
||||||
|
- Der bisherige Port `http://192.168.188.45:8001/` leitet auf HTTPS weiter.
|
||||||
|
- Die Benutzerkonten und Passwort-Hashes aus der lokalen Projektdatenbank wurden übernommen. Bestehende Anmeldedaten bleiben gültig.
|
||||||
|
- Installationsverzeichnis: `/home/denis/csr-inventory`
|
||||||
|
|
||||||
|
Die Anwendung läuft mit deaktiviertem Debugmodus, sicheren Cookies und einer
|
||||||
|
separaten PostgreSQL-Datenbank. Nur Traefik veröffentlicht Webports; PostgreSQL und
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Import-Certificate -FilePath .\docs\homelab-root-ca.crt -CertStoreLocation Cert:\CurrentUser\Root
|
||||||
|
```
|
||||||
|
|
||||||
|
SHA-256-Fingerabdruck des Stammzertifikats:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7B:15:0B:52:62:81:68:3D:93:AA:CE:96:63:FD:23:9E:37:E2:AE:F3:6C:F3:E7:AB:E4:91:A6:F7:F9:8F:ED:05
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Serverdatei `docker-compose.override.yml` entspricht `docker-compose.lan.yml`
|
||||||
|
und veröffentlicht zusätzlich den Weiterleitungsport 8001. Traefik verwendet
|
||||||
|
den File-Provider ohne Docker-Socket und ohne veröffentlichtes Dashboard.
|
||||||
|
Statische Dateien liefert Django über WhiteNoise aus.
|
||||||
|
|
||||||
|
Die bisherige Stamm-CA bleibt erhalten. Ihr privater Schlüssel liegt ausschließlich
|
||||||
|
auf dem Host unter `tls/ca/root.key`. Traefik erhält nur die Serverzertifikate.
|
||||||
|
Der Timer `csr-inventory-tls.timer` prüft täglich um 01:30 Uhr die Gültigkeit und
|
||||||
|
erneuert 90-Tage-Zertifikate bei weniger als 30 Tagen Restlaufzeit. Traefik lädt
|
||||||
|
neue Zertifikate automatisch. Einrichtung und Wartung: [Traefik](traefik.md).
|
||||||
|
|
||||||
|
## Verwaltung
|
||||||
|
|
||||||
|
Der SSH-Benutzer `denis` hat keine direkten Docker- oder sudo-Rechte. Zur Verwaltung
|
||||||
|
wie bei der Installation eine Root-Sitzung öffnen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh denis@192.168.188.45
|
||||||
|
su -
|
||||||
|
cd /home/denis/csr-inventory
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail 100 web traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach Änderungen am Anwendungscode neu bauen und starten:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh scripts/backup.sh
|
||||||
|
docker compose up -d --build --wait web traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
Die individuellen Anwendungs- und Datenbankgeheimnisse stehen ausschließlich in
|
||||||
|
der geschützten `.env` auf dem Server. Bei Aktualisierungen diese Datei, die
|
||||||
|
Serverüberschreibung und die persistenten Volumes beibehalten.
|
||||||
|
|
||||||
|
## Datenbankzugriff mit DBeaver
|
||||||
|
|
||||||
|
PostgreSQL wird nur auf `127.0.0.1:15432` des Servers veröffentlicht.
|
||||||
|
Für DBeaver einen PostgreSQL-Anschluss mit **Host** `127.0.0.1`, **Port**
|
||||||
|
`15432`, **Datenbank** `csr_inventory` und **Benutzer** `csr_inventory`
|
||||||
|
anlegen. Im Reiter **SSH** einen Tunnel zu `192.168.188.45:22` mit dem
|
||||||
|
SSH-Benutzer `denis` einrichten. Die Datenbank bleibt über die LAN-Adresse
|
||||||
|
auf Port 15432 unerreichbar.
|
||||||
|
|
||||||
|
Das Datenbankpasswort ist `POSTGRES_PASSWORD` in der geschützten `.env`.
|
||||||
|
Als Root auf dem Server kann es mit
|
||||||
|
`sed -n 's/^POSTGRES_PASSWORD=//p' /home/denis/csr-inventory/.env`
|
||||||
|
für die Eingabe in DBeaver angezeigt werden. Das SSH-Passwort ist getrennt
|
||||||
|
vom Datenbankpasswort. In DBeaver die Verbindung über die Host-Felder
|
||||||
|
konfigurieren, nicht über eine fest eingetragene JDBC-URL.
|
||||||
|
|
||||||
|
## Datensicherung
|
||||||
|
|
||||||
|
- PostgreSQL-Sicherungen: `/home/denis/csr-inventory/backups/`
|
||||||
|
- Automatische Sicherung: täglich um **02:00 Uhr Europe/Berlin**.
|
||||||
|
- Timer: `csr-inventory-backup.timer`
|
||||||
|
- Dienst: `csr-inventory-backup.service`
|
||||||
|
- Der tägliche Job sichert die Datenbank. Upload-Dateien liegen im persistenten Media-Volume. Die TLS-Zertifizierungsstelle und Serverzertifikate liegen im Projektverzeichnis `tls/`. Diese Daten und die geschützte `.env` müssen separat gesichert werden.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl list-timers csr-inventory-backup.timer
|
||||||
|
journalctl -u csr-inventory-backup.service --no-pager
|
||||||
|
sh scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine erste PostgreSQL-Sicherung wurde erfolgreich in eine separate temporäre
|
||||||
|
Datenbank zurückgespielt und die Geräteanzahl verglichen. Die Prüfdatenbank wurde
|
||||||
|
anschließend entfernt.
|
||||||
|
|
||||||
|
## Ablösung der bisherigen Inventarsoftware
|
||||||
|
|
||||||
|
Die Container `computerspende-inventar-web-1` und
|
||||||
|
`computerspende-inventar-db-1` sowie deren Compose-Netzwerk wurden entfernt.
|
||||||
|
Die anderen Anwendungen auf dem Server laufen weiter.
|
||||||
|
|
||||||
|
Vor der Entfernung wurden Datenbank, Anwendungskonfiguration und Uploads unter
|
||||||
|
`/root/csr-retired-inventory-20260923/` gesichert. Die alten Docker-Volumes und das
|
||||||
|
bisherige Quellverzeichnis wurden für eine mögliche Wiederherstellung erhalten.
|
||||||
|
Die Daten dieser anderen Anwendung wurden nicht mit der neuen Datenbank vermischt.
|
||||||
|
|
||||||
|
## Durchgeführte Prüfungen
|
||||||
|
|
||||||
|
- Alle **53 Tests mit PostgreSQL erfolgreich**, einschließlich gleichzeitiger Bearbeitungen.
|
||||||
|
- Django-Produktionsprüfung ohne Beanstandungen.
|
||||||
|
- 109 exportierte Datensätze übernommen, darunter zwei Geräte und die vorhandenen Benutzerkonten.
|
||||||
|
- HTTPS-Zertifikatsprüfung, Healthcheck, Loginseite und statische Dateien vom Server und vom Arbeitsplatz geprüft.
|
||||||
|
- API-Zugriff ohne Schlüssel wird mit HTTP 401 abgewiesen.
|
||||||
|
- Weiterleitung vom bisherigen Port 8001 geprüft.
|
||||||
|
- Datenbank-Backup und Wiederherstellung in eine separate Prüfdatenbank erfolgreich.
|
||||||
|
|
||||||
|
## Datenbank- und UI-Update vom 26. September 2026
|
||||||
|
|
||||||
|
Vor Migration 0009/0010 wurde eine neue PostgreSQL-Sicherung erstellt.
|
||||||
|
61 Tests bestanden mit PostgreSQL 17. Die drei vorhandenen Geräte blieben
|
||||||
|
erhalten und besitzen jetzt jeweils einen Datensatz in
|
||||||
|
`inventory_computerspecification`. Die alten Hardware-Spalten in
|
||||||
|
`inventory_device` wurden entfernt. Beide Migrationen sind angewendet;
|
||||||
|
der Web-Container ist gesund. Der neue Ablauf unter `/ablauf/` antwortet
|
||||||
|
für angemeldete Benutzer erfolgreich. Healthcheck, Login, CSS und API-Schutz
|
||||||
|
wurden nach dem Neustart geprüft.
|
||||||
|
|
||||||
|
## Arbeitsschritt-Formulare vom 26. September 2026
|
||||||
|
|
||||||
|
Vor Migration 0011 wurde erneut eine PostgreSQL-Sicherung erstellt.
|
||||||
|
Die Migration ergänzt getrennte Tabellen für Inspektionsbefunde und
|
||||||
|
Reparaturarbeiten. Im Ablauf-Board können Bearbeiter während der
|
||||||
|
Inspektion Mängel und technische Daten, während der Reparatur die Arbeiten
|
||||||
|
und während der Lagerung den Empfänger eintragen. 63 Tests bestanden auf
|
||||||
|
PostgreSQL 17. Nach dem Neustart waren alle drei vorhandenen Geräte
|
||||||
|
erhalten; das Board antwortete angemeldet mit HTTP 200 und der Web-Container
|
||||||
|
war gesund. Healthcheck, Loginseite und CSS antworteten ebenfalls mit 200.
|
||||||
|
|
||||||
|
## Gerätetyp-Schema vom 28. September 2026
|
||||||
|
|
||||||
|
Vor Migration 0012 wurde eine PostgreSQL-Sicherung erstellt. 64 Tests
|
||||||
|
bestanden mit PostgreSQL 17. Die Migration verschob die vier vorhandenen
|
||||||
|
Geräte in drei Laptop-Spezifikationen und eine Desktop-Spezifikation;
|
||||||
|
die allgemeine Computertabelle enthält danach keine dieser Geräte mehr.
|
||||||
|
Die Tabellen für AiO, Monitor und Drucker sind vorhanden. Geräteliste,
|
||||||
|
Ablauf, Übersicht, Detailseite, Healthcheck und Loginseite wurden nach dem
|
||||||
|
Neustart erfolgreich geprüft. Das Ablauf-Board antwortete angemeldet in
|
||||||
|
etwa 0,3 Sekunden. Das Schema ist unter [Datenbankschema](datenbankschema.md)
|
||||||
|
beschrieben.
|
||||||
58
docs/traefik.md
Normal file
58
docs/traefik.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Traefik-Betrieb
|
||||||
|
|
||||||
|
Traefik v3.7.13 ersetzt Caddy. Die Routerdefinition liegt unter
|
||||||
|
`traefik/dynamic/routes.yml`; ein Docker-Socket ist nicht erforderlich.
|
||||||
|
Nur HTTP/HTTPS werden veröffentlicht, das Dashboard ist deaktiviert.
|
||||||
|
WhiteNoise liefert statische Dateien aus, Uploads bleiben geschützt.
|
||||||
|
|
||||||
|
## Öffentliche Domain
|
||||||
|
|
||||||
|
In `.env` `APP_HOST` ohne Protokoll oder Port und `ACME_EMAIL` setzen.
|
||||||
|
DNS muss auf den Server zeigen; TCP 80 und 443 müssen erreichbar sein.
|
||||||
|
`docker compose up -d --build --wait` aktiviert HTTPS über Let's Encrypt.
|
||||||
|
ACME-Zustand liegt im Volume `traefik_acme` und muss gesichert werden.
|
||||||
|
|
||||||
|
## Privates LAN
|
||||||
|
|
||||||
|
Auf Linux mit Python 3 und OpenSSL, im Projektverzeichnis als Administrator:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# APP_HOST in .env auf die LAN-IP setzen.
|
||||||
|
cp docker-compose.lan.yml docker-compose.override.yml
|
||||||
|
python3 scripts/renew_local_tls.py --hostname 192.168.188.45
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
install -m 644 deploy/csr-inventory-tls.service deploy/csr-inventory-tls.timer /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now csr-inventory-tls.timer
|
||||||
|
systemctl start csr-inventory-tls.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Die mitgelieferten Units setzen `/home/denis/csr-inventory` voraus; bei anderen
|
||||||
|
Installationspfaden beide Pfade im Dienst anpassen. Der LAN-Override deaktiviert
|
||||||
|
ACME und ergänzt Port 8001 als HTTPS-Weiterleitung.
|
||||||
|
|
||||||
|
Ohne vorhandene CA erzeugt das Skript eine neue. Zur Übernahme einer bestehenden
|
||||||
|
CA vor dem ersten Lauf deren `root.crt` und `root.key` nach `tls/ca/` kopieren.
|
||||||
|
Der CA-Schlüssel bleibt auf dem Host und darf nicht verteilt werden. Nur das
|
||||||
|
öffentliche Stammzertifikat wird in den Vertrauensspeicher der Clients importiert.
|
||||||
|
Auf dem Server 192.168.188.45 wird die bisherige Stamm-CA weiterverwendet.
|
||||||
|
|
||||||
|
Der Timer prüft täglich um 01:30 Europe/Berlin. Zertifikate gelten 90 Tage und
|
||||||
|
werden bei höchstens 30 Tagen Restlaufzeit erneuert. Ein Standardzertifikat
|
||||||
|
ermöglicht auch den IP-Zugriff ohne SNI. Die Dateikonfiguration wird atomar
|
||||||
|
ersetzt; Traefik lädt sie ohne Neustart. Frühere Zertifikate bleiben erhalten.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl list-timers csr-inventory-tls.timer
|
||||||
|
journalctl -u csr-inventory-tls.service --no-pager
|
||||||
|
docker compose logs --tail 100 traefik
|
||||||
|
# Erneuerung bei Bedarf erzwingen:
|
||||||
|
python3 scripts/renew_local_tls.py --hostname 192.168.188.45 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
`tls/`, `.env`, Uploads und Datenbank separat geschützt sichern. Der tägliche
|
||||||
|
Datenbankjob umfasst keine TLS-Schlüssel. `tls/` und die erzeugte `tls.yml`
|
||||||
|
sind vom Git-Repository und Docker-Build ausgeschlossen.
|
||||||
|
|
||||||
|
Referenzen: [File-Provider](https://doc.traefik.io/traefik/providers/file/),
|
||||||
|
[TLS-Dateikonfiguration](https://doc.traefik.io/traefik/reference/routing-configuration/other-providers/file/).
|
||||||
1
inventory/__init__.py
Normal file
1
inventory/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
120
inventory/admin.py
Normal file
120
inventory/admin.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework_api_key.admin import APIKeyModelAdmin
|
||||||
|
from rest_framework_api_key.models import APIKey
|
||||||
|
|
||||||
|
from .models import AuditLog, AllInOneSpecification, ChoiceValue, ComputerSpecification, DesktopSpecification, Device, DeviceTask, Employee, FieldDefinition, FieldOption, ImportJob, InspectionRecord, InventoryAPIKey, LaptopSpecification, MonitorSpecification, PrinterSpecification, RepairRecord, SavedView, UserPreference, UserProfile, WorkflowEvent
|
||||||
|
from .permissions import can_edit
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
admin.site.unregister(APIKey)
|
||||||
|
except admin.sites.NotRegistered:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryAPIKeyAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = InventoryAPIKey
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
def clean_expiry_date(self):
|
||||||
|
expiry = self.cleaned_data.get("expiry_date")
|
||||||
|
if not expiry:
|
||||||
|
raise ValidationError("Ein Ablaufdatum ist aus Sicherheitsgründen erforderlich.")
|
||||||
|
if expiry <= timezone.now():
|
||||||
|
raise ValidationError("Das Ablaufdatum muss in der Zukunft liegen.")
|
||||||
|
return expiry
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
user = cleaned.get("service_user")
|
||||||
|
if cleaned.get("can_write") and user and not can_edit(user):
|
||||||
|
self.add_error("service_user", "Schreibzugriff erfordert ein Bearbeiter- oder Administratorkonto.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Device)
|
||||||
|
class DeviceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("number", "stage", "status", "device_type", "model", "employee", "date", "archived_at")
|
||||||
|
search_fields = ("=number", "model", "laptop_specification__cpu", "desktop_specification__cpu", "aio_specification__cpu", "computer_specification__cpu", "ticket", "comment")
|
||||||
|
list_filter = ("stage", "status", "device_type", "employee", "archived_at")
|
||||||
|
readonly_fields = ("number", "created_at", "updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeviceTask)
|
||||||
|
class DeviceTaskAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "device", "state", "assigned_to", "due_date", "completed_at")
|
||||||
|
list_filter = ("state", "due_date")
|
||||||
|
search_fields = ("title", "device__number")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(WorkflowEvent)
|
||||||
|
class WorkflowEventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("device", "from_stage", "to_stage", "user", "created_at")
|
||||||
|
readonly_fields = ("device", "from_stage", "to_stage", "note", "user", "created_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(InspectionRecord, RepairRecord)
|
||||||
|
class StageRecordAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("device", "recorded_by", "created_at")
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
return tuple(field.name for field in self.model._meta.fields)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LaptopSpecification, DesktopSpecification, AllInOneSpecification, ComputerSpecification, MonitorSpecification, PrinterSpecification)
|
||||||
|
class SpecificationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("device",)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ChoiceValue)
|
||||||
|
class ChoiceValueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("kind", "label", "active", "sort_order")
|
||||||
|
list_filter = ("kind", "active")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(InventoryAPIKey)
|
||||||
|
class InventoryAPIKeyAdmin(APIKeyModelAdmin):
|
||||||
|
form = InventoryAPIKeyAdminForm
|
||||||
|
list_display = (
|
||||||
|
"prefix", "name", "service_user", "can_read", "can_write", "created",
|
||||||
|
"expiry_date", "_has_expired", "revoked", "last_used_at",
|
||||||
|
)
|
||||||
|
list_filter = ("can_read", "can_write", "revoked", "created")
|
||||||
|
search_fields = ("name", "prefix", "service_user__username")
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
return super().get_readonly_fields(request, obj) + ("created_by", "last_used_at", "last_used_ip")
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form=None, change=False):
|
||||||
|
if not obj.pk:
|
||||||
|
obj.created_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Employee)
|
||||||
|
admin.site.register(UserProfile)
|
||||||
|
admin.site.register(FieldDefinition)
|
||||||
|
admin.site.register(FieldOption)
|
||||||
|
admin.site.register(AuditLog)
|
||||||
|
admin.site.register(UserPreference)
|
||||||
|
admin.site.register(SavedView)
|
||||||
|
admin.site.register(ImportJob)
|
||||||
282
inventory/api.py
Normal file
282
inventory/api.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
from rest_framework import generics, serializers, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from .models import ChoiceValue, Device, Employee
|
||||||
|
from .query import SPECIFICATION_RELATIONS
|
||||||
|
from .services import update_device_field
|
||||||
|
|
||||||
|
|
||||||
|
class SecureAPIResponseMixin:
|
||||||
|
def finalize_response(self, request, response, *args, **kwargs):
|
||||||
|
response = super().finalize_response(request, response, *args, **kwargs)
|
||||||
|
response["Cache-Control"] = "no-store"
|
||||||
|
response["Pragma"] = "no-cache"
|
||||||
|
response["Vary"] = "Authorization"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceSummarySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ChoiceValue
|
||||||
|
fields = ["id", "label", "color"]
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeSummarySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Employee
|
||||||
|
fields = ["id", "code", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceSerializer(serializers.ModelSerializer):
|
||||||
|
battery = serializers.CharField(read_only=True)
|
||||||
|
wifi = serializers.CharField(read_only=True)
|
||||||
|
lan = serializers.CharField(read_only=True)
|
||||||
|
display_inches = serializers.DecimalField(max_digits=4, decimal_places=1, read_only=True, allow_null=True)
|
||||||
|
ram_gb = serializers.IntegerField(read_only=True, allow_null=True)
|
||||||
|
ssd_gb = serializers.IntegerField(read_only=True, allow_null=True)
|
||||||
|
hdd_gb = serializers.IntegerField(read_only=True, allow_null=True)
|
||||||
|
cpu = serializers.CharField(read_only=True)
|
||||||
|
hwid = serializers.CharField(read_only=True)
|
||||||
|
ports = serializers.CharField(read_only=True)
|
||||||
|
resolution = serializers.CharField(read_only=True)
|
||||||
|
print_technology = serializers.CharField(read_only=True)
|
||||||
|
duplex = serializers.CharField(read_only=True)
|
||||||
|
color_printing = serializers.CharField(read_only=True)
|
||||||
|
status = ChoiceSummarySerializer(read_only=True)
|
||||||
|
recipient = ChoiceSummarySerializer(read_only=True)
|
||||||
|
location = ChoiceSummarySerializer(read_only=True)
|
||||||
|
device_type = ChoiceSummarySerializer(read_only=True)
|
||||||
|
employee = EmployeeSummarySerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Device
|
||||||
|
fields = [
|
||||||
|
"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",
|
||||||
|
"donor", "comment", "archived_at", "created_at", "updated_at", "revision",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StrictSerializer(serializers.Serializer):
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
if isinstance(data, Mapping):
|
||||||
|
unknown = set(data) - set(self.fields)
|
||||||
|
if unknown:
|
||||||
|
names = ", ".join(sorted(unknown))
|
||||||
|
raise serializers.ValidationError({"unbekannte_felder": f"Nicht erlaubt: {names}"})
|
||||||
|
return super().to_internal_value(data)
|
||||||
|
|
||||||
|
|
||||||
|
def active_choice_field(kind, *, allow_null=True):
|
||||||
|
return serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=ChoiceValue.objects.filter(kind=kind, active=True),
|
||||||
|
required=False,
|
||||||
|
allow_null=allow_null,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceChangesSerializer(StrictSerializer):
|
||||||
|
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)
|
||||||
|
recipient = active_choice_field(ChoiceValue.Kind.RECIPIENT)
|
||||||
|
location = active_choice_field(ChoiceValue.Kind.LOCATION)
|
||||||
|
ticket = serializers.CharField(max_length=120, required=False, allow_blank=True)
|
||||||
|
device_type = active_choice_field(ChoiceValue.Kind.DEVICE_TYPE)
|
||||||
|
price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, allow_null=True, min_value=0)
|
||||||
|
battery = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
|
||||||
|
wifi = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
|
||||||
|
lan = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
|
||||||
|
display_inches = serializers.DecimalField(max_digits=4, decimal_places=1, required=False, allow_null=True, min_value=0)
|
||||||
|
ram_gb = serializers.IntegerField(required=False, allow_null=True, min_value=0)
|
||||||
|
ssd_gb = serializers.IntegerField(required=False, allow_null=True, min_value=0)
|
||||||
|
hdd_gb = serializers.IntegerField(required=False, allow_null=True, min_value=0)
|
||||||
|
cpu = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||||
|
hwid = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||||
|
ports = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||||
|
resolution = serializers.CharField(max_length=80, required=False, allow_blank=True)
|
||||||
|
print_technology = serializers.CharField(max_length=80, required=False, allow_blank=True)
|
||||||
|
duplex = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
|
||||||
|
color_printing = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
|
||||||
|
model = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||||
|
employee = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Employee.objects.filter(active=True), required=False, allow_null=True,
|
||||||
|
)
|
||||||
|
donor = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||||
|
comment = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if not attrs:
|
||||||
|
raise serializers.ValidationError("Mindestens ein zu änderndes Feld ist erforderlich.")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePatchSerializer(DeviceChangesSerializer):
|
||||||
|
expected_updated_at = serializers.DateTimeField(required=False, write_only=True)
|
||||||
|
expected_revision = serializers.IntegerField(required=False, min_value=1, write_only=True)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if not {"expected_updated_at", "expected_revision"} & set(attrs):
|
||||||
|
raise serializers.ValidationError("expected_revision oder expected_updated_at ist erforderlich.")
|
||||||
|
if not set(attrs) - {"expected_updated_at", "expected_revision"}:
|
||||||
|
raise serializers.ValidationError("Mindestens ein zu änderndes Feld ist erforderlich.")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateSerializer(StrictSerializer):
|
||||||
|
device_numbers = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(min_value=1), min_length=1, max_length=500,
|
||||||
|
)
|
||||||
|
changes = DeviceChangesSerializer()
|
||||||
|
|
||||||
|
def validate_device_numbers(self, numbers):
|
||||||
|
if len(numbers) != len(set(numbers)):
|
||||||
|
raise serializers.ValidationError("Gerätenummern dürfen nicht doppelt vorkommen.")
|
||||||
|
return numbers
|
||||||
|
|
||||||
|
|
||||||
|
def raw_service_value(value):
|
||||||
|
if isinstance(value, (ChoiceValue, Employee)):
|
||||||
|
return value.pk
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def apply_changes(device, changes, *, user, api_client, expected_updated_at=None, expected_revision=None):
|
||||||
|
current = device
|
||||||
|
for index, (field, value) in enumerate(changes.items()):
|
||||||
|
current = update_device_field(
|
||||||
|
current,
|
||||||
|
field,
|
||||||
|
raw_service_value(value),
|
||||||
|
user,
|
||||||
|
expected_updated_at if index == 0 and expected_updated_at else None,
|
||||||
|
api_client=api_client,
|
||||||
|
expected_revision=expected_revision if index == 0 else None,
|
||||||
|
)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceListView(SecureAPIResponseMixin, generics.ListAPIView):
|
||||||
|
serializer_class = DeviceSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Device.objects.select_related(
|
||||||
|
"status", "recipient", "location", "device_type", "employee",
|
||||||
|
*SPECIFICATION_RELATIONS,
|
||||||
|
).order_by("number")
|
||||||
|
params = self.request.query_params
|
||||||
|
if params.get("archived", "false").lower() not in {"1", "true", "yes"}:
|
||||||
|
queryset = queryset.filter(archived_at__isnull=True)
|
||||||
|
if number := params.get("number"):
|
||||||
|
if number.isascii() and number.isdigit() and len(number) <= 10:
|
||||||
|
queryset = queryset.filter(number=int(number))
|
||||||
|
else:
|
||||||
|
return queryset.none()
|
||||||
|
if prefix := params.get("number_prefix"):
|
||||||
|
if prefix.isascii() and prefix.isdigit() and len(prefix) <= 10:
|
||||||
|
queryset = queryset.filter(number__startswith=prefix)
|
||||||
|
else:
|
||||||
|
return queryset.none()
|
||||||
|
for parameter, field in (("status", "status_id"), ("type", "device_type_id"), ("employee", "employee_id")):
|
||||||
|
value = params.get(parameter)
|
||||||
|
if value:
|
||||||
|
if not value.isascii() or not value.isdigit() or len(value) > 19 or int(value) > 9223372036854775807:
|
||||||
|
return queryset.none()
|
||||||
|
queryset = queryset.filter(**{field: int(value)})
|
||||||
|
if changed_since := params.get("changed_since"):
|
||||||
|
try:
|
||||||
|
parsed = parse_datetime(changed_since)
|
||||||
|
except ValueError:
|
||||||
|
parsed = None
|
||||||
|
if parsed:
|
||||||
|
queryset = queryset.filter(updated_at__gte=parsed)
|
||||||
|
else:
|
||||||
|
return queryset.none()
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDetailView(SecureAPIResponseMixin, APIView):
|
||||||
|
def get_object(self, number):
|
||||||
|
return generics.get_object_or_404(
|
||||||
|
Device.objects.select_related("status", "recipient", "location", "device_type", "employee", *SPECIFICATION_RELATIONS),
|
||||||
|
number=number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, number):
|
||||||
|
return Response(DeviceSerializer(self.get_object(number)).data)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def patch(self, request, number):
|
||||||
|
serializer = DevicePatchSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
values = dict(serializer.validated_data)
|
||||||
|
expected = values.pop("expected_updated_at", None)
|
||||||
|
revision = values.pop("expected_revision", None)
|
||||||
|
device = self.get_object(number)
|
||||||
|
try:
|
||||||
|
device = apply_changes(
|
||||||
|
device,
|
||||||
|
values,
|
||||||
|
user=request.user,
|
||||||
|
api_client=request.auth.name,
|
||||||
|
expected_updated_at=expected,
|
||||||
|
expected_revision=revision,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if str(exc) == "conflict":
|
||||||
|
return Response(
|
||||||
|
{"detail": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden."},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
raise serializers.ValidationError(exc.messages)
|
||||||
|
device.refresh_from_db()
|
||||||
|
return Response(DeviceSerializer(device).data)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateView(SecureAPIResponseMixin, APIView):
|
||||||
|
@transaction.atomic
|
||||||
|
def post(self, request):
|
||||||
|
serializer = BulkUpdateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
numbers = serializer.validated_data["device_numbers"]
|
||||||
|
changes = serializer.validated_data["changes"]
|
||||||
|
devices = list(Device.objects.select_for_update().filter(number__in=numbers, archived_at__isnull=True))
|
||||||
|
found = {device.number for device in devices}
|
||||||
|
missing = sorted(set(numbers) - found)
|
||||||
|
if missing:
|
||||||
|
raise serializers.ValidationError({"device_numbers": f"Nicht gefunden oder archiviert: {missing}"})
|
||||||
|
try:
|
||||||
|
for device in devices:
|
||||||
|
apply_changes(device, changes, user=request.user, api_client=request.auth.name)
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
raise serializers.ValidationError(exc.messages)
|
||||||
|
return Response({"updated": len(devices), "device_numbers": sorted(found)})
|
||||||
|
|
||||||
|
|
||||||
|
class ChoicesView(SecureAPIResponseMixin, APIView):
|
||||||
|
def get(self, request):
|
||||||
|
result = {}
|
||||||
|
for kind, label in (
|
||||||
|
(ChoiceValue.Kind.STATUS, "statuses"),
|
||||||
|
(ChoiceValue.Kind.RECIPIENT, "recipients"),
|
||||||
|
(ChoiceValue.Kind.LOCATION, "locations"),
|
||||||
|
(ChoiceValue.Kind.DEVICE_TYPE, "device_types"),
|
||||||
|
):
|
||||||
|
values = ChoiceValue.objects.filter(kind=kind, active=True).order_by("sort_order", "label")
|
||||||
|
result[label] = ChoiceSummarySerializer(values, many=True).data
|
||||||
|
employees = Employee.objects.filter(active=True).order_by("code")
|
||||||
|
result["employees"] = EmployeeSummarySerializer(employees, many=True).data
|
||||||
|
result["tri_state_values"] = [{"value": value, "label": label} for value, label in Device.TriState.choices]
|
||||||
|
return Response(result)
|
||||||
64
inventory/api_auth.py
Normal file
64
inventory/api_auth.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from ipaddress import ip_address
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import authentication, exceptions, permissions, throttling
|
||||||
|
from rest_framework_api_key.permissions import KeyParser
|
||||||
|
|
||||||
|
from .models import InventoryAPIKey
|
||||||
|
from .permissions import can_edit
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryAPIKeyAuthentication(authentication.BaseAuthentication):
|
||||||
|
"""Authentifiziert ausschließlich `Authorization: Api-Key <Schlüssel>`."""
|
||||||
|
|
||||||
|
key_parser = KeyParser()
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
raw_key = self.key_parser.get(request)
|
||||||
|
if not raw_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
api_key = InventoryAPIKey.objects.get_from_key(raw_key)
|
||||||
|
except (InventoryAPIKey.DoesNotExist, ValueError):
|
||||||
|
raise exceptions.AuthenticationFailed("Ungültiger API-Schlüssel.")
|
||||||
|
if api_key.has_expired:
|
||||||
|
raise exceptions.AuthenticationFailed("Der API-Schlüssel ist abgelaufen.")
|
||||||
|
if not api_key.service_user.is_active:
|
||||||
|
raise exceptions.AuthenticationFailed("Das zugehörige Konto ist deaktiviert.")
|
||||||
|
|
||||||
|
# Letzte Nutzung höchstens alle fünf Minuten schreiben, damit häufige
|
||||||
|
# Lesezugriffe nicht unnötig viele Datenbankänderungen erzeugen.
|
||||||
|
now = timezone.now()
|
||||||
|
if not api_key.last_used_at or api_key.last_used_at < now - timedelta(minutes=5):
|
||||||
|
remote_addr = request.META.get("REMOTE_ADDR", "")
|
||||||
|
try:
|
||||||
|
last_used_ip = str(ip_address(remote_addr)) if remote_addr else None
|
||||||
|
except ValueError:
|
||||||
|
last_used_ip = None
|
||||||
|
InventoryAPIKey.objects.filter(pk=api_key.pk).update(last_used_at=now, last_used_ip=last_used_ip)
|
||||||
|
api_key.last_used_at = now
|
||||||
|
api_key.last_used_ip = last_used_ip
|
||||||
|
|
||||||
|
return api_key.service_user, api_key
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return 'Api-Key realm="inventory-api"'
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryAPIPermission(permissions.BasePermission):
|
||||||
|
message = "Dieser API-Schlüssel besitzt nicht die erforderliche Berechtigung."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
api_key = request.auth
|
||||||
|
if not isinstance(api_key, InventoryAPIKey):
|
||||||
|
return False
|
||||||
|
if request.method in permissions.SAFE_METHODS:
|
||||||
|
return api_key.can_read
|
||||||
|
return api_key.can_write and can_edit(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientRateThrottle(throttling.UserRateThrottle):
|
||||||
|
scope = "api_client"
|
||||||
7
inventory/api_pagination.py
Normal file
7
inventory/api_pagination.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
|
class PagedDeviceResults(PageNumberPagination):
|
||||||
|
page_size = 50
|
||||||
|
page_size_query_param = "page_size"
|
||||||
|
max_page_size = 100
|
||||||
13
inventory/api_urls.py
Normal file
13
inventory/api_urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .api import BulkUpdateView, ChoicesView, DeviceDetailView, DeviceListView
|
||||||
|
|
||||||
|
|
||||||
|
app_name = "inventory-api"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("devices/", DeviceListView.as_view(), name="device-list"),
|
||||||
|
path("devices/bulk-update/", BulkUpdateView.as_view(), name="device-bulk-update"),
|
||||||
|
path("devices/<int:number>/", DeviceDetailView.as_view(), name="device-detail"),
|
||||||
|
path("choices/", ChoicesView.as_view(), name="choices"),
|
||||||
|
]
|
||||||
9
inventory/apps.py
Normal file
9
inventory/apps.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "inventory"
|
||||||
|
|
||||||
|
def ready(self) -> None:
|
||||||
|
from . import signals # noqa: F401
|
||||||
10
inventory/context_processors.py
Normal file
10
inventory/context_processors.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .models import UserProfile
|
||||||
|
from .permissions import user_role
|
||||||
|
|
||||||
|
|
||||||
|
def navigation_context(request):
|
||||||
|
role = user_role(request.user) if request.user.is_authenticated else None
|
||||||
|
return {
|
||||||
|
"current_role": role,
|
||||||
|
"current_role_label": dict(UserProfile.Role.choices).get(role, ""),
|
||||||
|
}
|
||||||
104
inventory/cpu_catalog.py
Normal file
104
inventory/cpu_catalog.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CPU_VENDOR_CHOICES = (
|
||||||
|
("intel", "Intel"),
|
||||||
|
("amd", "AMD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
CPU_FAMILY_CHOICES = (
|
||||||
|
("intel_i3", "Intel Core i3"),
|
||||||
|
("intel_i5", "Intel Core i5"),
|
||||||
|
("intel_i7", "Intel Core i7"),
|
||||||
|
("intel_i9", "Intel Core i9"),
|
||||||
|
("intel_ultra_5", "Intel Core Ultra 5"),
|
||||||
|
("intel_ultra_7", "Intel Core Ultra 7"),
|
||||||
|
("intel_ultra_9", "Intel Core Ultra 9"),
|
||||||
|
("amd_ryzen_3", "AMD Ryzen 3"),
|
||||||
|
("amd_ryzen_5", "AMD Ryzen 5"),
|
||||||
|
("amd_ryzen_7", "AMD Ryzen 7"),
|
||||||
|
("amd_ryzen_9", "AMD Ryzen 9"),
|
||||||
|
("amd_ryzen_ai", "AMD Ryzen AI"),
|
||||||
|
("amd_threadripper", "AMD Threadripper"),
|
||||||
|
("amd_fx", "AMD FX"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog() -> tuple[str, ...]:
|
||||||
|
values = []
|
||||||
|
for line in Path(__file__).with_name("cpu_models.txt").read_text(encoding="utf-8").splitlines():
|
||||||
|
value = line.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
if value.startswith("Core Ultra"):
|
||||||
|
value = f"Intel {value}"
|
||||||
|
values.append(value)
|
||||||
|
return tuple(dict.fromkeys(values))
|
||||||
|
|
||||||
|
|
||||||
|
CPU_MODELS = _load_catalog()
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(value: str) -> list[str]:
|
||||||
|
return re.findall(r"[a-z]+\d*|\d+[a-z]*", value.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _short_tokens(value: str) -> list[str]:
|
||||||
|
tokens = _tokens(value)
|
||||||
|
while tokens and tokens[0] in {"intel", "amd", "core"}:
|
||||||
|
tokens.pop(0)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _match_score(model: str, term: str, preferred: bool) -> int:
|
||||||
|
query_tokens = _tokens(term)
|
||||||
|
if not query_tokens:
|
||||||
|
return 100 if preferred else 1
|
||||||
|
|
||||||
|
model_tokens = _tokens(model)
|
||||||
|
short_tokens = _short_tokens(model)
|
||||||
|
query_text = " ".join(query_tokens)
|
||||||
|
model_text = " ".join(model_tokens)
|
||||||
|
short_text = " ".join(short_tokens)
|
||||||
|
compact_query = "".join(query_tokens)
|
||||||
|
compact_model = "".join(model_tokens)
|
||||||
|
compact_short = "".join(short_tokens)
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
if query_text == model_text:
|
||||||
|
score = 12_000
|
||||||
|
elif query_text == short_text:
|
||||||
|
score = 11_000
|
||||||
|
elif compact_query in {compact_model, compact_short}:
|
||||||
|
score = 10_000
|
||||||
|
elif all(token in model_tokens for token in query_tokens):
|
||||||
|
score = 9_000
|
||||||
|
elif all(any(candidate.startswith(token) for candidate in model_tokens) for token in query_tokens):
|
||||||
|
score = 8_000
|
||||||
|
elif compact_query in compact_model:
|
||||||
|
score = 7_000
|
||||||
|
elif all(token in model_text for token in query_tokens):
|
||||||
|
score = 6_000
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if preferred:
|
||||||
|
score += 250
|
||||||
|
score -= min(len(model), 200)
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_cpus(term: str, existing_values: Iterable[str] = (), limit: int = 15) -> list[str]:
|
||||||
|
existing = {value.strip() for value in existing_values if value and value.strip()}
|
||||||
|
candidates = list(dict.fromkeys([*existing, *CPU_MODELS]))
|
||||||
|
ranked = [
|
||||||
|
(_match_score(model, term, model in existing), index, model)
|
||||||
|
for index, model in enumerate(candidates)
|
||||||
|
]
|
||||||
|
ranked = [item for item in ranked if item[0] > 0]
|
||||||
|
ranked.sort(key=lambda item: (-item[0], item[1], item[2].casefold()))
|
||||||
|
return [model for _, _, model in ranked[:limit]]
|
||||||
640
inventory/cpu_models.txt
Normal file
640
inventory/cpu_models.txt
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
Core Ultra 9 285K
|
||||||
|
Core Ultra 9 285
|
||||||
|
Core Ultra 9 285T
|
||||||
|
Core Ultra 7 270K Plus
|
||||||
|
Core Ultra 7 265K
|
||||||
|
Core Ultra 7 265KF
|
||||||
|
Core Ultra 7 265
|
||||||
|
Core Ultra 7 265F
|
||||||
|
Core Ultra 7 265T
|
||||||
|
Core Ultra 5 250K Plus
|
||||||
|
Core Ultra 5 250KF Plus
|
||||||
|
Core Ultra 5 245K
|
||||||
|
Core Ultra 5 245KF
|
||||||
|
Core Ultra 5 245
|
||||||
|
Core Ultra 5 245T
|
||||||
|
Core Ultra 5 235
|
||||||
|
Core Ultra 5 235T
|
||||||
|
Core Ultra 5 225
|
||||||
|
Core Ultra 5 225F
|
||||||
|
Core Ultra 5 225T
|
||||||
|
Intel Core i9-14900KS
|
||||||
|
Intel Core i9-14900K
|
||||||
|
Intel Core i9-14900KF
|
||||||
|
Intel Core i9-14900
|
||||||
|
Intel Core i9-14900F
|
||||||
|
Intel Core i9-14900T
|
||||||
|
Intel Core i9-13900KS
|
||||||
|
Intel Core i9-13900K
|
||||||
|
Intel Core i9-13900KF
|
||||||
|
Intel Core i9-13900
|
||||||
|
Intel Core i9-13900F
|
||||||
|
Intel Core i9-13900T
|
||||||
|
Intel Core i9-12900KS
|
||||||
|
Intel Core i9-12900K
|
||||||
|
Intel Core i9-12900KF
|
||||||
|
Intel Core i9-12900
|
||||||
|
Intel Core i9-12900F
|
||||||
|
Intel Core i9-12900T
|
||||||
|
Intel Core i9-11900K
|
||||||
|
Intel Core i9-11900KF
|
||||||
|
Intel Core i9-11900
|
||||||
|
Intel Core i9-11900F
|
||||||
|
Intel Core i9-11900T
|
||||||
|
Intel Core i9-11900KB
|
||||||
|
Intel Core i9-10900K
|
||||||
|
Intel Core i9-10900KF
|
||||||
|
Intel Core i9-10910
|
||||||
|
Intel Core i9-10900
|
||||||
|
Intel Core i9-10900F
|
||||||
|
Intel Core i9-10900T
|
||||||
|
Intel Core i9-10850K
|
||||||
|
Intel Core i9-10980XE
|
||||||
|
Intel Core i9-10940X
|
||||||
|
Intel Core i9-10920X
|
||||||
|
Intel Core i9-10900X
|
||||||
|
Intel Core i9-9900KS
|
||||||
|
Intel Core i9-9900K
|
||||||
|
Intel Core i9-9900KF
|
||||||
|
Intel Core i9-9900
|
||||||
|
Intel Core i9-9900T
|
||||||
|
Intel Core i9-9980XE
|
||||||
|
Intel Core i9-9960X
|
||||||
|
Intel Core i9-9940X
|
||||||
|
Intel Core i9-9920X
|
||||||
|
Intel Core i9-9900X
|
||||||
|
Intel Core i9-7980XE
|
||||||
|
Intel Core i9-7960X
|
||||||
|
Intel Core i9-7940X
|
||||||
|
Intel Core i9-7920X
|
||||||
|
Intel Core i9-7900X
|
||||||
|
Intel Core i7-14790F
|
||||||
|
Intel Core i7-14700K
|
||||||
|
Intel Core i7-14700KF
|
||||||
|
Intel Core i7-14700
|
||||||
|
Intel Core i7-14700F
|
||||||
|
Intel Core i7-14700T
|
||||||
|
Intel Core i7-13790F
|
||||||
|
Intel Core i7-13700K
|
||||||
|
Intel Core i7-13700KF
|
||||||
|
Intel Core i7-13700
|
||||||
|
Intel Core i7-13700F
|
||||||
|
Intel Core i7-13700T
|
||||||
|
Intel Core i7-12700K
|
||||||
|
Intel Core i7-12700KF
|
||||||
|
Intel Core i7-12700
|
||||||
|
Intel Core i7-12700F
|
||||||
|
Intel Core i7-12700T
|
||||||
|
Intel Core i7-11700K
|
||||||
|
Intel Core i7-11700KF
|
||||||
|
Intel Core i7-11700
|
||||||
|
Intel Core i7-11700F
|
||||||
|
Intel Core i7-11700T
|
||||||
|
Intel Core i7-11700B
|
||||||
|
Intel Core i7-10700K
|
||||||
|
Intel Core i7-10700KF
|
||||||
|
Intel Core i7-10700
|
||||||
|
Intel Core i7-10700F
|
||||||
|
Intel Core i7-10700T
|
||||||
|
Intel Core i7-9700K
|
||||||
|
Intel Core i7-9700KF
|
||||||
|
Intel Core i7-9700
|
||||||
|
Intel Core i7-9700F
|
||||||
|
Intel Core i7-9700T
|
||||||
|
Intel Core i7-9800X
|
||||||
|
Intel Core i7-8086K
|
||||||
|
Intel Core i7-8700K
|
||||||
|
Intel Core i7-8700
|
||||||
|
Intel Core i7-8700T
|
||||||
|
Intel Core i7-7700K
|
||||||
|
Intel Core i7-7700
|
||||||
|
Intel Core i7-7700T
|
||||||
|
Intel Core i7-7820X
|
||||||
|
Intel Core i7-7800X
|
||||||
|
Intel Core i7-7740X
|
||||||
|
Intel Core i7-6785R
|
||||||
|
Intel Core i7-6700K
|
||||||
|
Intel Core i7-6700
|
||||||
|
Intel Core i7-6700T
|
||||||
|
Intel Core i7-6950X
|
||||||
|
Intel Core i7-6900K
|
||||||
|
Intel Core i7-6850K
|
||||||
|
Intel Core i7-6800K
|
||||||
|
Intel Core i7-5775C
|
||||||
|
Intel Core i7-5775R
|
||||||
|
Intel Core i7-5960X
|
||||||
|
Intel Core i7-5930K
|
||||||
|
Intel Core i7-5820K
|
||||||
|
Intel Core i7-4790K
|
||||||
|
Intel Core i7-4790
|
||||||
|
Intel Core i7-4790S
|
||||||
|
Intel Core i7-4790T
|
||||||
|
Intel Core i7-4785T
|
||||||
|
Intel Core i7-4771
|
||||||
|
Intel Core i7-4770K
|
||||||
|
Intel Core i7-4770
|
||||||
|
Intel Core i7-4770S
|
||||||
|
Intel Core i7-4770T
|
||||||
|
Intel Core i7-4770R
|
||||||
|
Intel Core i7-4765T
|
||||||
|
Intel Core i7-4960X
|
||||||
|
Intel Core i7-4930K
|
||||||
|
Intel Core i7-4820K
|
||||||
|
Intel Core i7-3770K
|
||||||
|
Intel Core i7-3770
|
||||||
|
Intel Core i7-3770S
|
||||||
|
Intel Core i7-3770T
|
||||||
|
Intel Core i7-2700K
|
||||||
|
Intel Core i7-2600K
|
||||||
|
Intel Core i7-2600
|
||||||
|
Intel Core i7-2600S
|
||||||
|
Intel Core i5-14600K
|
||||||
|
Intel Core i5-14600KF
|
||||||
|
Intel Core i5-14600
|
||||||
|
Intel Core i5-14600T
|
||||||
|
Intel Core i5-14500
|
||||||
|
Intel Core i5-14500T
|
||||||
|
Intel Core i5-14490F
|
||||||
|
Intel Core i5-14400
|
||||||
|
Intel Core i5-14400F
|
||||||
|
Intel Core i5-14400T
|
||||||
|
Intel Core i5-13600K
|
||||||
|
Intel Core i5-13600KF
|
||||||
|
Intel Core i5-13600
|
||||||
|
Intel Core i5-13600T
|
||||||
|
Intel Core i5-13500
|
||||||
|
Intel Core i5-13500T
|
||||||
|
Intel Core i5-13490F
|
||||||
|
Intel Core i5-13400
|
||||||
|
Intel Core i5-13400F
|
||||||
|
Intel Core i5-13400T
|
||||||
|
Intel Core i5-12600K
|
||||||
|
Intel Core i5-12600KF
|
||||||
|
Intel Core i5-12600
|
||||||
|
Intel Core i5-12600T
|
||||||
|
Intel Core i5-12500
|
||||||
|
Intel Core i5-12500T
|
||||||
|
Intel Core i5-12490F
|
||||||
|
Intel Core i5-12400
|
||||||
|
Intel Core i5-12400F
|
||||||
|
Intel Core i5-12400T
|
||||||
|
Intel Core i5-11600K
|
||||||
|
Intel Core i5-11600KF
|
||||||
|
Intel Core i5-11600
|
||||||
|
Intel Core i5-11600T
|
||||||
|
Intel Core i5-11500
|
||||||
|
Intel Core i5-11500T
|
||||||
|
Intel Core i5-11400
|
||||||
|
Intel Core i5-11400F
|
||||||
|
Intel Core i5-11400T
|
||||||
|
Intel Core i5-11500B
|
||||||
|
Intel Core i5-10600K
|
||||||
|
Intel Core i5-10600KF
|
||||||
|
Intel Core i5-10600
|
||||||
|
Intel Core i5-10600T
|
||||||
|
Intel Core i5-10505
|
||||||
|
Intel Core i5-10500
|
||||||
|
Intel Core i5-10500T
|
||||||
|
Intel Core i5-10400
|
||||||
|
Intel Core i5-10400F
|
||||||
|
Intel Core i5-10400T
|
||||||
|
Intel Core i5-9600K
|
||||||
|
Intel Core i5-9600KF
|
||||||
|
Intel Core i5-9600
|
||||||
|
Intel Core i5-9600T
|
||||||
|
Intel Core i5-9500
|
||||||
|
Intel Core i5-9500F
|
||||||
|
Intel Core i5-9500T
|
||||||
|
Intel Core i5-9400
|
||||||
|
Intel Core i5-9400F
|
||||||
|
Intel Core i5-9400T
|
||||||
|
Intel Core i5-8600K
|
||||||
|
Intel Core i5-8600
|
||||||
|
Intel Core i5-8600T
|
||||||
|
Intel Core i5-8500
|
||||||
|
Intel Core i5-8500T
|
||||||
|
Intel Core i5-8400
|
||||||
|
Intel Core i5-8400T
|
||||||
|
Intel Core i5-7600K
|
||||||
|
Intel Core i5-7600
|
||||||
|
Intel Core i5-7600T
|
||||||
|
Intel Core i5-7500
|
||||||
|
Intel Core i5-7500T
|
||||||
|
Intel Core i5-7400
|
||||||
|
Intel Core i5-7400T
|
||||||
|
Intel Core i5-7640X
|
||||||
|
Intel Core i5-6685R
|
||||||
|
Intel Core i5-6600K
|
||||||
|
Intel Core i5-6600
|
||||||
|
Intel Core i5-6600T
|
||||||
|
Intel Core i5-6585R
|
||||||
|
Intel Core i5-6500
|
||||||
|
Intel Core i5-6500T
|
||||||
|
Intel Core i5-6402P
|
||||||
|
Intel Core i5-6400
|
||||||
|
Intel Core i5-6400T
|
||||||
|
Intel Core i5-5675C
|
||||||
|
Intel Core i5-5675R
|
||||||
|
Intel Core i5-5575R
|
||||||
|
Intel Core i5-4690K
|
||||||
|
Intel Core i5-4690
|
||||||
|
Intel Core i5-4690S
|
||||||
|
Intel Core i5-4690T
|
||||||
|
Intel Core i5-4670K
|
||||||
|
Intel Core i5-4670
|
||||||
|
Intel Core i5-4670S
|
||||||
|
Intel Core i5-4670T
|
||||||
|
Intel Core i5-4670R
|
||||||
|
Intel Core i5-4590
|
||||||
|
Intel Core i5-4590S
|
||||||
|
Intel Core i5-4590T
|
||||||
|
Intel Core i5-4570
|
||||||
|
Intel Core i5-4570S
|
||||||
|
Intel Core i5-4570T
|
||||||
|
Intel Core i5-4570R
|
||||||
|
Intel Core i5-4460
|
||||||
|
Intel Core i5-4460S
|
||||||
|
Intel Core i5-4460T
|
||||||
|
Intel Core i5-4440
|
||||||
|
Intel Core i5-4440S
|
||||||
|
Intel Core i5-4430
|
||||||
|
Intel Core i5-4430S
|
||||||
|
Intel Core i5-3570K
|
||||||
|
Intel Core i5-3570
|
||||||
|
Intel Core i5-3570S
|
||||||
|
Intel Core i5-3570T
|
||||||
|
Intel Core i5-3550
|
||||||
|
Intel Core i5-3550S
|
||||||
|
Intel Core i5-3475S
|
||||||
|
Intel Core i5-3470
|
||||||
|
Intel Core i5-3470S
|
||||||
|
Intel Core i5-3470T
|
||||||
|
Intel Core i5-3450
|
||||||
|
Intel Core i5-3450S
|
||||||
|
Intel Core i5-3350P
|
||||||
|
Intel Core i5-3340
|
||||||
|
Intel Core i5-3340S
|
||||||
|
Intel Core i5-3335S
|
||||||
|
Intel Core i5-3330
|
||||||
|
Intel Core i5-3330S
|
||||||
|
Intel Core i5-2550K
|
||||||
|
Intel Core i5-2500K
|
||||||
|
Intel Core i5-2500
|
||||||
|
Intel Core i5-2500S
|
||||||
|
Intel Core i5-2500T
|
||||||
|
Intel Core i5-2450P
|
||||||
|
Intel Core i5-2405S
|
||||||
|
Intel Core i5-2400
|
||||||
|
Intel Core i5-2400S
|
||||||
|
Intel Core i5-2390T
|
||||||
|
Intel Core i5-2380P
|
||||||
|
Intel Core i5-2320
|
||||||
|
Intel Core i5-2310
|
||||||
|
Intel Core i5-2300
|
||||||
|
Intel Core i3-14100
|
||||||
|
Intel Core i3-14100F
|
||||||
|
Intel Core i3-14100T
|
||||||
|
Intel Core i3-13100
|
||||||
|
Intel Core i3-13100F
|
||||||
|
Intel Core i3-13100T
|
||||||
|
Intel Core i3-12300
|
||||||
|
Intel Core i3-12300T
|
||||||
|
Intel Core i3-12100
|
||||||
|
Intel Core i3-12100F
|
||||||
|
Intel Core i3-12100T
|
||||||
|
Intel Core i3-11100B
|
||||||
|
Intel Core i3-10325
|
||||||
|
Intel Core i3-10320
|
||||||
|
Intel Core i3-10305
|
||||||
|
Intel Core i3-10305T
|
||||||
|
Intel Core i3-10300
|
||||||
|
Intel Core i3-10300T
|
||||||
|
Intel Core i3-10105
|
||||||
|
Intel Core i3-10105F
|
||||||
|
Intel Core i3-10105T
|
||||||
|
Intel Core i3-10100
|
||||||
|
Intel Core i3-10100F
|
||||||
|
Intel Core i3-10100T
|
||||||
|
Intel Core i3-9350K
|
||||||
|
Intel Core i3-9350KF
|
||||||
|
Intel Core i3-9320
|
||||||
|
Intel Core i3-9300
|
||||||
|
Intel Core i3-9300T
|
||||||
|
Intel Core i3-9100
|
||||||
|
Intel Core i3-9100F
|
||||||
|
Intel Core i3-9100T
|
||||||
|
Intel Core i3-8350K
|
||||||
|
Intel Core i3-8300
|
||||||
|
Intel Core i3-8300T
|
||||||
|
Intel Core i3-8100
|
||||||
|
Intel Core i3-8100F
|
||||||
|
Intel Core i3-8100T
|
||||||
|
Intel Core i3-7350K
|
||||||
|
Intel Core i3-7320
|
||||||
|
Intel Core i3-7300
|
||||||
|
Intel Core i3-7300T
|
||||||
|
Intel Core i3-7100
|
||||||
|
Intel Core i3-7100T
|
||||||
|
Intel Core i3-6320
|
||||||
|
Intel Core i3-6300
|
||||||
|
Intel Core i3-6300T
|
||||||
|
Intel Core i3-6100
|
||||||
|
Intel Core i3-6100T
|
||||||
|
Intel Core i3-6098P
|
||||||
|
Intel Core i3-4370
|
||||||
|
Intel Core i3-4370T
|
||||||
|
Intel Core i3-4360
|
||||||
|
Intel Core i3-4360T
|
||||||
|
Intel Core i3-4350
|
||||||
|
Intel Core i3-4350T
|
||||||
|
Intel Core i3-4340
|
||||||
|
Intel Core i3-4330
|
||||||
|
Intel Core i3-4330T
|
||||||
|
Intel Core i3-4170
|
||||||
|
Intel Core i3-4170T
|
||||||
|
Intel Core i3-4160
|
||||||
|
Intel Core i3-4160T
|
||||||
|
Intel Core i3-4150
|
||||||
|
Intel Core i3-4150T
|
||||||
|
Intel Core i3-4130
|
||||||
|
Intel Core i3-4130T
|
||||||
|
Intel Core i3-3250
|
||||||
|
Intel Core i3-3250T
|
||||||
|
Intel Core i3-3245
|
||||||
|
Intel Core i3-3240
|
||||||
|
Intel Core i3-3240T
|
||||||
|
Intel Core i3-3225
|
||||||
|
Intel Core i3-3220
|
||||||
|
Intel Core i3-3220T
|
||||||
|
Intel Core i3-3210
|
||||||
|
Intel Core i3-2130
|
||||||
|
Intel Core i3-2125
|
||||||
|
Intel Core i3-2120
|
||||||
|
Intel Core i3-2120T
|
||||||
|
Intel Core i3-2105
|
||||||
|
Intel Core i3-2102
|
||||||
|
Intel Core i3-2100
|
||||||
|
Intel Core i3-2100T
|
||||||
|
AMD Ryzen 9 9950X3D2 Dual Edition
|
||||||
|
AMD Ryzen 9 PRO 9965X3D
|
||||||
|
AMD Ryzen 9 PRO 9965
|
||||||
|
AMD Ryzen 9 9950X3D
|
||||||
|
AMD Ryzen 9 9950X
|
||||||
|
AMD Ryzen 9 PRO 9955
|
||||||
|
AMD Ryzen 9 PRO 9945
|
||||||
|
AMD Ryzen 9 9900X3D
|
||||||
|
AMD Ryzen 9 9900X
|
||||||
|
AMD Ryzen 9 7950X3D
|
||||||
|
AMD Ryzen 9 7950X
|
||||||
|
AMD Ryzen 9 PRO 7945
|
||||||
|
AMD Ryzen 9 7900X3D
|
||||||
|
AMD Ryzen 9 7900X
|
||||||
|
AMD Ryzen 9 7900
|
||||||
|
AMD Ryzen 9 5950X
|
||||||
|
AMD Ryzen 9 PRO 5945
|
||||||
|
AMD Ryzen 9 5900XT
|
||||||
|
AMD Ryzen 9 5900X
|
||||||
|
AMD Ryzen 9 5900
|
||||||
|
AMD Ryzen 9 3950X
|
||||||
|
AMD Ryzen 9 PRO 3900
|
||||||
|
AMD Ryzen 9 3900XT
|
||||||
|
AMD Ryzen 9 3900X
|
||||||
|
AMD Ryzen 9 3900
|
||||||
|
AMD Ryzen 7 9850X3D
|
||||||
|
AMD Ryzen 7 9800X3D
|
||||||
|
AMD Ryzen 7 PRO 9755X3D
|
||||||
|
AMD Ryzen 7 PRO 9755
|
||||||
|
AMD Ryzen 7 PRO 9745
|
||||||
|
AMD Ryzen 7 9700X
|
||||||
|
AMD Ryzen 7 9700F
|
||||||
|
AMD Ryzen 7 8700G
|
||||||
|
AMD Ryzen 7 PRO 8700G
|
||||||
|
AMD Ryzen 7 PRO 8700GE
|
||||||
|
AMD Ryzen 7 8700F
|
||||||
|
AMD Ryzen 7 7800X3D
|
||||||
|
AMD Ryzen 7 PRO 7745
|
||||||
|
AMD Ryzen 7 7700X3D
|
||||||
|
AMD Ryzen 7 7700X
|
||||||
|
AMD Ryzen 7 7700
|
||||||
|
AMD Ryzen 7 5800X3D
|
||||||
|
AMD Ryzen 7 5800XT
|
||||||
|
AMD Ryzen 7 5800X
|
||||||
|
AMD Ryzen 7 5800
|
||||||
|
AMD Ryzen 7 PRO 5845
|
||||||
|
AMD Ryzen 7 5755G
|
||||||
|
AMD Ryzen 7 5755GE
|
||||||
|
AMD Ryzen 7 5750G
|
||||||
|
AMD Ryzen 7 5750GE
|
||||||
|
AMD Ryzen 7 5705G
|
||||||
|
AMD Ryzen 7 5705GE
|
||||||
|
AMD Ryzen 7 5700G
|
||||||
|
AMD Ryzen 7 5700GE
|
||||||
|
AMD Ryzen 7 5700X3D
|
||||||
|
AMD Ryzen 7 5700X
|
||||||
|
AMD Ryzen 7 5700
|
||||||
|
AMD Ryzen 7 4750G PRO
|
||||||
|
AMD Ryzen 7 4750GE PRO
|
||||||
|
AMD Ryzen 7 4700LE
|
||||||
|
AMD Ryzen 7 4700G
|
||||||
|
AMD Ryzen 7 4700GE
|
||||||
|
AMD Ryzen 7 3800XT
|
||||||
|
AMD Ryzen 7 3800X
|
||||||
|
AMD Ryzen 7 PRO 3700
|
||||||
|
AMD Ryzen 7 3700X
|
||||||
|
AMD Ryzen 7 2700X
|
||||||
|
AMD Ryzen 7 2700
|
||||||
|
AMD Ryzen 7 2700E
|
||||||
|
AMD Ryzen 7 1800X
|
||||||
|
AMD Ryzen 7 PRO 1700X
|
||||||
|
AMD Ryzen 7 1700X
|
||||||
|
AMD Ryzen 7 PRO 1700
|
||||||
|
AMD Ryzen 7 1700
|
||||||
|
AMD Ryzen 5 PRO 9655
|
||||||
|
AMD Ryzen 5 PRO 9645
|
||||||
|
AMD Ryzen 5 9600X
|
||||||
|
AMD Ryzen 5 9600
|
||||||
|
AMD Ryzen 5 9500F
|
||||||
|
AMD Ryzen 5 8600G
|
||||||
|
AMD Ryzen 5 PRO 8600G
|
||||||
|
AMD Ryzen 5 PRO 8600GE
|
||||||
|
AMD Ryzen 5 8500G
|
||||||
|
AMD Ryzen 5 PRO 8500G
|
||||||
|
AMD Ryzen 5 8500GE
|
||||||
|
AMD Ryzen 5 PRO 8500GE
|
||||||
|
AMD Ryzen 5 8400F
|
||||||
|
AMD Ryzen 5 7600X3D
|
||||||
|
AMD Ryzen 5 7600X
|
||||||
|
AMD Ryzen 5 7600
|
||||||
|
AMD Ryzen 5 PRO 7645
|
||||||
|
AMD Ryzen 5 7500X3D
|
||||||
|
AMD Ryzen 5 7500
|
||||||
|
AMD Ryzen 5 7500F
|
||||||
|
AMD Ryzen 5 7400
|
||||||
|
AMD Ryzen 5 7400F
|
||||||
|
AMD Ryzen 5 5655G PRO
|
||||||
|
AMD Ryzen 5 5655GE PRO
|
||||||
|
AMD Ryzen 5 5650G PRO
|
||||||
|
AMD Ryzen 5 5650GE PRO
|
||||||
|
AMD Ryzen 5 PRO 5645
|
||||||
|
AMD Ryzen 5 5605G
|
||||||
|
AMD Ryzen 5 5605GE
|
||||||
|
AMD Ryzen 5 5600X3D
|
||||||
|
AMD Ryzen 5 5600XT
|
||||||
|
AMD Ryzen 5 5600X
|
||||||
|
AMD Ryzen 5 5600T
|
||||||
|
AMD Ryzen 5 5600
|
||||||
|
AMD Ryzen 5 5600F
|
||||||
|
AMD Ryzen 5 5600GT
|
||||||
|
AMD Ryzen 5 5600G
|
||||||
|
AMD Ryzen 5 5600GE
|
||||||
|
AMD Ryzen 5 5500X3D
|
||||||
|
AMD Ryzen 5 5500F
|
||||||
|
AMD Ryzen 5 5500GT
|
||||||
|
AMD Ryzen 5 5500
|
||||||
|
AMD Ryzen 5 PRO 4655G
|
||||||
|
AMD Ryzen 5 PRO 4655GE
|
||||||
|
AMD Ryzen 5 PRO 4650G
|
||||||
|
AMD Ryzen 5 PRO 4650GE
|
||||||
|
AMD Ryzen 5 4600G
|
||||||
|
AMD Ryzen 5 4600GE
|
||||||
|
AMD Ryzen 5 4500
|
||||||
|
AMD Ryzen 5 PRO 3600
|
||||||
|
AMD Ryzen 5 3600XT
|
||||||
|
AMD Ryzen 5 3600X
|
||||||
|
AMD Ryzen 5 3600
|
||||||
|
AMD Ryzen 5 3500X
|
||||||
|
AMD Ryzen 5 3500
|
||||||
|
AMD Ryzen 5 PRO 3400G
|
||||||
|
AMD Ryzen 5 3400G
|
||||||
|
AMD Ryzen 5 PRO 3400GE
|
||||||
|
AMD Ryzen 5 PRO 3350G
|
||||||
|
AMD Ryzen 5 PRO 3350GE
|
||||||
|
AMD Ryzen 5 2600X
|
||||||
|
AMD Ryzen 5 2600
|
||||||
|
AMD Ryzen 5 2600E
|
||||||
|
AMD Ryzen 5 2500X
|
||||||
|
AMD Ryzen 5 2400G
|
||||||
|
AMD Ryzen 5 2400GE
|
||||||
|
AMD Ryzen 5 PRO 2400G
|
||||||
|
AMD Ryzen 5 PRO 2400GE
|
||||||
|
AMD Ryzen 5 1600 AF
|
||||||
|
AMD Ryzen 5 1600X
|
||||||
|
AMD Ryzen 5 PRO 1600
|
||||||
|
AMD Ryzen 5 1600
|
||||||
|
AMD Ryzen 5 1500X
|
||||||
|
AMD Ryzen 5 PRO 1500
|
||||||
|
AMD Ryzen 5 1400
|
||||||
|
AMD Ryzen 3 8300G
|
||||||
|
AMD Ryzen 3 PRO 8300G
|
||||||
|
AMD Ryzen 3 8300GE
|
||||||
|
AMD Ryzen 3 PRO 8300GE
|
||||||
|
AMD Ryzen 3 PRO 5355G
|
||||||
|
AMD Ryzen 3 PRO 5355GE
|
||||||
|
AMD Ryzen 3 PRO 5350G
|
||||||
|
AMD Ryzen 3 PRO 5350GE
|
||||||
|
AMD Ryzen 3 5305G
|
||||||
|
AMD Ryzen 3 5305GE
|
||||||
|
AMD Ryzen 3 5300G
|
||||||
|
AMD Ryzen 3 5300GE
|
||||||
|
AMD Ryzen 3 5100
|
||||||
|
AMD Ryzen 3 PRO 4355G
|
||||||
|
AMD Ryzen 3 PRO 4355GE
|
||||||
|
AMD Ryzen 3 PRO 4350G
|
||||||
|
AMD Ryzen 3 PRO 4350GE
|
||||||
|
AMD Ryzen 3 4300G
|
||||||
|
AMD Ryzen 3 4300GE
|
||||||
|
AMD Ryzen 3 4100
|
||||||
|
AMD Ryzen 3 3300X
|
||||||
|
AMD Ryzen 3 PRO 3200G
|
||||||
|
AMD Ryzen 3 3200G
|
||||||
|
AMD Ryzen 3 3200GE
|
||||||
|
AMD Ryzen 3 PRO 3200GE
|
||||||
|
AMD Ryzen 3 3100
|
||||||
|
AMD Ryzen 3 2300X
|
||||||
|
AMD Ryzen 3 2200G
|
||||||
|
AMD Ryzen 3 2200GE
|
||||||
|
AMD Ryzen 3 PRO 2200G
|
||||||
|
AMD Ryzen 3 PRO 2200GE
|
||||||
|
AMD Ryzen 3 PRO 2100GE
|
||||||
|
AMD Ryzen 3 1200 AF
|
||||||
|
AMD Ryzen 3 1300X
|
||||||
|
AMD Ryzen 3 PRO 1300
|
||||||
|
AMD Ryzen 3 PRO 1200
|
||||||
|
AMD Ryzen 3 1200
|
||||||
|
AMD Ryzen AI 7 450G
|
||||||
|
AMD Ryzen AI 7 450GE
|
||||||
|
AMD Ryzen AI 7 PRO 450G
|
||||||
|
AMD Ryzen AI 7 PRO 450GE
|
||||||
|
AMD Ryzen AI 5 440G
|
||||||
|
AMD Ryzen AI 5 440GE
|
||||||
|
AMD Ryzen AI 5 PRO 440G
|
||||||
|
AMD Ryzen AI 5 PRO 440GE
|
||||||
|
AMD Ryzen AI 5 435G
|
||||||
|
AMD Ryzen AI 5 435GE
|
||||||
|
AMD Ryzen AI 5 PRO 435G
|
||||||
|
AMD Ryzen AI 5 PRO 435GE
|
||||||
|
AMD FX-9590
|
||||||
|
AMD FX-9370
|
||||||
|
AMD FX-8370
|
||||||
|
AMD FX-8370E
|
||||||
|
AMD FX-8350
|
||||||
|
AMD FX-8320
|
||||||
|
AMD FX-8320E
|
||||||
|
AMD FX-8310
|
||||||
|
AMD FX-8300
|
||||||
|
AMD FX-8150
|
||||||
|
AMD FX-8140
|
||||||
|
AMD FX-8120
|
||||||
|
AMD FX-8100
|
||||||
|
AMD FX-6350
|
||||||
|
AMD FX-6330
|
||||||
|
AMD FX-6300
|
||||||
|
AMD FX-6200
|
||||||
|
AMD FX-6130
|
||||||
|
AMD FX-6120
|
||||||
|
AMD FX-6100
|
||||||
|
AMD FX-4350
|
||||||
|
AMD FX-4320
|
||||||
|
AMD FX-4300
|
||||||
|
AMD FX-4200
|
||||||
|
AMD FX-4170
|
||||||
|
AMD FX-4150
|
||||||
|
AMD FX-4130
|
||||||
|
AMD FX-4100
|
||||||
|
AMD Ryzen Threadripper PRO 9995WX
|
||||||
|
AMD Ryzen Threadripper PRO 9985WX
|
||||||
|
AMD Ryzen Threadripper PRO 9975WX
|
||||||
|
AMD Ryzen Threadripper PRO 9965WX
|
||||||
|
AMD Ryzen Threadripper PRO 9955WX
|
||||||
|
AMD Ryzen Threadripper PRO 9945WX
|
||||||
|
AMD Ryzen Threadripper 9980X
|
||||||
|
AMD Ryzen Threadripper 9970X
|
||||||
|
AMD Ryzen Threadripper 9960X
|
||||||
|
AMD Ryzen Threadripper PRO 7995WX
|
||||||
|
AMD Ryzen Threadripper PRO 7985WX
|
||||||
|
AMD Ryzen Threadripper PRO 7975WX
|
||||||
|
AMD Ryzen Threadripper PRO 7965WX
|
||||||
|
AMD Ryzen Threadripper PRO 7955WX
|
||||||
|
AMD Ryzen Threadripper PRO 7945WX
|
||||||
|
AMD Ryzen Threadripper 7980X
|
||||||
|
AMD Ryzen Threadripper 7970X
|
||||||
|
AMD Ryzen Threadripper 7960X
|
||||||
|
AMD Ryzen Threadripper PRO 5995WX
|
||||||
|
AMD Ryzen Threadripper PRO 5975WX
|
||||||
|
AMD Ryzen Threadripper PRO 5965WX
|
||||||
|
AMD Ryzen Threadripper PRO 5955WX
|
||||||
|
AMD Ryzen Threadripper PRO 5945WX
|
||||||
|
AMD Ryzen Threadripper PRO 3995WX
|
||||||
|
AMD Ryzen Threadripper PRO 3975WX
|
||||||
|
AMD Ryzen Threadripper PRO 3955WX
|
||||||
|
AMD Ryzen Threadripper PRO 3945WX
|
||||||
|
AMD Ryzen Threadripper 3990X
|
||||||
|
AMD Ryzen Threadripper 3970X
|
||||||
|
AMD Ryzen Threadripper 3960X
|
||||||
|
AMD Ryzen Threadripper 2990WX
|
||||||
|
AMD Ryzen Threadripper 2970WX
|
||||||
|
AMD Ryzen Threadripper 2950X
|
||||||
|
AMD Ryzen Threadripper 2920X
|
||||||
|
AMD Ryzen Threadripper 1950X
|
||||||
|
AMD Ryzen Threadripper 1920X
|
||||||
|
AMD Ryzen Threadripper 1900X
|
||||||
61
inventory/device_types.py
Normal file
61
inventory/device_types.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Hardware fields applicable to each of the built-in device types."""
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
TYPE_FIELDS = {
|
||||||
|
"laptop": COMPUTER_FIELDS,
|
||||||
|
"aio": COMPUTER_FIELDS - {"battery"},
|
||||||
|
"desktop": COMPUTER_FIELDS - {"battery", "display_inches"},
|
||||||
|
"bildschirm": frozenset({"display_inches", "resolution", "ports"}),
|
||||||
|
"monitor": frozenset({"display_inches", "resolution", "ports"}),
|
||||||
|
"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"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def applicable_fields(device_type):
|
||||||
|
"""Unknown custom types retain all fields until configured explicitly."""
|
||||||
|
if device_type is None:
|
||||||
|
return COMPUTER_FIELDS
|
||||||
|
return TYPE_FIELDS.get(device_type.label.strip().casefold(), COMPUTER_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
def field_applies(device_type, field):
|
||||||
|
return field in applicable_fields(device_type)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_inapplicable(device):
|
||||||
|
"""Move incompatible legacy values into JSON before clearing active fields."""
|
||||||
|
old_values = {}
|
||||||
|
legacy = dict(device.custom_values.get("legacy_hardware") or {})
|
||||||
|
for name in HARDWARE_FIELDS - applicable_fields(device.device_type):
|
||||||
|
value = getattr(device, name)
|
||||||
|
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)
|
||||||
|
if old_values:
|
||||||
|
device.custom_values = {**device.custom_values, "legacy_hardware": legacy}
|
||||||
|
return old_values
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_create_specifications(devices):
|
||||||
|
"""Create specification rows after the intentional bulk device insert."""
|
||||||
|
from .models import HARDWARE_DEFAULTS, SPECIFICATION_MODELS, specification_model
|
||||||
|
|
||||||
|
buckets = {model: [] for model in SPECIFICATION_MODELS}
|
||||||
|
for device in devices:
|
||||||
|
if device._hardware_cache is None:
|
||||||
|
device._hardware_cache = dict(HARDWARE_DEFAULTS)
|
||||||
|
model = specification_model(device.device_type)
|
||||||
|
buckets[model].append(model(device_id=device.pk, **{
|
||||||
|
name: getattr(device, name) for name in model.hardware_field_names
|
||||||
|
}))
|
||||||
|
for model, specifications in buckets.items():
|
||||||
|
if specifications:
|
||||||
|
model.objects.bulk_create(specifications, batch_size=1000)
|
||||||
13
inventory/error_views.py
Normal file
13
inventory/error_views.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
|
||||||
|
def permission_denied(request, exception=None):
|
||||||
|
return render(request, "403.html", status=403)
|
||||||
|
|
||||||
|
|
||||||
|
def not_found(request, exception=None):
|
||||||
|
return render(request, "404.html", status=404)
|
||||||
|
|
||||||
|
|
||||||
|
def server_error(request):
|
||||||
|
return render(request, "500.html", status=500)
|
||||||
313
inventory/forms.py
Normal file
313
inventory/forms.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
from django import forms
|
||||||
|
from copy import deepcopy
|
||||||
|
from django.db import models
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y-%m-%d"]
|
||||||
|
GERMAN_DATE_WIDGET = {"type": "text", "placeholder": "TT.MM.JJJJ", "inputmode": "numeric", "data-german-date": ""}
|
||||||
|
STATUS_COLOR_CHOICES = [
|
||||||
|
("#2563eb", "Blau"), ("#0891b2", "Türkis"), ("#15803d", "Grün"),
|
||||||
|
("#4d7c0f", "Limette"), ("#b45309", "Orange"), ("#a16207", "Gelb"),
|
||||||
|
("#dc2626", "Rot"), ("#be185d", "Pink"), ("#7c3aed", "Violett"),
|
||||||
|
("#4f46e5", "Indigo"), ("#64748b", "Grau"), ("#334155", "Anthrazit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class QuickAddForm(forms.Form):
|
||||||
|
number = forms.IntegerField(label="Erste Nr.", min_value=1, max_value=2147483647, help_text="Die vorgeschlagene Startnummer kann geändert werden.")
|
||||||
|
quantity = forms.IntegerField(label="Anzahl", min_value=1, initial=1, help_text="Alle Geräte erhalten aufsteigende Nummern.")
|
||||||
|
shelf = forms.CharField(label="Regal", max_length=80, required=False)
|
||||||
|
status = forms.ModelChoiceField(label="Status", queryset=ChoiceValue.objects.none())
|
||||||
|
date = forms.DateField(
|
||||||
|
label="Datum", input_formats=GERMAN_DATE_INPUT_FORMATS,
|
||||||
|
widget=forms.DateInput(format="%d.%m.%Y", attrs=GERMAN_DATE_WIDGET),
|
||||||
|
)
|
||||||
|
device_type = forms.ModelChoiceField(label="Typ", queryset=ChoiceValue.objects.none(), required=False)
|
||||||
|
employee = forms.ModelChoiceField(
|
||||||
|
label="Bearbeiter", queryset=Employee.objects.none(), required=True,
|
||||||
|
empty_label="Bearbeiter auswählen", error_messages={"required": "Bitte einen Bearbeiter auswählen."},
|
||||||
|
)
|
||||||
|
donor = forms.CharField(label="Spender", max_length=200, required=False)
|
||||||
|
model = forms.CharField(label="Modell", max_length=200, required=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["status"].queryset = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True)
|
||||||
|
self.fields["device_type"].queryset = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True)
|
||||||
|
self.fields["employee"].queryset = Employee.objects.filter(active=True)
|
||||||
|
selected_type = None
|
||||||
|
if self.is_bound and str(self.data.get("device_type", "")).isdecimal():
|
||||||
|
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"}:
|
||||||
|
field.required = False
|
||||||
|
field.initial = Device.TriState.UNKNOWN
|
||||||
|
if self.is_bound and name not in applicable_fields(selected_type):
|
||||||
|
field.disabled = True
|
||||||
|
self.fields[name] = field
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hardware_fields(self):
|
||||||
|
return [self[name] for name in self.fields if name in HARDWARE_FIELDS]
|
||||||
|
|
||||||
|
def selected_hardware_values(self):
|
||||||
|
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:
|
||||||
|
value = Device.TriState.UNKNOWN
|
||||||
|
values[name] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
def clean_number(self):
|
||||||
|
number = self.cleaned_data["number"]
|
||||||
|
if Device.objects.filter(number=number).exists():
|
||||||
|
raise forms.ValidationError(f"Die Gerätenummer {number} ist bereits vergeben.")
|
||||||
|
return number
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
first_number = cleaned.get("number")
|
||||||
|
quantity = cleaned.get("quantity")
|
||||||
|
if first_number is None or quantity is None:
|
||||||
|
return cleaned
|
||||||
|
last_number = first_number + quantity - 1
|
||||||
|
if last_number > 2147483647:
|
||||||
|
self.add_error("quantity", "Die letzte Gerätenummer wäre zu groß.")
|
||||||
|
return cleaned
|
||||||
|
conflict = Device.objects.filter(number__range=(first_number, last_number)).order_by("number").values_list("number", flat=True).first()
|
||||||
|
if conflict is not None:
|
||||||
|
self.add_error("number", f"Die Gerätenummer {conflict} ist bereits vergeben.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceForm(forms.ModelForm):
|
||||||
|
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)
|
||||||
|
display_inches = forms.DecimalField(label="Zoll", max_digits=4, decimal_places=1, min_value=0, required=False, widget=forms.NumberInput(attrs={"step": "0.1"}))
|
||||||
|
ram_gb = forms.IntegerField(label="RAM (GB)", min_value=0, required=False)
|
||||||
|
ssd_gb = forms.IntegerField(label="SSD (GB)", min_value=0, required=False)
|
||||||
|
hdd_gb = forms.IntegerField(label="HDD (GB)", min_value=0, required=False)
|
||||||
|
cpu = forms.CharField(label="Prozessor", max_length=200, required=False)
|
||||||
|
hwid = forms.CharField(label="HWID", max_length=255, required=False)
|
||||||
|
ports = forms.CharField(label="Anschlüsse", max_length=255, required=False)
|
||||||
|
resolution = forms.CharField(label="Auflösung", max_length=80, required=False)
|
||||||
|
print_technology = forms.CharField(label="Druckverfahren", max_length=80, required=False)
|
||||||
|
duplex = forms.ChoiceField(label="Duplex", choices=Device.TriState.choices, required=False)
|
||||||
|
color_printing = forms.ChoiceField(label="Farbdruck", choices=Device.TriState.choices, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Device
|
||||||
|
exclude = ["number", "archived_at", "archived_by", "custom_values", "stage"]
|
||||||
|
labels = {
|
||||||
|
"shelf": "Regal", "status": "Bestandsstatus", "date": "Datum", "recipient": "Empfänger",
|
||||||
|
"location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis",
|
||||||
|
"battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll",
|
||||||
|
"ram_gb": "RAM (GB)", "ssd_gb": "SSD (GB)", "hdd_gb": "HDD (GB)",
|
||||||
|
"cpu": "Prozessor", "hwid": "HWID", "model": "Modell", "employee": "Bearbeiter",
|
||||||
|
"donor": "Spender", "comment": "Kommentar",
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
"date": forms.DateInput(format="%d.%m.%Y", attrs=GERMAN_DATE_WIDGET),
|
||||||
|
"comment": forms.Textarea(attrs={"rows": 4}),
|
||||||
|
"price": forms.NumberInput(attrs={"step": "0.01"}),
|
||||||
|
"display_inches": forms.NumberInput(attrs={"step": "0.1"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if not self.is_bound:
|
||||||
|
for name in HARDWARE_FIELDS:
|
||||||
|
self.initial.setdefault(name, getattr(self.instance, name))
|
||||||
|
selected_type = self.instance.device_type if self.instance.device_type_id else None
|
||||||
|
if self.is_bound and str(self.data.get("device_type", "")).isdecimal():
|
||||||
|
selected_type = ChoiceValue.objects.filter(
|
||||||
|
pk=self.data.get("device_type"), kind=ChoiceValue.Kind.DEVICE_TYPE,
|
||||||
|
).first()
|
||||||
|
if self.is_bound:
|
||||||
|
for name in HARDWARE_FIELDS - applicable_fields(selected_type):
|
||||||
|
self.fields[name].disabled = True
|
||||||
|
self.fields["date"].input_formats = GERMAN_DATE_INPUT_FORMATS
|
||||||
|
mapping = {
|
||||||
|
"status": ChoiceValue.Kind.STATUS,
|
||||||
|
"recipient": ChoiceValue.Kind.RECIPIENT,
|
||||||
|
"location": ChoiceValue.Kind.LOCATION,
|
||||||
|
"device_type": ChoiceValue.Kind.DEVICE_TYPE,
|
||||||
|
}
|
||||||
|
for field_name, kind in mapping.items():
|
||||||
|
current = getattr(self.instance, f"{field_name}_id", None)
|
||||||
|
queryset = ChoiceValue.objects.filter(kind=kind, active=True)
|
||||||
|
if current:
|
||||||
|
queryset = ChoiceValue.objects.filter(kind=kind).filter(models.Q(active=True) | models.Q(pk=current))
|
||||||
|
self.fields[field_name].queryset = queryset
|
||||||
|
current_employee = getattr(self.instance, "employee_id", None)
|
||||||
|
employee_queryset = Employee.objects.filter(active=True)
|
||||||
|
if current_employee:
|
||||||
|
employee_queryset = Employee.objects.filter(models.Q(active=True) | models.Q(pk=current_employee))
|
||||||
|
self.fields["employee"].queryset = employee_queryset
|
||||||
|
for field_name in ["ram_gb", "ssd_gb", "hdd_gb", "display_inches"]:
|
||||||
|
if field_name in self.fields:
|
||||||
|
self.fields[field_name].widget.attrs["list"] = f"suggestions-{field_name}"
|
||||||
|
if "cpu" in self.fields:
|
||||||
|
self.fields["cpu"].widget.attrs.update({"data-cpu-autocomplete": "", "autocomplete": "off"})
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
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:
|
||||||
|
value = Device.TriState.UNKNOWN
|
||||||
|
setattr(device, name, value)
|
||||||
|
if commit:
|
||||||
|
device.save()
|
||||||
|
self.save_m2m()
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
class InspectionForm(forms.Form):
|
||||||
|
defects = forms.CharField(label="Festgestellte Mängel", widget=forms.Textarea(attrs={"rows": 2}), max_length=4000)
|
||||||
|
model = forms.CharField(label="Modell", max_length=200, required=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, device, **kwargs):
|
||||||
|
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"):
|
||||||
|
if name in applicable_fields(device.device_type):
|
||||||
|
self.fields[name] = deepcopy(DeviceForm.base_fields[name])
|
||||||
|
self.initial.setdefault(name, getattr(device, name))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def specification_fields(self):
|
||||||
|
return [self[name] for name in self.fields if name != "defects"]
|
||||||
|
|
||||||
|
|
||||||
|
class RepairForm(forms.Form):
|
||||||
|
work_done = forms.CharField(label="Durchgeführte Arbeiten", max_length=4000, widget=forms.Textarea(attrs={"rows": 2}))
|
||||||
|
|
||||||
|
|
||||||
|
class StorageRecipientForm(forms.Form):
|
||||||
|
recipient = forms.CharField(label="Empfänger", max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Employee
|
||||||
|
fields = ["code", "name", "active"]
|
||||||
|
labels = {"code": "Kürzel", "name": "Name", "active": "Aktiv"}
|
||||||
|
help_texts = {"code": "Kurzes eindeutiges Kürzel, zum Beispiel JH."}
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceValueForm(forms.ModelForm):
|
||||||
|
color = forms.ChoiceField(
|
||||||
|
label="Farbe", choices=STATUS_COLOR_CHOICES, required=False,
|
||||||
|
widget=forms.RadioSelect, help_text="Wird für Statuswerte in Liste und Übersicht verwendet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ChoiceValue
|
||||||
|
fields = ["kind", "label", "color", "numeric_value", "active", "sort_order"]
|
||||||
|
labels = {
|
||||||
|
"kind": "Kategorie", "label": "Bezeichnung", "numeric_value": "Zahlenwert",
|
||||||
|
"active": "Aktiv", "sort_order": "Sortierreihenfolge",
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
if cleaned.get("kind") == ChoiceValue.Kind.STATUS and not cleaned.get("color"):
|
||||||
|
cleaned["color"] = STATUS_COLOR_CHOICES[0][0]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDefinitionForm(forms.ModelForm):
|
||||||
|
options_text = forms.CharField(
|
||||||
|
required=False, label="Optionen", help_text="Eine Option pro Zeile (nur für Auswahlfelder).",
|
||||||
|
widget=forms.Textarea(attrs={"rows": 5}),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FieldDefinition
|
||||||
|
fields = [
|
||||||
|
"name", "key", "field_type", "unit", "required", "active", "show_in_list",
|
||||||
|
"filterable", "searchable", "sort_order", "group", "allow_custom_values",
|
||||||
|
]
|
||||||
|
labels = {
|
||||||
|
"name": "Name", "key": "Interner Schlüssel", "field_type": "Feldtyp", "unit": "Einheit",
|
||||||
|
"required": "Pflichtfeld", "active": "Aktiv", "show_in_list": "In der Geräteliste anzeigen",
|
||||||
|
"filterable": "Filterbar", "searchable": "Durchsuchbar", "sort_order": "Sortierreihenfolge",
|
||||||
|
"group": "Gruppe", "allow_custom_values": "Eigene Werte erlauben",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance.pk:
|
||||||
|
self.fields["options_text"].initial = "\n".join(self.instance.options.values_list("label", flat=True))
|
||||||
|
self.fields["key"].disabled = True
|
||||||
|
self.fields["key"].help_text = "Der interne Key bleibt nach dem Anlegen stabil."
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=commit)
|
||||||
|
if commit:
|
||||||
|
labels = [line.strip() for line in self.cleaned_data.get("options_text", "").splitlines() if line.strip()]
|
||||||
|
for order, label in enumerate(labels):
|
||||||
|
FieldOption.objects.update_or_create(field=instance, label=label, defaults={"sort_order": order, "active": True})
|
||||||
|
instance.options.exclude(label__in=labels).update(active=False)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelUploadForm(forms.Form):
|
||||||
|
file = forms.FileField(label="Excel-Datei (.xlsx)")
|
||||||
|
|
||||||
|
def clean_file(self):
|
||||||
|
uploaded = self.cleaned_data["file"]
|
||||||
|
if not uploaded.name.lower().endswith(".xlsx"):
|
||||||
|
raise forms.ValidationError("Bitte eine .xlsx-Datei auswählen.")
|
||||||
|
if uploaded.size > 10 * 1024 * 1024:
|
||||||
|
raise forms.ValidationError("Die Datei darf höchstens 10 MB groß sein.")
|
||||||
|
return uploaded
|
||||||
|
|
||||||
|
|
||||||
|
class UserManagementForm(forms.ModelForm):
|
||||||
|
role = forms.ChoiceField(label="Rolle", choices=UserProfile.Role.choices)
|
||||||
|
password = forms.CharField(label="Passwort", required=False, widget=forms.PasswordInput, help_text="Bei bestehenden Konten leer lassen, um das Passwort nicht zu ändern.")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ["username", "first_name", "last_name", "email", "is_active"]
|
||||||
|
labels = {
|
||||||
|
"username": "Benutzername", "first_name": "Vorname", "last_name": "Nachname",
|
||||||
|
"email": "E-Mail-Adresse", "is_active": "Aktiv",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance.pk:
|
||||||
|
profile, _ = UserProfile.objects.get_or_create(user=self.instance)
|
||||||
|
self.fields["role"].initial = profile.role
|
||||||
|
|
||||||
|
def clean_password(self):
|
||||||
|
password = self.cleaned_data.get("password", "")
|
||||||
|
if not self.instance.pk and not password:
|
||||||
|
raise forms.ValidationError("Für ein neues Konto ist ein Passwort erforderlich.")
|
||||||
|
if password:
|
||||||
|
validate_password(password, self.instance if self.instance.pk else None)
|
||||||
|
return password
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
password = self.cleaned_data.get("password")
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||||
|
profile.role = UserProfile.Role.ADMIN if user.is_superuser else self.cleaned_data["role"]
|
||||||
|
profile.save()
|
||||||
|
return user
|
||||||
267
inventory/import_export.py
Normal file
267
inventory/import_export.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import zipfile
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpResponse
|
||||||
|
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 .services import add_audit
|
||||||
|
|
||||||
|
|
||||||
|
COLUMN_MAP = {
|
||||||
|
"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",
|
||||||
|
"lan": "lan", "zoll": "display_inches", "ram": "ram_gb", "ssd": "ssd_gb", "hdd": "hdd_gb",
|
||||||
|
"cpu": "cpu", "hwid": "hwid", "anschlüsse": "ports", "anschluesse": "ports",
|
||||||
|
"auflösung": "resolution", "aufloesung": "resolution", "druckverfahren": "print_technology",
|
||||||
|
"duplex": "duplex", "farbdruck": "color_printing",
|
||||||
|
"modell": "model", "bearbeiter": "employee", "spender": "donor", "kommentar": "comment",
|
||||||
|
}
|
||||||
|
EXPORT_COLUMNS = [
|
||||||
|
("number", "Nr."), ("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"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"), ("hdd_gb", "HDD"),
|
||||||
|
("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"), ("resolution", "Auflösung"),
|
||||||
|
("print_technology", "Druckverfahren"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"),
|
||||||
|
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"),
|
||||||
|
("comment", "Kommentar"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_header(value: Any) -> str:
|
||||||
|
return str(value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_workbook(file_obj) -> tuple[list[str], list[list[str]]]:
|
||||||
|
workbook = load_workbook(file_obj, read_only=True, data_only=True)
|
||||||
|
sheet = workbook.active
|
||||||
|
rows = sheet.iter_rows(values_only=True)
|
||||||
|
headers = [str(value or "").strip() for value in next(rows, [])]
|
||||||
|
preview = [[format_cell(value) for value in row[: len(headers)]] for _, row in zip(range(10), rows)]
|
||||||
|
if not headers or not any(headers):
|
||||||
|
raise ValueError("Die Datei enthält keine Kopfzeile.")
|
||||||
|
return headers, preview
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_mapping(headers: list[str]) -> dict[int, str]:
|
||||||
|
return {index: COLUMN_MAP.get(normalize_header(header), "") for index, header in enumerate(headers)}
|
||||||
|
|
||||||
|
|
||||||
|
def format_cell(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (date, datetime)):
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value: Any) -> date:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Ungültiges Datum: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def decimal_value(value: Any) -> Decimal | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = Decimal(str(value).replace("€", "").replace(" ", "").replace(",", "."))
|
||||||
|
if not parsed.is_finite():
|
||||||
|
raise InvalidOperation
|
||||||
|
return parsed
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValueError(f"Ungültige Zahl: {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def integer_value(value: Any) -> int | None:
|
||||||
|
decimal = decimal_value(value)
|
||||||
|
if decimal is not None and decimal != decimal.to_integral_value():
|
||||||
|
raise ValueError(f"Eine ganze Zahl ist erforderlich: {value}")
|
||||||
|
return int(decimal) if decimal is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def tri_state(value: Any) -> str:
|
||||||
|
normalized = str(value or "").strip().upper()
|
||||||
|
return {"JA": Device.TriState.YES, "YES": Device.TriState.YES, "NEIN": Device.TriState.NO, "NO": Device.TriState.NO}.get(normalized, Device.TriState.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_choice(kind: str, value: Any, *, create: bool = True) -> ChoiceValue | None:
|
||||||
|
label = str(value or "").strip()
|
||||||
|
if not label:
|
||||||
|
return None
|
||||||
|
obj = ChoiceValue.objects.filter(kind=kind, label__iexact=label).first()
|
||||||
|
if obj:
|
||||||
|
return obj
|
||||||
|
if not create:
|
||||||
|
raise ValueError(f"Unbekannter Auswahlwert: {label}")
|
||||||
|
return ChoiceValue.objects.create(kind=kind, label=label, active=True)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]:
|
||||||
|
workbook = load_workbook(file_obj, read_only=True, data_only=True)
|
||||||
|
sheet = workbook.active
|
||||||
|
next(sheet.iter_rows(values_only=True), None)
|
||||||
|
created = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
maximum = 0
|
||||||
|
for row_number, row in enumerate(sheet.iter_rows(values_only=True, min_row=2), start=2):
|
||||||
|
if not any(value not in (None, "") for value in row):
|
||||||
|
continue
|
||||||
|
data = {field: row[index] if index < len(row) else None for index, field in mapping.items() if field}
|
||||||
|
try:
|
||||||
|
number = integer_value(data.get("number"))
|
||||||
|
if number and Device.objects.filter(number=number).exists():
|
||||||
|
raise ValueError(f"Gerätenummer {number} existiert bereits")
|
||||||
|
status = resolve_choice(ChoiceValue.Kind.STATUS, data.get("status") or "Eingang")
|
||||||
|
employee = None
|
||||||
|
employee_code = str(data.get("employee") or "").strip()
|
||||||
|
if employee_code:
|
||||||
|
employee = Employee.objects.filter(code__iexact=employee_code).first()
|
||||||
|
if not employee:
|
||||||
|
raise ValueError(f"Unbekanntes Mitarbeiterkürzel: {employee_code}")
|
||||||
|
device = Device(
|
||||||
|
number=number or 0, shelf=str(data.get("shelf") or "").strip(), status=status,
|
||||||
|
date=parse_date(data["date"]) if data.get("date") else date.today(),
|
||||||
|
recipient=resolve_choice(ChoiceValue.Kind.RECIPIENT, data.get("recipient")),
|
||||||
|
location=resolve_choice(ChoiceValue.Kind.LOCATION, data.get("location")),
|
||||||
|
ticket=str(data.get("ticket") or "").strip(),
|
||||||
|
device_type=resolve_choice(ChoiceValue.Kind.DEVICE_TYPE, data.get("device_type")),
|
||||||
|
price=decimal_value(data.get("price")), battery=tri_state(data.get("battery")),
|
||||||
|
wifi=tri_state(data.get("wifi")), lan=tri_state(data.get("lan")),
|
||||||
|
display_inches=decimal_value(data.get("display_inches")), ram_gb=integer_value(data.get("ram_gb")),
|
||||||
|
ssd_gb=integer_value(data.get("ssd_gb")), hdd_gb=integer_value(data.get("hdd_gb")),
|
||||||
|
cpu=str(data.get("cpu") or "").strip(), hwid=str(data.get("hwid") or "").strip(),
|
||||||
|
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")),
|
||||||
|
model=str(data.get("model") or "").strip(),
|
||||||
|
employee=employee, donor=str(data.get("donor") or "").strip(), comment=str(data.get("comment") or "").strip(),
|
||||||
|
)
|
||||||
|
clear_inapplicable(device)
|
||||||
|
device.full_clean(exclude=["number", "custom_values"])
|
||||||
|
device.save()
|
||||||
|
maximum = max(maximum, device.number)
|
||||||
|
add_audit(user, device, AuditLog.Action.IMPORT, new="Excel-Import")
|
||||||
|
created += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"Zeile {row_number}: {exc}")
|
||||||
|
if errors:
|
||||||
|
raise ValueError("\n".join(errors[:50]))
|
||||||
|
NumberSequence.ensure_at_least(maximum)
|
||||||
|
return {"created": created, "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
def export_value(device: Device, field: str) -> Any:
|
||||||
|
value = getattr(device, field)
|
||||||
|
if field in {"battery", "wifi", "lan", "duplex", "color_printing"}:
|
||||||
|
return dict(Device.TriState.choices).get(value, value)
|
||||||
|
if hasattr(value, "label"):
|
||||||
|
return value.label
|
||||||
|
if hasattr(value, "code"):
|
||||||
|
return value.code
|
||||||
|
return value if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def csv_safe_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, str) and (value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n"))):
|
||||||
|
return "'" + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def xlsx_safe_value(sheet, value: Any):
|
||||||
|
if isinstance(value, str):
|
||||||
|
cell = WriteOnlyCell(sheet, value=value)
|
||||||
|
cell.data_type = "s"
|
||||||
|
return cell
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def csv_response(devices: Iterable[Device]) -> HttpResponse:
|
||||||
|
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="geraete.csv"'
|
||||||
|
response.write("\ufeff")
|
||||||
|
writer = csv.writer(response, delimiter=";")
|
||||||
|
writer.writerow([label for _, label in EXPORT_COLUMNS])
|
||||||
|
for device in devices.iterator(chunk_size=1000):
|
||||||
|
writer.writerow([csv_safe_value(export_value(device, field)) for field, _ in EXPORT_COLUMNS])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def xlsx_response(devices: Iterable[Device]) -> HttpResponse:
|
||||||
|
workbook = Workbook(write_only=True)
|
||||||
|
sheet = workbook.create_sheet("Geräte")
|
||||||
|
sheet.append([label for _, label in EXPORT_COLUMNS])
|
||||||
|
for device in devices.iterator(chunk_size=1000):
|
||||||
|
sheet.append([xlsx_safe_value(sheet, export_value(device, field)) for field, _ in EXPORT_COLUMNS])
|
||||||
|
output = BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
response = HttpResponse(output.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="geraete.xlsx"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _ods_cell(value: Any, *, header: bool = False) -> str:
|
||||||
|
style = ' table:style-name="Kopfzeile"' if header else ""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
value = value.date()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return f'<table:table-cell{style} office:value-type="date" office:date-value="{value.isoformat()}"><text:p>{value.strftime("%d.%m.%Y")}</text:p></table:table-cell>'
|
||||||
|
if isinstance(value, (int, float, Decimal)) and not isinstance(value, bool):
|
||||||
|
number = str(value)
|
||||||
|
return f'<table:table-cell{style} office:value-type="float" office:value="{number}"><text:p>{escape(number)}</text:p></table:table-cell>'
|
||||||
|
text = escape(str(value if value is not None else ""))
|
||||||
|
return f'<table:table-cell{style} office:value-type="string"><text:p>{text}</text:p></table:table-cell>'
|
||||||
|
|
||||||
|
|
||||||
|
def ods_response(devices: Iterable[Device]) -> HttpResponse:
|
||||||
|
content = StringIO()
|
||||||
|
content.write('<?xml version="1.0" encoding="UTF-8"?>')
|
||||||
|
content.write('<office:document-content office:version="1.2" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">')
|
||||||
|
content.write('<office:automatic-styles><style:style style:name="Kopfzeile" style:family="table-cell"><style:text-properties fo:font-weight="bold"/></style:style></office:automatic-styles>')
|
||||||
|
content.write('<office:body><office:spreadsheet><table:table table:name="Geräte">')
|
||||||
|
content.write('<table:table-row>')
|
||||||
|
for _, label in EXPORT_COLUMNS:
|
||||||
|
content.write(_ods_cell(label, header=True))
|
||||||
|
content.write('</table:table-row>')
|
||||||
|
for device in devices.iterator(chunk_size=1000):
|
||||||
|
content.write('<table:table-row>')
|
||||||
|
for field, _ in EXPORT_COLUMNS:
|
||||||
|
content.write(_ods_cell(export_value(device, field)))
|
||||||
|
content.write('</table:table-row>')
|
||||||
|
content.write('</table:table></office:spreadsheet></office:body></office:document-content>')
|
||||||
|
|
||||||
|
mime_type = "application/vnd.oasis.opendocument.spreadsheet"
|
||||||
|
manifest = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<manifest:manifest manifest:version="1.2" xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
||||||
|
<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="{mime_type}"/>
|
||||||
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||||
|
</manifest:manifest>'''
|
||||||
|
output = BytesIO()
|
||||||
|
with zipfile.ZipFile(output, "w") as archive:
|
||||||
|
archive.writestr("mimetype", mime_type, compress_type=zipfile.ZIP_STORED)
|
||||||
|
archive.writestr("content.xml", content.getvalue(), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
archive.writestr("META-INF/manifest.xml", manifest, compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
response = HttpResponse(output.getvalue(), content_type=mime_type)
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="geraete.ods"'
|
||||||
|
return response
|
||||||
1
inventory/management/__init__.py
Normal file
1
inventory/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
inventory/management/commands/__init__.py
Normal file
1
inventory/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
46
inventory/management/commands/create_api_key.py
Normal file
46
inventory/management/commands/create_api_key.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from inventory.models import InventoryAPIKey, UserProfile
|
||||||
|
from inventory.permissions import can_edit
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Erstellt einen zeitlich begrenzten API-Schlüssel und zeigt ihn genau einmal an."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("name", help="Eindeutiger Zweck, z. B. inventur-laptop-team2")
|
||||||
|
parser.add_argument("username", help="Aktives Django-Konto für die Audit-Zuordnung")
|
||||||
|
parser.add_argument("--write", action="store_true", help="Schreibzugriff erlauben")
|
||||||
|
parser.add_argument("--read-only", action="store_true", help="Nur Lesezugriff (Standard)")
|
||||||
|
parser.add_argument("--expires-days", type=int, default=90, help="Gültigkeit in Tagen (Standard: 90)")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options["write"] and options["read_only"]:
|
||||||
|
raise CommandError("--write und --read-only können nicht gemeinsam verwendet werden.")
|
||||||
|
if options["expires_days"] < 1 or options["expires_days"] > 365:
|
||||||
|
raise CommandError("Die Gültigkeit muss zwischen 1 und 365 Tagen liegen.")
|
||||||
|
user_model = get_user_model()
|
||||||
|
try:
|
||||||
|
user = user_model.objects.get(username=options["username"], is_active=True)
|
||||||
|
except user_model.DoesNotExist as exc:
|
||||||
|
raise CommandError("Das angegebene aktive Konto wurde nicht gefunden.") from exc
|
||||||
|
if options["write"] and not can_edit(user):
|
||||||
|
raise CommandError(
|
||||||
|
f"Schreibzugriff erfordert die Rolle {UserProfile.Role.EDITOR} oder {UserProfile.Role.ADMIN}."
|
||||||
|
)
|
||||||
|
api_key, secret = InventoryAPIKey.objects.create_key(
|
||||||
|
name=options["name"],
|
||||||
|
service_user=user,
|
||||||
|
can_read=True,
|
||||||
|
can_write=options["write"],
|
||||||
|
expiry_date=timezone.now() + timedelta(days=options["expires_days"]),
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"API-Schlüssel '{api_key.name}' wurde erstellt."))
|
||||||
|
self.stdout.write(f"Präfix: {api_key.prefix}")
|
||||||
|
self.stdout.write(f"Läuft ab: {api_key.expiry_date.isoformat()}")
|
||||||
|
self.stdout.write(self.style.WARNING("Nur jetzt sichtbar – sicher speichern und niemals in Git einchecken:"))
|
||||||
|
self.stdout.write(secret)
|
||||||
53
inventory/management/commands/generate_test_devices.py
Normal file
53
inventory/management/commands/generate_test_devices.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from inventory.models import ChoiceValue, Device, Employee, NumberSequence
|
||||||
|
from inventory.device_types import bulk_create_specifications, clear_inapplicable
|
||||||
|
|
||||||
|
|
||||||
|
MODELS = ["Lenovo ThinkPad T480", "Dell Latitude 7490", "HP EliteBook 840 G5", "Lenovo ThinkCentre M720", "Dell OptiPlex 7060", "HP ProDesk 600 G4", "Lenovo ThinkPad P50"]
|
||||||
|
CPUS = ["Intel Core i5-8250U", "Intel Core i5-8350U", "Intel Core i7-8650U", "Intel Core i5-6500", "Intel Core i7-6700", "AMD Ryzen 5 3500U"]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Erzeugt realistische Testgeräte, z. B. generate_test_devices 5000."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("count", type=int)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
count = options["count"]
|
||||||
|
if count < 1 or count > 100000:
|
||||||
|
raise CommandError("Anzahl muss zwischen 1 und 100000 liegen.")
|
||||||
|
statuses = list(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True))
|
||||||
|
types = list(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True))
|
||||||
|
employees = list(Employee.objects.filter(active=True))
|
||||||
|
if not statuses:
|
||||||
|
raise CommandError("Bitte zuerst seed_initial_data ausführen.")
|
||||||
|
start = (Device.objects.aggregate(value=Max("number"))["value"] or 0) + 1
|
||||||
|
today = timezone.localdate()
|
||||||
|
devices = []
|
||||||
|
for offset in range(count):
|
||||||
|
model = random.choice(MODELS)
|
||||||
|
device = Device(
|
||||||
|
number=start + offset, shelf=f"{random.choice('ABCDEFGH')}{random.randint(1, 30)}",
|
||||||
|
status=random.choice(statuses), device_type=random.choice(types) if types else None,
|
||||||
|
date=today - timedelta(days=random.randint(0, 1500)), model=model, cpu=random.choice(CPUS),
|
||||||
|
ram_gb=random.choice([4, 8, 8, 16, 32]), ssd_gb=random.choice([0, 128, 256, 512, 1000]),
|
||||||
|
hdd_gb=random.choice([0, 0, 500, 1000, 2000]), employee=random.choice(employees) if employees else None,
|
||||||
|
wifi=random.choice(Device.TriState.values), lan=random.choice(Device.TriState.values),
|
||||||
|
)
|
||||||
|
clear_inapplicable(device)
|
||||||
|
device.custom_values = {}
|
||||||
|
devices.append(device)
|
||||||
|
for index in range(0, count, 1000):
|
||||||
|
saved = Device.objects.bulk_create(devices[index:index + 1000], batch_size=1000)
|
||||||
|
bulk_create_specifications(saved)
|
||||||
|
NumberSequence.ensure_at_least(start + count - 1)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{count} Testgeräte angelegt."))
|
||||||
51
inventory/management/commands/import_sqlite_export.py
Normal file
51
inventory/management/commands/import_sqlite_export.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.core.management import BaseCommand, CommandError, call_command
|
||||||
|
from django.core.serializers.base import DeserializationError
|
||||||
|
from django.db import DatabaseError, connection, transaction
|
||||||
|
|
||||||
|
from inventory.models import ChoiceValue, Device, NumberSequence
|
||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import a SQLite export into an empty, migrated PostgreSQL database."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("fixture", type=Path)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if connection.vendor != "postgresql":
|
||||||
|
raise CommandError("The destination must be PostgreSQL.")
|
||||||
|
fixture = options["fixture"].resolve()
|
||||||
|
if not fixture.is_file():
|
||||||
|
raise CommandError("Export file does not exist.")
|
||||||
|
count = self.load_export(fixture, verbosity=options["verbosity"])
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Imported {count} devices. SQLite source retained."))
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def load_export(self, fixture, *, verbosity=1):
|
||||||
|
# Migration-created content types and permissions are expected. Business
|
||||||
|
# data, users and groups must be empty: never merge or overwrite records.
|
||||||
|
models = [*apps.get_app_config("inventory").get_models(),
|
||||||
|
apps.get_model("auth", "User"), apps.get_model("auth", "Group")]
|
||||||
|
for model in models:
|
||||||
|
if model is ChoiceValue:
|
||||||
|
# Migration 0003 inserts these two rows even into a fresh DB.
|
||||||
|
baseline = {("device_type", "Bildschirm", True, 3, "", None),
|
||||||
|
("device_type", "Drucker", True, 4, "", None)}
|
||||||
|
rows = set(model.objects.values_list("kind", "label", "active", "sort_order", "color", "numeric_value"))
|
||||||
|
if rows <= baseline:
|
||||||
|
continue
|
||||||
|
if model.objects.exists():
|
||||||
|
raise CommandError(f"Destination is not empty ({model._meta.label}). No data imported.")
|
||||||
|
# Only the verified migration defaults can reach this point. Remove them
|
||||||
|
# within the import transaction so fixture IDs and labels cannot collide.
|
||||||
|
ChoiceValue.objects.all().delete()
|
||||||
|
try:
|
||||||
|
call_command("loaddata", str(fixture), verbosity=verbosity)
|
||||||
|
except (DeserializationError, DatabaseError) as exc:
|
||||||
|
raise CommandError("Invalid export or database constraint violation. No data imported.") from exc
|
||||||
|
NumberSequence.ensure_at_least(Device.objects.aggregate(value=Max("number"))["value"] or 0)
|
||||||
|
return Device.objects.count()
|
||||||
55
inventory/management/commands/seed_initial_data.py
Normal file
55
inventory/management/commands/seed_initial_data.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from inventory.models import ChoiceValue, Employee, NumberSequence
|
||||||
|
|
||||||
|
|
||||||
|
CHOICES = {
|
||||||
|
ChoiceValue.Kind.STATUS: ["Eingang", "Entsorgt", "Fertig", "Defekt", "Reserviert", "Ausgegeben", "Intern"],
|
||||||
|
ChoiceValue.Kind.RECIPIENT: ["Landkreis", "Jugendamt", "Bürgergeld", "Rentner", "Geflüchtete", "NGO", "Stadt", "Sonstige", "Verein", "Schüler/Studenten"],
|
||||||
|
ChoiceValue.Kind.LOCATION: ["R", "L"],
|
||||||
|
ChoiceValue.Kind.DEVICE_TYPE: ["Laptop", "AiO", "Desktop", "Bildschirm", "Drucker"],
|
||||||
|
}
|
||||||
|
NUMERIC_CHOICES = {
|
||||||
|
ChoiceValue.Kind.RAM: [2, 4, 6, 8, 12, 16, 24, 32, 64, 128],
|
||||||
|
ChoiceValue.Kind.SSD: [0, 64, 120, 128, 240, 250, 256, 480, 500, 512, 960, 1000, 2000, 4000],
|
||||||
|
ChoiceValue.Kind.HDD: [0, 64, 120, 128, 240, 250, 256, 480, 500, 512, 960, 1000, 2000, 4000],
|
||||||
|
ChoiceValue.Kind.DISPLAY: [10.1, 11.6, 12.5, 13.3, 14, 15, 15.6, 16, 17, 17.3],
|
||||||
|
}
|
||||||
|
EMPLOYEES = {
|
||||||
|
"AC": "Ahmad Chaja", "A_H": "Ahmad Hakim", "HA": "Aaron Holmer", "JH": "Johannes Hundshammer",
|
||||||
|
"TS": "Thomas Stemberger", "YN": "Yurii Naumenko", "VL": "Viktor Lysenko", "SB": "Stefan Bauer",
|
||||||
|
"KA": "Kanani", "RS": "Ruth Stepper", "SK": "Name aktuell unbekannt", "RG": "Rainer Gerhardt",
|
||||||
|
"HN": "Heinrich", "KK": "Karsten Kopplin", "LL": "Lucas Lenninger", "AL": "Arseni Litvin", "GW": "Gregor Witczak",
|
||||||
|
}
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"Eingang": "#2563eb", "Entsorgt": "#64748b", "Fertig": "#15803d",
|
||||||
|
"Defekt": "#dc2626", "Reserviert": "#b45309", "Ausgegeben": "#7c3aed", "Intern": "#0891b2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Legt die initialen Auswahlwerte und Mitarbeiter idempotent an."
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
count = 0
|
||||||
|
for kind, labels in CHOICES.items():
|
||||||
|
for order, label in enumerate(labels):
|
||||||
|
defaults = {"active": True, "sort_order": order}
|
||||||
|
if kind == ChoiceValue.Kind.STATUS:
|
||||||
|
defaults["color"] = STATUS_COLORS.get(label, "#2563eb")
|
||||||
|
_, created = ChoiceValue.objects.get_or_create(kind=kind, label=label, defaults=defaults)
|
||||||
|
count += int(created)
|
||||||
|
for kind, values in NUMERIC_CHOICES.items():
|
||||||
|
for order, value in enumerate(values):
|
||||||
|
label = f"{value:g}"
|
||||||
|
_, created = ChoiceValue.objects.get_or_create(kind=kind, label=label, defaults={"numeric_value": Decimal(str(value)), "active": True, "sort_order": order})
|
||||||
|
count += int(created)
|
||||||
|
for code, name in EMPLOYEES.items():
|
||||||
|
_, created = Employee.objects.get_or_create(code=code, defaults={"name": name, "active": True})
|
||||||
|
count += int(created)
|
||||||
|
NumberSequence.objects.get_or_create(name="device")
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Seed-Daten bereit ({count} neue Datensätze)."))
|
||||||
233
inventory/migrations/0001_initial.py
Normal file
233
inventory/migrations/0001_initial.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-19 12:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Employee',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('code', models.CharField(max_length=12, unique=True)),
|
||||||
|
('name', models.CharField(max_length=160)),
|
||||||
|
('active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['code'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FieldDefinition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(max_length=120)),
|
||||||
|
('key', models.SlugField(max_length=80, unique=True)),
|
||||||
|
('field_type', models.CharField(choices=[('text', 'Text'), ('long_text', 'Langer Text'), ('integer', 'Ganzzahl'), ('decimal', 'Dezimalzahl'), ('boolean', 'Ja/Nein'), ('date', 'Datum'), ('select', 'Auswahl'), ('multi_select', 'Mehrfachauswahl')], max_length=20)),
|
||||||
|
('unit', models.CharField(blank=True, max_length=30)),
|
||||||
|
('required', models.BooleanField(default=False)),
|
||||||
|
('active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
('show_in_list', models.BooleanField(default=False)),
|
||||||
|
('filterable', models.BooleanField(default=False)),
|
||||||
|
('searchable', models.BooleanField(default=False)),
|
||||||
|
('sort_order', models.PositiveIntegerField(default=0)),
|
||||||
|
('group', models.CharField(default='Zusatzfelder', max_length=80)),
|
||||||
|
('allow_custom_values', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['sort_order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NumberSequence',
|
||||||
|
fields=[
|
||||||
|
('name', models.CharField(max_length=30, primary_key=True, serialize=False)),
|
||||||
|
('last_value', models.PositiveIntegerField(default=0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ChoiceValue',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('kind', models.CharField(choices=[('status', 'Status'), ('recipient', 'Empfänger'), ('location', 'Ort'), ('device_type', 'Typ'), ('ram', 'RAM-Vorschlag'), ('ssd', 'SSD-Vorschlag'), ('hdd', 'HDD-Vorschlag'), ('display', 'Displaygröße')], db_index=True, max_length=30)),
|
||||||
|
('label', models.CharField(max_length=120)),
|
||||||
|
('numeric_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
('sort_order', models.PositiveIntegerField(default=0)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['kind', 'sort_order', 'label'],
|
||||||
|
'indexes': [models.Index(fields=['kind', 'active', 'sort_order'], name='inventory_c_kind_320c2f_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('kind', 'label'), name='unique_choice_label_per_kind')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Device',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('number', models.PositiveIntegerField(db_index=True, editable=False, unique=True)),
|
||||||
|
('shelf', models.CharField(blank=True, db_index=True, max_length=80)),
|
||||||
|
('date', models.DateField(db_index=True, default=django.utils.timezone.localdate)),
|
||||||
|
('ticket', models.CharField(blank=True, db_index=True, max_length=120)),
|
||||||
|
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('battery', models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10)),
|
||||||
|
('wifi', models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10)),
|
||||||
|
('lan', models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10)),
|
||||||
|
('display_inches', models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True)),
|
||||||
|
('ram_gb', models.PositiveIntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('ssd_gb', models.PositiveIntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('hdd_gb', models.PositiveIntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('cpu', models.CharField(blank=True, db_index=True, max_length=200)),
|
||||||
|
('model', models.CharField(blank=True, db_index=True, max_length=200)),
|
||||||
|
('donor', models.CharField(blank=True, max_length=200)),
|
||||||
|
('comment', models.TextField(blank=True)),
|
||||||
|
('custom_values', models.JSONField(blank=True, default=dict)),
|
||||||
|
('archived_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||||
|
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_devices', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('device_type', models.ForeignKey(blank=True, limit_choices_to={'kind': 'device_type'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='type_devices', to='inventory.choicevalue')),
|
||||||
|
('location', models.ForeignKey(blank=True, limit_choices_to={'kind': 'location'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='location_devices', to='inventory.choicevalue')),
|
||||||
|
('recipient', models.ForeignKey(blank=True, limit_choices_to={'kind': 'recipient'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='recipient_devices', to='inventory.choicevalue')),
|
||||||
|
('status', models.ForeignKey(limit_choices_to={'kind': 'status'}, on_delete=django.db.models.deletion.PROTECT, related_name='status_devices', to='inventory.choicevalue')),
|
||||||
|
('employee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='inventory.employee')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-number'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FieldOption',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('label', models.CharField(max_length=120)),
|
||||||
|
('active', models.BooleanField(default=True)),
|
||||||
|
('sort_order', models.PositiveIntegerField(default=0)),
|
||||||
|
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='inventory.fielddefinition')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['sort_order', 'label'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ImportJob',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('file', models.FileField(upload_to='imports/%Y/%m/')),
|
||||||
|
('original_name', models.CharField(max_length=255)),
|
||||||
|
('status', models.CharField(choices=[('UPLOADED', 'Hochgeladen'), ('COMPLETED', 'Abgeschlossen'), ('FAILED', 'Fehlgeschlagen')], default='UPLOADED', max_length=12)),
|
||||||
|
('headers', models.JSONField(default=list)),
|
||||||
|
('preview_rows', models.JSONField(default=list)),
|
||||||
|
('result', models.JSONField(default=dict)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_jobs', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SavedView',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(max_length=120)),
|
||||||
|
('filters', models.JSONField(default=dict)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_views', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserPreference',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('visible_columns', models.JSONField(blank=True, default=list)),
|
||||||
|
('column_order', models.JSONField(blank=True, default=list)),
|
||||||
|
('column_widths', models.JSONField(blank=True, default=dict)),
|
||||||
|
('page_size', models.PositiveSmallIntegerField(default=50)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_preferences', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('role', models.CharField(choices=[('ADMIN', 'Administrator'), ('EDITOR', 'Bearbeiter'), ('VIEWER', 'Betrachter')], default='VIEWER', max_length=10)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AuditLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('action', models.CharField(choices=[('CREATE', 'Erstellt'), ('UPDATE', 'Geändert'), ('ARCHIVE', 'Archiviert'), ('RESTORE', 'Wiederhergestellt'), ('IMPORT', 'Importiert')], max_length=10)),
|
||||||
|
('field', models.CharField(blank=True, max_length=100)),
|
||||||
|
('old_value', models.TextField(blank=True)),
|
||||||
|
('new_value', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_entries', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_entries', to='inventory.device')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at', '-id'],
|
||||||
|
'indexes': [models.Index(fields=['device', '-created_at'], name='inventory_a_device__78ae29_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='device',
|
||||||
|
index=models.Index(fields=['archived_at', '-number'], name='inventory_d_archive_2483fe_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='device',
|
||||||
|
index=models.Index(fields=['status', 'archived_at'], name='inventory_d_status__f41a2e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='device',
|
||||||
|
index=models.Index(fields=['device_type', 'archived_at'], name='inventory_d_device__ec71d5_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='device',
|
||||||
|
index=models.Index(fields=['employee', 'archived_at'], name='inventory_d_employe_e23a79_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='device',
|
||||||
|
index=models.Index(fields=['recipient', 'archived_at'], name='inventory_d_recipie_5290ea_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='fieldoption',
|
||||||
|
constraint=models.UniqueConstraint(fields=('field', 'label'), name='unique_custom_field_option'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='savedview',
|
||||||
|
constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_saved_view_name'),
|
||||||
|
),
|
||||||
|
]
|
||||||
30
inventory/migrations/0002_postgres_search_indexes.py
Normal file
30
inventory/migrations/0002_postgres_search_indexes.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def add_postgres_indexes(apps, schema_editor):
|
||||||
|
if schema_editor.connection.vendor != "postgresql":
|
||||||
|
return
|
||||||
|
statements = [
|
||||||
|
"CREATE EXTENSION IF NOT EXISTS pg_trgm",
|
||||||
|
"CREATE INDEX IF NOT EXISTS inventory_device_cpu_trgm ON inventory_device USING gin (cpu gin_trgm_ops)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS inventory_device_model_trgm ON inventory_device USING gin (model gin_trgm_ops)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS inventory_device_ticket_trgm ON inventory_device USING gin (ticket gin_trgm_ops)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS inventory_device_donor_trgm ON inventory_device USING gin (donor gin_trgm_ops)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS inventory_device_custom_values_gin ON inventory_device USING gin (custom_values jsonb_path_ops)",
|
||||||
|
]
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
for statement in statements:
|
||||||
|
cursor.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_postgres_indexes(apps, schema_editor):
|
||||||
|
if schema_editor.connection.vendor != "postgresql":
|
||||||
|
return
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
for name in ["inventory_device_cpu_trgm", "inventory_device_model_trgm", "inventory_device_ticket_trgm", "inventory_device_donor_trgm", "inventory_device_custom_values_gin"]:
|
||||||
|
cursor.execute(f"DROP INDEX IF EXISTS {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("inventory", "0001_initial")]
|
||||||
|
operations = [migrations.RunPython(add_postgres_indexes, remove_postgres_indexes)]
|
||||||
51
inventory/migrations/0003_audit_revert_and_device_types.py
Normal file
51
inventory/migrations/0003_audit_revert_and_device_types.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def add_device_types(apps, schema_editor):
|
||||||
|
ChoiceValue = apps.get_model("inventory", "ChoiceValue")
|
||||||
|
for order, label in enumerate(("Bildschirm", "Drucker"), start=3):
|
||||||
|
ChoiceValue.objects.update_or_create(
|
||||||
|
kind="device_type", label=label,
|
||||||
|
defaults={"active": True, "sort_order": order},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_device_types(apps, schema_editor):
|
||||||
|
ChoiceValue = apps.get_model("inventory", "ChoiceValue")
|
||||||
|
ChoiceValue.objects.filter(kind="device_type", label__in=("Bildschirm", "Drucker")).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("inventory", "0002_postgres_search_indexes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditlog", name="field_key",
|
||||||
|
field=models.CharField(blank=True, max_length=120),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditlog", name="old_data",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditlog", name="new_data",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditlog", name="reverted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="auditlog", name="reverted_by",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="reverted_audit_entries", to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(add_device_types, remove_device_types),
|
||||||
|
]
|
||||||
24
inventory/migrations/0004_choicevalue_color.py
Normal file
24
inventory/migrations/0004_choicevalue_color.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"Eingang": "#2563eb", "Entsorgt": "#64748b", "Fertig": "#15803d",
|
||||||
|
"Defekt": "#dc2626", "Reserviert": "#b45309", "Ausgegeben": "#7c3aed", "Intern": "#0891b2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_status_colors(apps, schema_editor):
|
||||||
|
ChoiceValue = apps.get_model("inventory", "ChoiceValue")
|
||||||
|
for label, color in STATUS_COLORS.items():
|
||||||
|
ChoiceValue.objects.filter(kind="status", label=label).update(color=color)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("inventory", "0003_audit_revert_and_device_types")]
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="choicevalue", name="color",
|
||||||
|
field=models.CharField(blank=True, max_length=20),
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_status_colors, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-21 13:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0004_choicevalue_color'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='auditlog',
|
||||||
|
name='api_client',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='hwid',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InventoryAPIKey',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('prefix', models.CharField(editable=False, max_length=8, unique=True)),
|
||||||
|
('hashed_key', models.CharField(editable=False, max_length=150)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)),
|
||||||
|
('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')),
|
||||||
|
('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')),
|
||||||
|
('can_read', models.BooleanField(default=True)),
|
||||||
|
('can_write', models.BooleanField(default=False)),
|
||||||
|
('last_used_at', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
('last_used_ip', models.GenericIPAddressField(blank=True, editable=False, null=True)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_inventory_api_keys', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('service_user', models.ForeignKey(help_text='Technisches oder persönliches Konto, dem API-Änderungen zugerechnet werden.', on_delete=django.db.models.deletion.PROTECT, related_name='inventory_api_keys', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'API-Schlüssel',
|
||||||
|
'verbose_name_plural': 'API-Schlüssel',
|
||||||
|
'ordering': ('-created',),
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
16
inventory/migrations/0006_device_revision.py
Normal file
16
inventory/migrations/0006_device_revision.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("inventory", "0005_auditlog_api_client_device_hwid_inventoryapikey")]
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="device", name="revision",
|
||||||
|
field=models.PositiveBigIntegerField(default=1, editable=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="device", name="updated_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
67
inventory/migrations/0007_workflow_tasks.py
Normal file
67
inventory/migrations/0007_workflow_tasks.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def initial_stages(apps, schema_editor):
|
||||||
|
Device = apps.get_model("inventory", "Device")
|
||||||
|
for device in Device.objects.select_related("status").iterator():
|
||||||
|
label = device.status.label.casefold()
|
||||||
|
if label == "ausgegeben":
|
||||||
|
stage = "ISSUED"
|
||||||
|
elif label in {"fertig", "reserviert"}:
|
||||||
|
stage = "STORAGE"
|
||||||
|
elif label == "defekt":
|
||||||
|
stage = "REPAIR"
|
||||||
|
else:
|
||||||
|
stage = "INTAKE"
|
||||||
|
Device.objects.filter(pk=device.pk).update(stage=stage)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("inventory", "0006_device_revision"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="device", name="stage",
|
||||||
|
field=models.CharField(max_length=12, choices=[
|
||||||
|
("INTAKE", "Eingang"), ("INSPECTION", "Inspektion"),
|
||||||
|
("REPAIR", "Reparatur"), ("STORAGE", "Lagerung"), ("ISSUED", "Ausgegeben"),
|
||||||
|
], default="INTAKE", db_index=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DeviceTask",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("state", models.CharField(max_length=8, choices=[("OPEN", "Offen"), ("DONE", "Erledigt")], default="OPEN", db_index=True)),
|
||||||
|
("completed_at", models.DateTimeField(null=True, blank=True)),
|
||||||
|
("due_date", models.DateField(null=True, blank=True)),
|
||||||
|
("assigned_to", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, related_name="assigned_device_tasks", null=True, blank=True)),
|
||||||
|
("completed_by", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, related_name="completed_device_tasks", null=True, blank=True)),
|
||||||
|
("created_by", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, related_name="created_device_tasks", null=True)),
|
||||||
|
("device", models.ForeignKey(to="inventory.device", on_delete=django.db.models.deletion.CASCADE, related_name="tasks")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["state", "-created_at"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="WorkflowEvent",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("from_stage", models.CharField(max_length=12, choices=[("INTAKE", "Eingang"), ("INSPECTION", "Inspektion"), ("REPAIR", "Reparatur"), ("STORAGE", "Lagerung"), ("ISSUED", "Ausgegeben")], blank=True)),
|
||||||
|
("to_stage", models.CharField(max_length=12, choices=[("INTAKE", "Eingang"), ("INSPECTION", "Inspektion"), ("REPAIR", "Reparatur"), ("STORAGE", "Lagerung"), ("ISSUED", "Ausgegeben")])),
|
||||||
|
("note", models.CharField(max_length=500, blank=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("device", models.ForeignKey(to="inventory.device", on_delete=django.db.models.deletion.CASCADE, related_name="workflow_events")),
|
||||||
|
("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, null=True)),
|
||||||
|
],
|
||||||
|
options={"ordering": ["-created_at", "-id"]},
|
||||||
|
),
|
||||||
|
migrations.RunPython(initial_stages, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
36
inventory/migrations/0008_normalize_hardware_by_type.py
Normal file
36
inventory/migrations/0008_normalize_hardware_by_type.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
COMPUTER = {"cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan"}
|
||||||
|
ALLOWED = {
|
||||||
|
"laptop": COMPUTER,
|
||||||
|
"aio": COMPUTER - {"battery"},
|
||||||
|
"desktop": COMPUTER - {"battery", "display_inches"},
|
||||||
|
"bildschirm": {"display_inches"},
|
||||||
|
"drucker": {"wifi", "lan"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(apps, schema_editor):
|
||||||
|
Device = apps.get_model("inventory", "Device")
|
||||||
|
for device in Device.objects.select_related("device_type").iterator():
|
||||||
|
if device.device_type is None:
|
||||||
|
continue
|
||||||
|
fields = COMPUTER - ALLOWED.get(device.device_type.label.strip().casefold(), COMPUTER)
|
||||||
|
if not fields:
|
||||||
|
continue
|
||||||
|
changes = {}
|
||||||
|
legacy = dict((device.custom_values or {}).get("legacy_hardware") or {})
|
||||||
|
for field in fields:
|
||||||
|
value = getattr(device, field)
|
||||||
|
if value not in (None, "", "UNKNOWN"):
|
||||||
|
legacy[field] = str(value)
|
||||||
|
changes[field] = None if Device._meta.get_field(field).null else "UNKNOWN" if field in {"battery", "wifi", "lan"} else ""
|
||||||
|
if legacy:
|
||||||
|
changes["custom_values"] = {**(device.custom_values or {}), "legacy_hardware": legacy}
|
||||||
|
Device.objects.filter(pk=device.pk).update(**changes)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("inventory", "0007_workflow_tasks")]
|
||||||
|
operations = [migrations.RunPython(normalize, migrations.RunPython.noop)]
|
||||||
237
inventory/migrations/0009_split_device_specifications.py
Normal file
237
inventory/migrations/0009_split_device_specifications.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-26 10:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def copy_to_specifications(apps, schema_editor):
|
||||||
|
Device = apps.get_model("inventory", "Device")
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
Monitor = apps.get_model("inventory", "MonitorSpecification")
|
||||||
|
Printer = apps.get_model("inventory", "PrinterSpecification")
|
||||||
|
for device in Device.objects.select_related("device_type").iterator(chunk_size=500):
|
||||||
|
label = device.device_type.label.strip().casefold() if device.device_type else ""
|
||||||
|
if label == "drucker":
|
||||||
|
Printer.objects.create(device_id=device.pk, wifi=device.wifi, lan=device.lan)
|
||||||
|
elif label == "bildschirm":
|
||||||
|
Monitor.objects.create(device_id=device.pk, display_inches=device.display_inches)
|
||||||
|
else:
|
||||||
|
Computer.objects.create(
|
||||||
|
device_id=device.pk, cpu=device.cpu, hwid=device.hwid,
|
||||||
|
ram_gb=device.ram_gb, ssd_gb=device.ssd_gb, hdd_gb=device.hdd_gb,
|
||||||
|
display_inches=device.display_inches, battery=device.battery,
|
||||||
|
wifi=device.wifi, lan=device.lan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_back_to_devices(apps, schema_editor):
|
||||||
|
Device = apps.get_model("inventory", "Device")
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
Monitor = apps.get_model("inventory", "MonitorSpecification")
|
||||||
|
Printer = apps.get_model("inventory", "PrinterSpecification")
|
||||||
|
for device in Device.objects.select_related("device_type").iterator(chunk_size=500):
|
||||||
|
label = device.device_type.label.strip().casefold() if device.device_type else ""
|
||||||
|
if label == "drucker":
|
||||||
|
spec = Printer.objects.filter(device_id=device.pk).first()
|
||||||
|
elif label == "bildschirm":
|
||||||
|
spec = Monitor.objects.filter(device_id=device.pk).first()
|
||||||
|
else:
|
||||||
|
spec = Computer.objects.filter(device_id=device.pk).first()
|
||||||
|
if spec:
|
||||||
|
Device.objects.filter(pk=device.pk).update(**{
|
||||||
|
name: getattr(spec, name) for name in (
|
||||||
|
"cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan",
|
||||||
|
) if hasattr(spec, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def computer_search_index(apps, schema_editor):
|
||||||
|
if schema_editor.connection.vendor == "postgresql":
|
||||||
|
schema_editor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS inventory_computer_cpu_trgm "
|
||||||
|
"ON inventory_computerspecification USING gin (cpu gin_trgm_ops)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_computer_search_index(apps, schema_editor):
|
||||||
|
if schema_editor.connection.vendor == "postgresql":
|
||||||
|
schema_editor.execute("DROP INDEX IF EXISTS inventory_computer_cpu_trgm")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("inventory", "0008_normalize_hardware_by_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ComputerSpecification",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="computer_specification",
|
||||||
|
serialize=False,
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("cpu", models.CharField(blank=True, db_index=True, max_length=200)),
|
||||||
|
("hwid", models.CharField(blank=True, db_index=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"ram_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hdd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_inches",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=1, max_digits=4, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"battery",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wifi",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lan",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MonitorSpecification",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="monitor_specification",
|
||||||
|
serialize=False,
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_inches",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=1, max_digits=4, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PrinterSpecification",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="printer_specification",
|
||||||
|
serialize=False,
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wifi",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lan",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(copy_to_specifications, copy_back_to_devices),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="battery",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="cpu",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="display_inches",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="hdd_gb",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="hwid",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="lan",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="ram_gb",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="ssd_gb",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="device",
|
||||||
|
name="wifi",
|
||||||
|
),
|
||||||
|
migrations.RunPython(computer_search_index, drop_computer_search_index),
|
||||||
|
]
|
||||||
63
inventory/migrations/0010_workflow_integrity.py
Normal file
63
inventory/migrations/0010_workflow_integrity.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-26 11:04
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("inventory", "0009_split_device_specifications"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="devicetask",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["state", "due_date"], name="inventory_d_state_bfd57d_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="devicetask",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["device", "state"], name="inventory_d_device__f5a9da_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="workflowevent",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["device", "-created_at"], name="inventory_w_device__f6b3a5_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="device",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(
|
||||||
|
(
|
||||||
|
"stage__in",
|
||||||
|
["INTAKE", "INSPECTION", "REPAIR", "STORAGE", "ISSUED"],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
name="valid_device_stage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="devicetask",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("state__in", ["OPEN", "DONE"])),
|
||||||
|
name="valid_device_task_state",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="devicetask",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(
|
||||||
|
models.Q(("completed_at__isnull", True), ("state", "OPEN")),
|
||||||
|
models.Q(("completed_at__isnull", False), ("state", "DONE")),
|
||||||
|
_connector="OR",
|
||||||
|
),
|
||||||
|
name="device_task_completion_matches_state",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
98
inventory/migrations/0011_inspectionrecord_repairrecord.py
Normal file
98
inventory/migrations/0011_inspectionrecord_repairrecord.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-26 12:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("inventory", "0010_workflow_integrity"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="InspectionRecord",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("defects", models.TextField()),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="inspections",
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recorded_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at", "-id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["device", "-created_at"],
|
||||||
|
name="inventory_i_device__4a3af8_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RepairRecord",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("work_done", models.TextField()),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="repairs",
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recorded_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at", "-id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["device", "-created_at"],
|
||||||
|
name="inventory_r_device__58bce5_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-28 19:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
TYPE_TABLES = {
|
||||||
|
"laptop": "LaptopSpecification",
|
||||||
|
"desktop": "DesktopSpecification",
|
||||||
|
"aio": "AllInOneSpecification",
|
||||||
|
"all-in-one": "AllInOneSpecification",
|
||||||
|
"all in one": "AllInOneSpecification",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def move_computer_rows(apps, schema_editor):
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
targets = {name: apps.get_model("inventory", name) for name in set(TYPE_TABLES.values())}
|
||||||
|
for source in Computer.objects.select_related("device__device_type").iterator(chunk_size=500):
|
||||||
|
label = source.device.device_type.label.strip().casefold() if source.device.device_type else ""
|
||||||
|
target_name = TYPE_TABLES.get(label)
|
||||||
|
if not target_name:
|
||||||
|
continue
|
||||||
|
target = targets[target_name]
|
||||||
|
fields = [field.name for field in target._meta.fields if field.name != "device"]
|
||||||
|
target.objects.create(device_id=source.device_id, **{name: getattr(source, name) for name in fields})
|
||||||
|
source.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def restore_computer_rows(apps, schema_editor):
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
for name in set(TYPE_TABLES.values()):
|
||||||
|
target = apps.get_model("inventory", name)
|
||||||
|
for source in target.objects.iterator(chunk_size=500):
|
||||||
|
fields = [field.name for field in Computer._meta.fields if field.name != "device"]
|
||||||
|
Computer.objects.create(device_id=source.device_id, **{
|
||||||
|
field: getattr(source, field) for field in fields if hasattr(source, field)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def create_cpu_indexes(apps, schema_editor):
|
||||||
|
if schema_editor.connection.vendor == "postgresql":
|
||||||
|
for table, name in (("inventory_laptopspecification", "inventory_laptop_cpu_trgm"),
|
||||||
|
("inventory_desktopspecification", "inventory_desktop_cpu_trgm"),
|
||||||
|
("inventory_allinonespecification", "inventory_aio_cpu_trgm")):
|
||||||
|
schema_editor.execute(f"CREATE INDEX {name} ON {table} USING gin (cpu gin_trgm_ops)")
|
||||||
|
|
||||||
|
|
||||||
|
def drop_cpu_indexes(apps, schema_editor):
|
||||||
|
if schema_editor.connection.vendor == "postgresql":
|
||||||
|
for name in ("inventory_laptop_cpu_trgm", "inventory_desktop_cpu_trgm", "inventory_aio_cpu_trgm"):
|
||||||
|
schema_editor.execute(f"DROP INDEX IF EXISTS {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("inventory", "0011_inspectionrecord_repairrecord"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AllInOneSpecification",
|
||||||
|
fields=[
|
||||||
|
("cpu", models.CharField(blank=True, db_index=True, max_length=200)),
|
||||||
|
("hwid", models.CharField(blank=True, db_index=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"ram_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hdd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wifi",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lan",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ports", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="aio_specification",
|
||||||
|
serialize=False,
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_inches",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=1, max_digits=4, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DesktopSpecification",
|
||||||
|
fields=[
|
||||||
|
("cpu", models.CharField(blank=True, db_index=True, max_length=200)),
|
||||||
|
("hwid", models.CharField(blank=True, db_index=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"ram_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hdd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wifi",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lan",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ports", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="desktop_specification",
|
||||||
|
serialize=False,
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LaptopSpecification",
|
||||||
|
fields=[
|
||||||
|
("cpu", models.CharField(blank=True, db_index=True, max_length=200)),
|
||||||
|
("hwid", models.CharField(blank=True, db_index=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"ram_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hdd_gb",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wifi",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lan",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ports", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="laptop_specification",
|
||||||
|
serialize=False,
|
||||||
|
to="inventory.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_inches",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=1, max_digits=4, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"battery",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("YES", "JA"),
|
||||||
|
("NO", "NEIN"),
|
||||||
|
("UNKNOWN", "UNBEKANNT"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="computerspecification",
|
||||||
|
name="ports",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="monitorspecification",
|
||||||
|
name="ports",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="monitorspecification",
|
||||||
|
name="resolution",
|
||||||
|
field=models.CharField(blank=True, max_length=80),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printerspecification",
|
||||||
|
name="color_printing",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("YES", "JA"), ("NO", "NEIN"), ("UNKNOWN", "UNBEKANNT")],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printerspecification",
|
||||||
|
name="duplex",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("YES", "JA"), ("NO", "NEIN"), ("UNKNOWN", "UNBEKANNT")],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printerspecification",
|
||||||
|
name="ports",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printerspecification",
|
||||||
|
name="print_technology",
|
||||||
|
field=models.CharField(blank=True, max_length=80),
|
||||||
|
),
|
||||||
|
migrations.RunPython(move_computer_rows, restore_computer_rows),
|
||||||
|
migrations.RunPython(create_cpu_indexes, drop_cpu_indexes),
|
||||||
|
]
|
||||||
0
inventory/migrations/__init__.py
Normal file
0
inventory/migrations/__init__.py
Normal file
564
inventory/models.py
Normal file
564
inventory/models.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models, transaction
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework_api_key.models import AbstractAPIKey, BaseAPIKeyManager
|
||||||
|
|
||||||
|
|
||||||
|
class TimeStampedModel(models.Model):
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(TimeStampedModel):
|
||||||
|
class Role(models.TextChoices):
|
||||||
|
ADMIN = "ADMIN", "Administrator"
|
||||||
|
EDITOR = "EDITOR", "Bearbeiter"
|
||||||
|
VIEWER = "VIEWER", "Betrachter"
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
|
role = models.CharField(max_length=10, choices=Role.choices, default=Role.VIEWER)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user.username} ({self.get_role_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceValue(TimeStampedModel):
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
STATUS = "status", "Status"
|
||||||
|
RECIPIENT = "recipient", "Empfänger"
|
||||||
|
LOCATION = "location", "Ort"
|
||||||
|
DEVICE_TYPE = "device_type", "Typ"
|
||||||
|
RAM = "ram", "RAM-Vorschlag"
|
||||||
|
SSD = "ssd", "SSD-Vorschlag"
|
||||||
|
HDD = "hdd", "HDD-Vorschlag"
|
||||||
|
DISPLAY = "display", "Displaygröße"
|
||||||
|
|
||||||
|
kind = models.CharField(max_length=30, choices=Kind.choices, db_index=True)
|
||||||
|
label = models.CharField(max_length=120)
|
||||||
|
color = models.CharField(max_length=20, blank=True)
|
||||||
|
numeric_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
active = models.BooleanField(default=True, db_index=True)
|
||||||
|
sort_order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["kind", "sort_order", "label"]
|
||||||
|
constraints = [models.UniqueConstraint(fields=["kind", "label"], name="unique_choice_label_per_kind")]
|
||||||
|
indexes = [models.Index(fields=["kind", "active", "sort_order"])]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.label
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(TimeStampedModel):
|
||||||
|
code = models.CharField(max_length=12, unique=True)
|
||||||
|
name = models.CharField(max_length=160)
|
||||||
|
active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["code"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.code} – {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDefinition(TimeStampedModel):
|
||||||
|
class FieldType(models.TextChoices):
|
||||||
|
TEXT = "text", "Text"
|
||||||
|
LONG_TEXT = "long_text", "Langer Text"
|
||||||
|
INTEGER = "integer", "Ganzzahl"
|
||||||
|
DECIMAL = "decimal", "Dezimalzahl"
|
||||||
|
BOOLEAN = "boolean", "Ja/Nein"
|
||||||
|
DATE = "date", "Datum"
|
||||||
|
SELECT = "select", "Auswahl"
|
||||||
|
MULTI_SELECT = "multi_select", "Mehrfachauswahl"
|
||||||
|
|
||||||
|
name = models.CharField(max_length=120)
|
||||||
|
key = models.SlugField(max_length=80, unique=True)
|
||||||
|
field_type = models.CharField(max_length=20, choices=FieldType.choices)
|
||||||
|
unit = models.CharField(max_length=30, blank=True)
|
||||||
|
required = models.BooleanField(default=False)
|
||||||
|
active = models.BooleanField(default=True, db_index=True)
|
||||||
|
show_in_list = models.BooleanField(default=False)
|
||||||
|
filterable = models.BooleanField(default=False)
|
||||||
|
searchable = models.BooleanField(default=False)
|
||||||
|
sort_order = models.PositiveIntegerField(default=0)
|
||||||
|
group = models.CharField(max_length=80, default="Zusatzfelder")
|
||||||
|
allow_custom_values = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["sort_order", "name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class FieldOption(TimeStampedModel):
|
||||||
|
field = models.ForeignKey(FieldDefinition, on_delete=models.CASCADE, related_name="options")
|
||||||
|
label = models.CharField(max_length=120)
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
sort_order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["sort_order", "label"]
|
||||||
|
constraints = [models.UniqueConstraint(fields=["field", "label"], name="unique_custom_field_option")]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.label
|
||||||
|
|
||||||
|
|
||||||
|
class NumberSequence(models.Model):
|
||||||
|
name = models.CharField(max_length=30, primary_key=True)
|
||||||
|
last_value = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def next_device_number(cls) -> int:
|
||||||
|
with transaction.atomic():
|
||||||
|
sequence, _ = cls.objects.select_for_update().get_or_create(name="device")
|
||||||
|
maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0
|
||||||
|
sequence.last_value = max(sequence.last_value, maximum) + 1
|
||||||
|
sequence.save(update_fields=["last_value"])
|
||||||
|
return sequence.last_value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def suggested_device_number(cls) -> int:
|
||||||
|
sequence_value = cls.objects.filter(name="device").values_list("last_value", flat=True).first() or 0
|
||||||
|
maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0
|
||||||
|
return max(sequence_value, maximum) + 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ensure_at_least(cls, value: int) -> None:
|
||||||
|
with transaction.atomic():
|
||||||
|
sequence, _ = cls.objects.select_for_update().get_or_create(name="device")
|
||||||
|
if sequence.last_value < value:
|
||||||
|
sequence.last_value = value
|
||||||
|
sequence.save(update_fields=["last_value"])
|
||||||
|
|
||||||
|
|
||||||
|
HARDWARE_DEFAULTS = {
|
||||||
|
"battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN",
|
||||||
|
"display_inches": None, "ram_gb": None, "ssd_gb": None, "hdd_gb": None,
|
||||||
|
"cpu": "", "hwid": "", "ports": "", "resolution": "",
|
||||||
|
"print_technology": "", "duplex": "UNKNOWN", "color_printing": "UNKNOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareValue:
|
||||||
|
"""Compatibility attribute backed by one type-specific specification row."""
|
||||||
|
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __get__(self, instance, owner):
|
||||||
|
if instance is None:
|
||||||
|
return self
|
||||||
|
instance._load_hardware()
|
||||||
|
return instance._hardware_cache[self.name]
|
||||||
|
|
||||||
|
def __set__(self, instance, value):
|
||||||
|
instance._load_hardware()
|
||||||
|
instance._hardware_cache[self.name] = value
|
||||||
|
instance._hardware_dirty = True
|
||||||
|
|
||||||
|
|
||||||
|
class Device(TimeStampedModel):
|
||||||
|
# A revision is independent of the resolution of the operating system clock.
|
||||||
|
revision = models.PositiveBigIntegerField(default=1, editable=False)
|
||||||
|
updated_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||||
|
|
||||||
|
class TriState(models.TextChoices):
|
||||||
|
YES = "YES", "JA"
|
||||||
|
NO = "NO", "NEIN"
|
||||||
|
UNKNOWN = "UNKNOWN", "UNBEKANNT"
|
||||||
|
|
||||||
|
class Stage(models.TextChoices):
|
||||||
|
INTAKE = "INTAKE", "Eingang"
|
||||||
|
INSPECTION = "INSPECTION", "Inspektion"
|
||||||
|
REPAIR = "REPAIR", "Reparatur"
|
||||||
|
STORAGE = "STORAGE", "Lagerung"
|
||||||
|
ISSUED = "ISSUED", "Ausgegeben"
|
||||||
|
|
||||||
|
stage = models.CharField(max_length=12, choices=Stage.choices, default=Stage.INTAKE, db_index=True)
|
||||||
|
|
||||||
|
number = models.PositiveIntegerField(unique=True, db_index=True, editable=False)
|
||||||
|
shelf = models.CharField(max_length=80, blank=True, db_index=True)
|
||||||
|
status = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, related_name="status_devices", limit_choices_to={"kind": ChoiceValue.Kind.STATUS})
|
||||||
|
date = models.DateField(default=timezone.localdate, db_index=True)
|
||||||
|
recipient = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, null=True, blank=True, related_name="recipient_devices", limit_choices_to={"kind": ChoiceValue.Kind.RECIPIENT})
|
||||||
|
location = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, null=True, blank=True, related_name="location_devices", limit_choices_to={"kind": ChoiceValue.Kind.LOCATION})
|
||||||
|
ticket = models.CharField(max_length=120, blank=True, db_index=True)
|
||||||
|
device_type = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, null=True, blank=True, related_name="type_devices", limit_choices_to={"kind": ChoiceValue.Kind.DEVICE_TYPE})
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
battery = HardwareValue("battery")
|
||||||
|
wifi = HardwareValue("wifi")
|
||||||
|
lan = HardwareValue("lan")
|
||||||
|
display_inches = HardwareValue("display_inches")
|
||||||
|
ram_gb = HardwareValue("ram_gb")
|
||||||
|
ssd_gb = HardwareValue("ssd_gb")
|
||||||
|
hdd_gb = HardwareValue("hdd_gb")
|
||||||
|
cpu = HardwareValue("cpu")
|
||||||
|
hwid = HardwareValue("hwid")
|
||||||
|
ports = HardwareValue("ports")
|
||||||
|
resolution = HardwareValue("resolution")
|
||||||
|
print_technology = HardwareValue("print_technology")
|
||||||
|
duplex = HardwareValue("duplex")
|
||||||
|
color_printing = HardwareValue("color_printing")
|
||||||
|
model = models.CharField(max_length=200, blank=True, db_index=True)
|
||||||
|
employee = models.ForeignKey(Employee, on_delete=models.PROTECT, null=True, blank=True, related_name="devices")
|
||||||
|
donor = models.CharField(max_length=200, blank=True)
|
||||||
|
comment = models.TextField(blank=True)
|
||||||
|
custom_values = models.JSONField(default=dict, blank=True)
|
||||||
|
archived_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||||
|
archived_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="archived_devices")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-number"]
|
||||||
|
constraints = [models.CheckConstraint(condition=models.Q(stage__in=["INTAKE", "INSPECTION", "REPAIR", "STORAGE", "ISSUED"]), name="valid_device_stage")]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["archived_at", "-number"]),
|
||||||
|
models.Index(fields=["status", "archived_at"]),
|
||||||
|
models.Index(fields=["device_type", "archived_at"]),
|
||||||
|
models.Index(fields=["employee", "archived_at"]),
|
||||||
|
models.Index(fields=["recipient", "archived_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
hardware = {name: kwargs.pop(name) for name in HARDWARE_DEFAULTS if name in kwargs}
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._loaded_device_type_id = self.device_type_id
|
||||||
|
self._hardware_cache = {**HARDWARE_DEFAULTS, **hardware} if hardware else None
|
||||||
|
self._hardware_dirty = bool(hardware)
|
||||||
|
|
||||||
|
def _load_hardware(self):
|
||||||
|
if self._hardware_cache is not None:
|
||||||
|
return
|
||||||
|
values = dict(HARDWARE_DEFAULTS)
|
||||||
|
if self.pk and not self._state.adding:
|
||||||
|
old_type = self.device_type if self._loaded_device_type_id == self.device_type_id else (
|
||||||
|
ChoiceValue.objects.filter(pk=self._loaded_device_type_id).first() if self._loaded_device_type_id else None
|
||||||
|
)
|
||||||
|
model = specification_model(old_type)
|
||||||
|
specification = getattr(self, model._meta.get_field("device").remote_field.related_name, None)
|
||||||
|
if specification:
|
||||||
|
for name in model.hardware_field_names:
|
||||||
|
values[name] = getattr(specification, name)
|
||||||
|
self._hardware_cache = values
|
||||||
|
|
||||||
|
def _save_hardware(self):
|
||||||
|
model = specification_model(self.device_type)
|
||||||
|
for other in SPECIFICATION_MODELS:
|
||||||
|
if other is not model:
|
||||||
|
other.objects.filter(device_id=self.pk).delete()
|
||||||
|
defaults = {name: self._hardware_cache[name] for name in model.hardware_field_names}
|
||||||
|
specification, _ = model.objects.update_or_create(device=self, defaults=defaults)
|
||||||
|
specification.full_clean()
|
||||||
|
self._hardware_dirty = False
|
||||||
|
self._loaded_device_type_id = self.device_type_id
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs) -> None:
|
||||||
|
creating = self._state.adding
|
||||||
|
type_changed = not creating and self.device_type_id != self._loaded_device_type_id
|
||||||
|
if creating or type_changed or self._hardware_dirty:
|
||||||
|
self._load_hardware()
|
||||||
|
if creating or type_changed:
|
||||||
|
from .device_types import clear_inapplicable
|
||||||
|
cleared = clear_inapplicable(self)
|
||||||
|
if cleared and kwargs.get("update_fields") is not None:
|
||||||
|
kwargs["update_fields"] = set(kwargs["update_fields"]) | {"custom_values"}
|
||||||
|
if kwargs.get("update_fields") is not None:
|
||||||
|
kwargs["update_fields"] = set(kwargs["update_fields"]) - HARDWARE_DEFAULTS.keys()
|
||||||
|
if self._hardware_dirty and not kwargs["update_fields"]:
|
||||||
|
kwargs["update_fields"].add("updated_at")
|
||||||
|
if not creating:
|
||||||
|
previous = Device.objects.select_for_update().get(pk=self.pk)
|
||||||
|
self.revision = previous.revision + 1
|
||||||
|
# Keep legacy API timestamp tokens unique, including on coarse clocks.
|
||||||
|
self.updated_at = max(timezone.now(), previous.updated_at + timedelta(microseconds=1))
|
||||||
|
if kwargs.get("update_fields") is not None:
|
||||||
|
if not kwargs["update_fields"]:
|
||||||
|
return
|
||||||
|
kwargs["update_fields"] = set(kwargs["update_fields"]) | {"revision", "updated_at"}
|
||||||
|
if not self.number:
|
||||||
|
self.number = NumberSequence.next_device_number()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if creating:
|
||||||
|
NumberSequence.ensure_at_least(self.number)
|
||||||
|
if creating or type_changed or self._hardware_dirty:
|
||||||
|
self._save_hardware()
|
||||||
|
|
||||||
|
def refresh_from_db(self, *args, **kwargs):
|
||||||
|
super().refresh_from_db(*args, **kwargs)
|
||||||
|
self._loaded_device_type_id = self.device_type_id
|
||||||
|
self._hardware_cache = None
|
||||||
|
self._hardware_dirty = False
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Gerät #{self.number}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hardware_fields(self):
|
||||||
|
from .device_types import applicable_fields
|
||||||
|
return applicable_fields(self.device_type)
|
||||||
|
|
||||||
|
def get_battery_display(self):
|
||||||
|
return self.TriState(self.battery).label
|
||||||
|
|
||||||
|
def get_wifi_display(self):
|
||||||
|
return self.TriState(self.wifi).label
|
||||||
|
|
||||||
|
def get_lan_display(self):
|
||||||
|
return self.TriState(self.lan).label
|
||||||
|
|
||||||
|
|
||||||
|
class ComputerSpecification(models.Model):
|
||||||
|
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports")
|
||||||
|
device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="computer_specification", primary_key=True)
|
||||||
|
cpu = models.CharField(max_length=200, blank=True, db_index=True)
|
||||||
|
hwid = models.CharField(max_length=255, blank=True, db_index=True)
|
||||||
|
ram_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
ssd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
hdd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=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)
|
||||||
|
wifi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
lan = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
ports = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputerCore(models.Model):
|
||||||
|
cpu = models.CharField(max_length=200, blank=True, db_index=True)
|
||||||
|
hwid = models.CharField(max_length=255, blank=True, db_index=True)
|
||||||
|
ram_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
ssd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
hdd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
wifi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
lan = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
ports = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class LaptopSpecification(ComputerCore):
|
||||||
|
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopSpecification(ComputerCore):
|
||||||
|
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "wifi", "lan", "ports")
|
||||||
|
device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="desktop_specification", primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AllInOneSpecification(ComputerCore):
|
||||||
|
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "wifi", "lan", "ports")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSpecification(models.Model):
|
||||||
|
hardware_field_names = ("display_inches", "resolution", "ports")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterSpecification(models.Model):
|
||||||
|
hardware_field_names = ("wifi", "lan", "ports", "print_technology", "duplex", "color_printing")
|
||||||
|
device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="printer_specification", primary_key=True)
|
||||||
|
wifi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
lan = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
ports = models.CharField(max_length=255, blank=True)
|
||||||
|
print_technology = models.CharField(max_length=80, blank=True)
|
||||||
|
duplex = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
color_printing = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
SPECIFICATION_MODELS = (LaptopSpecification, DesktopSpecification, AllInOneSpecification,
|
||||||
|
MonitorSpecification, PrinterSpecification, ComputerSpecification)
|
||||||
|
|
||||||
|
|
||||||
|
def specification_model(device_type):
|
||||||
|
label = device_type.label.strip().casefold() if device_type else ""
|
||||||
|
if label in {"drucker", "printer"}:
|
||||||
|
return PrinterSpecification
|
||||||
|
if label in {"bildschirm", "monitor"}:
|
||||||
|
return MonitorSpecification
|
||||||
|
if label == "laptop":
|
||||||
|
return LaptopSpecification
|
||||||
|
if label == "desktop":
|
||||||
|
return DesktopSpecification
|
||||||
|
if label in {"aio", "all-in-one", "all in one"}:
|
||||||
|
return AllInOneSpecification
|
||||||
|
return ComputerSpecification
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTask(TimeStampedModel):
|
||||||
|
class State(models.TextChoices):
|
||||||
|
OPEN = "OPEN", "Offen"
|
||||||
|
DONE = "DONE", "Erledigt"
|
||||||
|
|
||||||
|
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="tasks")
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
state = models.CharField(max_length=8, choices=State.choices, default=State.OPEN, db_index=True)
|
||||||
|
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_device_tasks")
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="created_device_tasks")
|
||||||
|
completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="completed_device_tasks")
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
due_date = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["state", "-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(condition=models.Q(state__in=["OPEN", "DONE"]), name="valid_device_task_state"),
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=models.Q(state="OPEN", completed_at__isnull=True) | models.Q(state="DONE", completed_at__isnull=False),
|
||||||
|
name="device_task_completion_matches_state",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [models.Index(fields=["state", "due_date"]), models.Index(fields=["device", "state"])]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowEvent(models.Model):
|
||||||
|
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="workflow_events")
|
||||||
|
from_stage = models.CharField(max_length=12, choices=Device.Stage.choices, blank=True)
|
||||||
|
to_stage = models.CharField(max_length=12, choices=Device.Stage.choices)
|
||||||
|
note = models.CharField(max_length=500, blank=True)
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
indexes = [models.Index(fields=["device", "-created_at"])]
|
||||||
|
|
||||||
|
|
||||||
|
class InspectionRecord(models.Model):
|
||||||
|
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="inspections")
|
||||||
|
defects = models.TextField()
|
||||||
|
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
indexes = [models.Index(fields=["device", "-created_at"])]
|
||||||
|
|
||||||
|
|
||||||
|
class RepairRecord(models.Model):
|
||||||
|
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="repairs")
|
||||||
|
work_done = models.TextField()
|
||||||
|
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
indexes = [models.Index(fields=["device", "-created_at"])]
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(models.Model):
|
||||||
|
class Action(models.TextChoices):
|
||||||
|
CREATE = "CREATE", "Erstellt"
|
||||||
|
UPDATE = "UPDATE", "Geändert"
|
||||||
|
ARCHIVE = "ARCHIVE", "Archiviert"
|
||||||
|
RESTORE = "RESTORE", "Wiederhergestellt"
|
||||||
|
IMPORT = "IMPORT", "Importiert"
|
||||||
|
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="audit_entries")
|
||||||
|
api_client = models.CharField(max_length=50, blank=True)
|
||||||
|
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="audit_entries")
|
||||||
|
action = models.CharField(max_length=10, choices=Action.choices)
|
||||||
|
field = models.CharField(max_length=100, blank=True)
|
||||||
|
field_key = models.CharField(max_length=120, blank=True)
|
||||||
|
old_value = models.TextField(blank=True)
|
||||||
|
new_value = models.TextField(blank=True)
|
||||||
|
old_data = models.JSONField(null=True, blank=True)
|
||||||
|
new_data = models.JSONField(null=True, blank=True)
|
||||||
|
reverted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
reverted_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
|
related_name="reverted_audit_entries",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
indexes = [models.Index(fields=["device", "-created_at"])]
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryAPIKeyManager(BaseAPIKeyManager):
|
||||||
|
def get_usable_keys(self):
|
||||||
|
return super().get_usable_keys().filter(
|
||||||
|
service_user__is_active=True,
|
||||||
|
expiry_date__gt=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryAPIKey(AbstractAPIKey):
|
||||||
|
"""Widerrufbarer Schlüssel für genau einen internen API-Client."""
|
||||||
|
|
||||||
|
objects = InventoryAPIKeyManager()
|
||||||
|
service_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="inventory_api_keys",
|
||||||
|
help_text="Technisches oder persönliches Konto, dem API-Änderungen zugerechnet werden.",
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="created_inventory_api_keys",
|
||||||
|
)
|
||||||
|
can_read = models.BooleanField(default=True)
|
||||||
|
can_write = models.BooleanField(default=False)
|
||||||
|
last_used_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
last_used_ip = models.GenericIPAddressField(null=True, blank=True, editable=False)
|
||||||
|
|
||||||
|
class Meta(AbstractAPIKey.Meta):
|
||||||
|
verbose_name = "API-Schlüssel"
|
||||||
|
verbose_name_plural = "API-Schlüssel"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreference(TimeStampedModel):
|
||||||
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="inventory_preferences")
|
||||||
|
visible_columns = models.JSONField(default=list, blank=True)
|
||||||
|
column_order = models.JSONField(default=list, blank=True)
|
||||||
|
column_widths = models.JSONField(default=dict, blank=True)
|
||||||
|
page_size = models.PositiveSmallIntegerField(default=50)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedView(TimeStampedModel):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="saved_views")
|
||||||
|
name = models.CharField(max_length=120)
|
||||||
|
filters = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [models.UniqueConstraint(fields=["user", "name"], name="unique_saved_view_name")]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportJob(TimeStampedModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
UPLOADED = "UPLOADED", "Hochgeladen"
|
||||||
|
COMPLETED = "COMPLETED", "Abgeschlossen"
|
||||||
|
FAILED = "FAILED", "Fehlgeschlagen"
|
||||||
|
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="import_jobs")
|
||||||
|
file = models.FileField(upload_to="imports/%Y/%m/")
|
||||||
|
original_name = models.CharField(max_length=255)
|
||||||
|
status = models.CharField(max_length=12, choices=Status.choices, default=Status.UPLOADED)
|
||||||
|
headers = models.JSONField(default=list)
|
||||||
|
preview_rows = models.JSONField(default=list)
|
||||||
|
result = models.JSONField(default=dict)
|
||||||
37
inventory/permissions.py
Normal file
37
inventory/permissions.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from django.contrib.auth.views import redirect_to_login
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
def user_role(user) -> str | None:
|
||||||
|
if not getattr(user, "is_authenticated", False):
|
||||||
|
return None
|
||||||
|
if user.is_superuser:
|
||||||
|
return UserProfile.Role.ADMIN
|
||||||
|
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||||
|
return profile.role
|
||||||
|
|
||||||
|
|
||||||
|
def role_required(*roles: str):
|
||||||
|
def decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect_to_login(request.get_full_path())
|
||||||
|
if user_role(request.user) not in roles:
|
||||||
|
raise PermissionDenied
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit(user) -> bool:
|
||||||
|
return user_role(user) in {UserProfile.Role.ADMIN, UserProfile.Role.EDITOR}
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user) -> bool:
|
||||||
|
return user_role(user) == UserProfile.Role.ADMIN
|
||||||
167
inventory/query.py
Normal file
167
inventory/query.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.models import CharField, Q, QuerySet
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
from .models import Device, FieldDefinition
|
||||||
|
|
||||||
|
|
||||||
|
SORT_FIELDS = {
|
||||||
|
"number": "number", "date": "date", "shelf": "shelf", "status": "status__label", "stage": "stage",
|
||||||
|
"device_type": "device_type__label", "price": "price", "ram_gb": "_ram_gb", "ssd_gb": "_ssd_gb",
|
||||||
|
"hdd_gb": "_hdd_gb", "display_inches": "_display_inches", "cpu": "_cpu", "model": "model",
|
||||||
|
"employee": "employee__code", "recipient": "recipient__label", "ticket": "ticket",
|
||||||
|
}
|
||||||
|
COMPUTER_RELATIONS = ("laptop_specification", "desktop_specification", "aio_specification", "computer_specification")
|
||||||
|
SPECIFICATION_RELATIONS = (*COMPUTER_RELATIONS, "monitor_specification", "printer_specification")
|
||||||
|
|
||||||
|
|
||||||
|
def computer_field_query(field, lookup, value):
|
||||||
|
query = Q()
|
||||||
|
for relation in COMPUTER_RELATIONS:
|
||||||
|
query |= Q(**{f"{relation}__{field}__{lookup}": value})
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_patterns(*patterns):
|
||||||
|
query = Q()
|
||||||
|
for lookup, value in patterns:
|
||||||
|
query |= computer_field_query("cpu", lookup, value)
|
||||||
|
return query
|
||||||
|
|
||||||
|
CPU_VENDOR_QUERIES = {
|
||||||
|
"intel": cpu_patterns(("icontains", "intel"), ("istartswith", "core "), ("istartswith", "i3"), ("istartswith", "i5"), ("istartswith", "i7"), ("istartswith", "i9")),
|
||||||
|
"amd": cpu_patterns(("icontains", "amd"), ("icontains", "ryzen"), ("icontains", "threadripper"), ("istartswith", "fx-")),
|
||||||
|
}
|
||||||
|
CPU_FAMILY_QUERIES = {
|
||||||
|
"intel_i3": cpu_patterns(("icontains", "core i3"), ("istartswith", "i3")),
|
||||||
|
"intel_i5": cpu_patterns(("icontains", "core i5"), ("istartswith", "i5")),
|
||||||
|
"intel_i7": cpu_patterns(("icontains", "core i7"), ("istartswith", "i7")),
|
||||||
|
"intel_i9": cpu_patterns(("icontains", "core i9"), ("istartswith", "i9")),
|
||||||
|
"intel_ultra_5": cpu_patterns(("icontains", "core ultra 5")),
|
||||||
|
"intel_ultra_7": cpu_patterns(("icontains", "core ultra 7")),
|
||||||
|
"intel_ultra_9": cpu_patterns(("icontains", "core ultra 9")),
|
||||||
|
"amd_ryzen_3": cpu_patterns(("icontains", "ryzen 3 ")),
|
||||||
|
"amd_ryzen_5": cpu_patterns(("icontains", "ryzen 5 ")),
|
||||||
|
"amd_ryzen_7": cpu_patterns(("icontains", "ryzen 7 ")),
|
||||||
|
"amd_ryzen_9": cpu_patterns(("icontains", "ryzen 9 ")),
|
||||||
|
"amd_ryzen_ai": cpu_patterns(("icontains", "ryzen ai")),
|
||||||
|
"amd_threadripper": cpu_patterns(("icontains", "threadripper")),
|
||||||
|
"amd_fx": cpu_patterns(("icontains", "amd fx-"), ("istartswith", "fx-")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filter_date(value):
|
||||||
|
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(value), date_format).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def device_queryset(params, archived: bool = False) -> QuerySet[Device]:
|
||||||
|
qs = Device.objects.select_related("status", "recipient", "location", "device_type", "employee", *SPECIFICATION_RELATIONS).annotate(
|
||||||
|
_display_inches=Coalesce("laptop_specification__display_inches", "aio_specification__display_inches", "monitor_specification__display_inches", "computer_specification__display_inches"),
|
||||||
|
_ram_gb=Coalesce(*(f"{name}__ram_gb" for name in COMPUTER_RELATIONS)),
|
||||||
|
_ssd_gb=Coalesce(*(f"{name}__ssd_gb" for name in COMPUTER_RELATIONS)),
|
||||||
|
_hdd_gb=Coalesce(*(f"{name}__hdd_gb" for name in COMPUTER_RELATIONS)),
|
||||||
|
_cpu=Coalesce(*(f"{name}__cpu" for name in COMPUTER_RELATIONS), output_field=CharField()),
|
||||||
|
)
|
||||||
|
qs = qs.filter(archived_at__isnull=not archived)
|
||||||
|
search = params.get("q", "").strip()
|
||||||
|
if search:
|
||||||
|
if search.isdigit() and not search.startswith("0") and len(search) <= 10:
|
||||||
|
prefix = int(search)
|
||||||
|
if params.get("exact") == "1":
|
||||||
|
qs = qs.filter(number=prefix)
|
||||||
|
else:
|
||||||
|
number_query = Q(number=prefix)
|
||||||
|
for total_digits in range(len(search) + 1, 11):
|
||||||
|
factor = 10 ** (total_digits - len(search))
|
||||||
|
number_query |= Q(number__gte=prefix * factor, number__lt=(prefix + 1) * factor)
|
||||||
|
qs = qs.filter(number_query)
|
||||||
|
else:
|
||||||
|
qs = qs.none()
|
||||||
|
for param, field in [("status", "status_id"), ("type", "device_type_id"), ("employee", "employee_id"), ("recipient", "recipient_id")]:
|
||||||
|
values = [v for v in params.getlist(param) if v]
|
||||||
|
if values:
|
||||||
|
if any(not v.isascii() or not v.isdigit() or len(v) > 19 or int(v) > 9223372036854775807 for v in values):
|
||||||
|
return qs.none()
|
||||||
|
qs = qs.filter(**{f"{field}__in": values})
|
||||||
|
if stage := params.get("stage"):
|
||||||
|
if stage not in Device.Stage.values:
|
||||||
|
return qs.none()
|
||||||
|
qs = qs.filter(stage=stage)
|
||||||
|
for param, field in [("ram", "_ram_gb"), ("ssd", "_ssd_gb"), ("hdd", "_hdd_gb")]:
|
||||||
|
for suffix, lookup in [("min", "gte"), ("max", "lte"), ("exact", "exact")]:
|
||||||
|
value = params.get(f"{param}_{suffix}")
|
||||||
|
if value not in (None, ""):
|
||||||
|
if not value.isascii() or not value.isdigit() or len(value) > 10 or int(value) > 2147483647:
|
||||||
|
return qs.none()
|
||||||
|
qs = qs.filter(**{f"{field}__{lookup}": int(value)})
|
||||||
|
for param, field in [("price", "price"), ("display", "_display_inches")]:
|
||||||
|
for suffix, lookup in [("min", "gte"), ("max", "lte")]:
|
||||||
|
value = params.get(f"{param}_{suffix}")
|
||||||
|
if value not in (None, ""):
|
||||||
|
try:
|
||||||
|
numeric = Decimal(value.replace(",", "."))
|
||||||
|
if not numeric.is_finite() or abs(numeric) > Decimal("99999999.99"):
|
||||||
|
return qs.none()
|
||||||
|
except InvalidOperation:
|
||||||
|
return qs.none()
|
||||||
|
qs = qs.filter(**{f"{field}__{lookup}": numeric})
|
||||||
|
for name in ["battery", "wifi", "lan"]:
|
||||||
|
value = params.get(name)
|
||||||
|
if value:
|
||||||
|
if name == "battery":
|
||||||
|
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}))
|
||||||
|
vendor_query = Q()
|
||||||
|
for value in params.getlist("cpu_vendor"):
|
||||||
|
if value in CPU_VENDOR_QUERIES:
|
||||||
|
vendor_query |= CPU_VENDOR_QUERIES[value]
|
||||||
|
if vendor_query:
|
||||||
|
qs = qs.filter(vendor_query)
|
||||||
|
family_query = Q()
|
||||||
|
for value in params.getlist("cpu_family"):
|
||||||
|
if value in CPU_FAMILY_QUERIES:
|
||||||
|
family_query |= CPU_FAMILY_QUERIES[value]
|
||||||
|
if family_query:
|
||||||
|
qs = qs.filter(family_query)
|
||||||
|
date_from = parse_filter_date(params.get("date_from")) if params.get("date_from") else None
|
||||||
|
date_to = parse_filter_date(params.get("date_to")) if params.get("date_to") else None
|
||||||
|
if date_from:
|
||||||
|
qs = qs.filter(date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
qs = qs.filter(date__lte=date_to)
|
||||||
|
for name in ["model", "ticket"]:
|
||||||
|
value = params.get(f"{name}_contains", "").strip()
|
||||||
|
if value:
|
||||||
|
qs = qs.filter(**{f"{name}__icontains": value})
|
||||||
|
cpu_value = params.get("cpu_contains", "").strip()
|
||||||
|
for token in re.findall(r"[a-z]+\d*|\d+[a-z]*", cpu_value.casefold()):
|
||||||
|
qs = qs.filter(computer_field_query("cpu", "icontains", token))
|
||||||
|
for definition in FieldDefinition.objects.filter(active=True, filterable=True):
|
||||||
|
value = params.get(f"custom_{definition.key}", "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
lookup = "exact" if definition.field_type in {"integer", "decimal", "boolean", "date", "select"} else "icontains"
|
||||||
|
if definition.field_type == "integer":
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
elif definition.field_type == "boolean":
|
||||||
|
value = value.lower() in {"1", "true", "yes", "ja"}
|
||||||
|
if lookup == "exact" and connection.vendor == "postgresql":
|
||||||
|
qs = qs.filter(custom_values__contains={definition.key: value})
|
||||||
|
else:
|
||||||
|
qs = qs.filter(**{f"custom_values__{definition.key}__{lookup}": value})
|
||||||
|
sort = params.get("sort", "number")
|
||||||
|
direction = "-" if params.get("dir", "desc") == "desc" else ""
|
||||||
|
return qs.order_by(f"{direction}{SORT_FIELDS.get(sort, 'number')}", "-number")
|
||||||
389
inventory/services.py
Normal file
389
inventory/services.py
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
FIELD_LABELS = {
|
||||||
|
"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",
|
||||||
|
"ram_gb": "RAM", "ssd_gb": "SSD", "hdd_gb": "HDD", "cpu": "CPU",
|
||||||
|
"hwid": "HWID", "model": "Modell", "employee": "Bearbeiter", "donor": "Spender", "comment": "Kommentar",
|
||||||
|
"ports": "Anschlüsse", "resolution": "Auflösung", "print_technology": "Druckverfahren",
|
||||||
|
"duplex": "Duplex", "color_printing": "Farbdruck",
|
||||||
|
}
|
||||||
|
INLINE_FIELDS = set(FIELD_LABELS)
|
||||||
|
RELATION_FIELDS = {
|
||||||
|
"status": ChoiceValue.Kind.STATUS, "recipient": ChoiceValue.Kind.RECIPIENT,
|
||||||
|
"location": ChoiceValue.Kind.LOCATION, "device_type": ChoiceValue.Kind.DEVICE_TYPE,
|
||||||
|
}
|
||||||
|
INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"}
|
||||||
|
DECIMAL_FIELDS = {"price", "display_inches"}
|
||||||
|
TRISTATE_FIELDS = {"battery", "wifi", "lan", "duplex", "color_printing"}
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def display_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if hasattr(value, "label"):
|
||||||
|
return value.label
|
||||||
|
if hasattr(value, "code"):
|
||||||
|
return value.code
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def display_device_value(field: str, value: Any) -> str:
|
||||||
|
if field in TRISTATE_FIELDS:
|
||||||
|
return dict(Device.TriState.choices).get(value, value or "")
|
||||||
|
if field == "date" and isinstance(value, date):
|
||||||
|
return value.strftime("%d.%m.%Y")
|
||||||
|
return display_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_audit_value(field: str, value: Any) -> Any:
|
||||||
|
if field.startswith("custom:"):
|
||||||
|
return value
|
||||||
|
if field in RELATION_FIELDS or field == "employee":
|
||||||
|
return value.pk if value is not None else None
|
||||||
|
if isinstance(value, (date, Decimal)):
|
||||||
|
return str(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def add_audit(
|
||||||
|
user, device: Device, action: str, field: str = "", old: Any = "", new: Any = "", *,
|
||||||
|
field_key: str = "", old_data: Any = _UNSET, new_data: Any = _UNSET, api_client: str = "",
|
||||||
|
) -> AuditLog:
|
||||||
|
values = {
|
||||||
|
"user": user if getattr(user, "is_authenticated", False) else None,
|
||||||
|
"api_client": api_client,
|
||||||
|
"device": device, "action": action, "field": field, "field_key": field_key,
|
||||||
|
"old_value": display_value(old), "new_value": display_value(new),
|
||||||
|
}
|
||||||
|
if old_data is not _UNSET:
|
||||||
|
values["old_data"] = old_data
|
||||||
|
if new_data is not _UNSET:
|
||||||
|
values["new_data"] = new_data
|
||||||
|
return AuditLog.objects.create(**values)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_device_batch(*, first_number: int, quantity: int, shelf: str, status, device_date, device_type, employee, donor: str, user,
|
||||||
|
model: str = "", hardware_values: dict | None = None) -> list[Device]:
|
||||||
|
sequence, _ = NumberSequence.objects.select_for_update().get_or_create(name="device")
|
||||||
|
last_number = first_number + quantity - 1
|
||||||
|
conflict = Device.objects.filter(number__range=(first_number, last_number)).order_by("number").values_list("number", flat=True).first()
|
||||||
|
if conflict is not None:
|
||||||
|
raise ValidationError(f"Die Gerätenummer {conflict} ist bereits vergeben.")
|
||||||
|
|
||||||
|
created: list[Device] = []
|
||||||
|
for batch_start in range(first_number, last_number + 1, 1000):
|
||||||
|
batch_end = min(batch_start + 999, last_number)
|
||||||
|
batch = [
|
||||||
|
Device(number=number, shelf=shelf, status=status, date=device_date, device_type=device_type,
|
||||||
|
employee=employee, donor=donor, model=model, **(hardware_values or {}))
|
||||||
|
for number in range(batch_start, batch_end + 1)
|
||||||
|
]
|
||||||
|
saved = Device.objects.bulk_create(batch, batch_size=1000)
|
||||||
|
bulk_create_specifications(saved)
|
||||||
|
AuditLog.objects.bulk_create(
|
||||||
|
[AuditLog(user=user, device=device, action=AuditLog.Action.CREATE, new_value=f"Gerät #{device.number}") for device in saved],
|
||||||
|
batch_size=1000,
|
||||||
|
)
|
||||||
|
created.extend(saved)
|
||||||
|
|
||||||
|
current_maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0
|
||||||
|
sequence.last_value = max(sequence.last_value, current_maximum, last_number)
|
||||||
|
sequence.save(update_fields=["last_value"])
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_inline_value(field: str, raw: Any) -> Any:
|
||||||
|
if isinstance(raw, (dict, list)):
|
||||||
|
raise ValidationError("Bitte einen einzelnen Feldwert eingeben.")
|
||||||
|
if raw in (None, ""):
|
||||||
|
if field == "status":
|
||||||
|
raise ValidationError("Dieses Feld darf nicht leer sein.")
|
||||||
|
return None if field in INTEGER_FIELDS | DECIMAL_FIELDS | set(RELATION_FIELDS) | {"employee"} else ""
|
||||||
|
if field in INTEGER_FIELDS:
|
||||||
|
try:
|
||||||
|
numeric = Decimal(str(raw))
|
||||||
|
if not numeric.is_finite() or numeric != numeric.to_integral_value():
|
||||||
|
raise ValueError
|
||||||
|
value = int(numeric)
|
||||||
|
except (TypeError, ValueError, InvalidOperation, OverflowError):
|
||||||
|
raise ValidationError("Bitte eine gültige ganze Zahl eingeben.")
|
||||||
|
if value < 0:
|
||||||
|
raise ValidationError("Der Wert darf nicht negativ sein.")
|
||||||
|
return value
|
||||||
|
if field in DECIMAL_FIELDS:
|
||||||
|
try:
|
||||||
|
value = Decimal(str(raw).replace(",", "."))
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValidationError("Bitte eine gültige Dezimalzahl eingeben.")
|
||||||
|
if not value.is_finite() or value < 0:
|
||||||
|
raise ValidationError("Der Wert darf nicht negativ sein.")
|
||||||
|
return value
|
||||||
|
if field == "date":
|
||||||
|
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw), date_format).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValidationError("Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.")
|
||||||
|
if field in TRISTATE_FIELDS:
|
||||||
|
if raw not in dict(Device.TriState.choices):
|
||||||
|
raise ValidationError("Ungültiger Auswahlwert.")
|
||||||
|
return raw
|
||||||
|
if field in RELATION_FIELDS:
|
||||||
|
try:
|
||||||
|
return ChoiceValue.objects.get(pk=int(raw), kind=RELATION_FIELDS[field], active=True)
|
||||||
|
except (ChoiceValue.DoesNotExist, TypeError, ValueError):
|
||||||
|
raise ValidationError("Ungültiger Auswahlwert.")
|
||||||
|
if field == "employee":
|
||||||
|
try:
|
||||||
|
return Employee.objects.get(pk=int(raw), active=True)
|
||||||
|
except (Employee.DoesNotExist, TypeError, ValueError):
|
||||||
|
raise ValidationError("Ungültiger Mitarbeiter.")
|
||||||
|
return str(raw).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def check_device_version(device, expected_updated_at=None, expected_revision=None):
|
||||||
|
if expected_revision is not None:
|
||||||
|
if isinstance(expected_revision, bool) or str(expected_revision) != str(device.revision):
|
||||||
|
raise RuntimeError("conflict")
|
||||||
|
if expected_updated_at:
|
||||||
|
try:
|
||||||
|
expected = expected_updated_at if isinstance(expected_updated_at, datetime) else datetime.fromisoformat(str(expected_updated_at).replace("Z", "+00:00"))
|
||||||
|
if timezone.is_naive(expected):
|
||||||
|
expected = timezone.make_aware(expected, timezone.get_current_timezone())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise RuntimeError("conflict")
|
||||||
|
if device.updated_at != expected:
|
||||||
|
raise RuntimeError("conflict")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_device_field(
|
||||||
|
device: Device, field: str, raw_value: Any, user, expected_updated_at: str | None = None, *,
|
||||||
|
api_client: str = "", expected_revision=None,
|
||||||
|
) -> Device:
|
||||||
|
if not isinstance(field, str) or field not in INLINE_FIELDS:
|
||||||
|
raise ValidationError("Dieses Feld kann nicht direkt bearbeitet werden.")
|
||||||
|
locked = Device.objects.select_for_update().get(pk=device.pk)
|
||||||
|
check_device_version(locked, expected_updated_at, expected_revision)
|
||||||
|
old = getattr(locked, field)
|
||||||
|
new = coerce_inline_value(field, raw_value)
|
||||||
|
if field in HARDWARE_FIELDS and field not in applicable_fields(locked.device_type):
|
||||||
|
raise ValidationError("Dieses Hardwarefeld gehört nicht zu diesem Gerätetyp.")
|
||||||
|
if serialize_audit_value(field, old) != serialize_audit_value(field, new):
|
||||||
|
setattr(locked, field, new)
|
||||||
|
changed_fields = [field, "updated_at"]
|
||||||
|
cleared_hardware = {}
|
||||||
|
if field == "device_type":
|
||||||
|
cleared_hardware = clear_inapplicable(locked)
|
||||||
|
changed_fields.extend(HARDWARE_FIELDS - applicable_fields(new))
|
||||||
|
if cleared_hardware:
|
||||||
|
changed_fields.append("custom_values")
|
||||||
|
locked.full_clean(exclude=["custom_values"])
|
||||||
|
locked.save(update_fields=changed_fields)
|
||||||
|
add_audit(
|
||||||
|
user, locked, AuditLog.Action.UPDATE, FIELD_LABELS[field],
|
||||||
|
display_device_value(field, old), display_device_value(field, new), field_key=field,
|
||||||
|
old_data=serialize_audit_value(field, old), new_data=serialize_audit_value(field, new),
|
||||||
|
api_client=api_client,
|
||||||
|
)
|
||||||
|
for hardware_field, previous_value in cleared_hardware.items():
|
||||||
|
add_audit(user, locked, AuditLog.Action.UPDATE, FIELD_LABELS[hardware_field],
|
||||||
|
previous_value, "", field_key=hardware_field,
|
||||||
|
old_data=serialize_audit_value(hardware_field, previous_value),
|
||||||
|
new_data=serialize_audit_value(hardware_field, getattr(locked, hardware_field)),
|
||||||
|
api_client=api_client)
|
||||||
|
return locked
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_device_value(field: str, raw: Any) -> Any:
|
||||||
|
if raw in (None, ""):
|
||||||
|
if field == "status":
|
||||||
|
raise ValidationError("Ein Status kann nicht leer sein.")
|
||||||
|
return None if field in INTEGER_FIELDS | DECIMAL_FIELDS | set(RELATION_FIELDS) | {"employee"} else ""
|
||||||
|
if field in RELATION_FIELDS:
|
||||||
|
try:
|
||||||
|
if str(raw).isdigit():
|
||||||
|
return ChoiceValue.objects.get(pk=int(raw), kind=RELATION_FIELDS[field])
|
||||||
|
return ChoiceValue.objects.get(label__iexact=str(raw), kind=RELATION_FIELDS[field])
|
||||||
|
except (ChoiceValue.DoesNotExist, ChoiceValue.MultipleObjectsReturned, TypeError, ValueError) as exc:
|
||||||
|
raise ValidationError("Der frühere Auswahlwert ist nicht mehr vorhanden.") from exc
|
||||||
|
if field == "employee":
|
||||||
|
try:
|
||||||
|
if str(raw).isdigit():
|
||||||
|
return Employee.objects.get(pk=int(raw))
|
||||||
|
return Employee.objects.get(code__iexact=str(raw))
|
||||||
|
except (Employee.DoesNotExist, Employee.MultipleObjectsReturned, TypeError, ValueError) as exc:
|
||||||
|
raise ValidationError("Der frühere Bearbeiter ist nicht mehr vorhanden.") from exc
|
||||||
|
if field == "date":
|
||||||
|
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw), date_format).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValidationError("Das frühere Datum ist ungültig.")
|
||||||
|
if field in INTEGER_FIELDS:
|
||||||
|
return int(raw)
|
||||||
|
if field in DECIMAL_FIELDS:
|
||||||
|
return Decimal(str(raw).replace(",", "."))
|
||||||
|
if field in TRISTATE_FIELDS:
|
||||||
|
reverse_labels = {label: key for key, label in Device.TriState.choices}
|
||||||
|
value = reverse_labels.get(str(raw), raw)
|
||||||
|
if value not in Device.TriState.values:
|
||||||
|
raise ValidationError("Der frühere Auswahlwert ist ungültig.")
|
||||||
|
return value
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_field_key(log: AuditLog) -> str:
|
||||||
|
key = next((name for name, label in FIELD_LABELS.items() if label == log.field), "")
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
definition = FieldDefinition.objects.filter(name=log.field).first()
|
||||||
|
return f"custom:{definition.key}" if definition else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_custom_value(definition: FieldDefinition, value: str) -> Any:
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.INTEGER:
|
||||||
|
return int(value)
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.BOOLEAN:
|
||||||
|
return value.lower() in {"1", "true", "yes", "ja"}
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT:
|
||||||
|
try:
|
||||||
|
parsed = ast.literal_eval(value)
|
||||||
|
except (SyntaxError, ValueError):
|
||||||
|
parsed = [part.strip() for part in value.split(",") if part.strip()]
|
||||||
|
return parsed if isinstance(parsed, list) else [str(parsed)]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def revert_audit_change(log: AuditLog, user) -> AuditLog:
|
||||||
|
locked_log = AuditLog.objects.select_for_update().select_related("device").get(pk=log.pk)
|
||||||
|
if locked_log.action != AuditLog.Action.UPDATE or locked_log.reverted_at:
|
||||||
|
raise ValidationError("Diese Änderung kann nicht zurückgesetzt werden.")
|
||||||
|
field_key = locked_log.field_key or _legacy_field_key(locked_log)
|
||||||
|
if not field_key:
|
||||||
|
raise ValidationError("Das geänderte Feld ist nicht mehr eindeutig zuzuordnen.")
|
||||||
|
|
||||||
|
device = Device.objects.select_for_update().get(pk=locked_log.device_id)
|
||||||
|
if field_key.startswith("custom:"):
|
||||||
|
custom_key = field_key.split(":", 1)[1]
|
||||||
|
definition = FieldDefinition.objects.filter(key=custom_key).first()
|
||||||
|
if not definition:
|
||||||
|
raise ValidationError("Das Zusatzfeld ist nicht mehr vorhanden.")
|
||||||
|
current = device.custom_values.get(custom_key)
|
||||||
|
if locked_log.field_key:
|
||||||
|
if current != locked_log.new_data:
|
||||||
|
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||||
|
restored = locked_log.old_data
|
||||||
|
else:
|
||||||
|
if display_value(current) != locked_log.new_value:
|
||||||
|
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||||
|
restored = _legacy_custom_value(definition, locked_log.old_value)
|
||||||
|
custom_values = dict(device.custom_values)
|
||||||
|
custom_values[custom_key] = restored
|
||||||
|
device.custom_values = custom_values
|
||||||
|
device.save(update_fields=["custom_values", "updated_at"])
|
||||||
|
reversal = add_audit(
|
||||||
|
user, device, AuditLog.Action.UPDATE, locked_log.field,
|
||||||
|
display_value(current), display_value(restored), field_key=field_key,
|
||||||
|
old_data=current, new_data=restored,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if field_key not in FIELD_LABELS:
|
||||||
|
raise ValidationError("Dieses Feld kann nicht zurückgesetzt werden.")
|
||||||
|
current = getattr(device, field_key)
|
||||||
|
current_data = serialize_audit_value(field_key, current)
|
||||||
|
if locked_log.field_key:
|
||||||
|
if current_data != locked_log.new_data:
|
||||||
|
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||||
|
raw_old = locked_log.old_data
|
||||||
|
else:
|
||||||
|
current_displays = {display_device_value(field_key, current), display_value(current)}
|
||||||
|
if locked_log.new_value not in current_displays:
|
||||||
|
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||||
|
raw_old = locked_log.old_value
|
||||||
|
restored = _deserialize_device_value(field_key, raw_old)
|
||||||
|
if field_key in HARDWARE_FIELDS and field_key not in applicable_fields(device.device_type):
|
||||||
|
raise ValidationError("Dieses Hardwarefeld gehört nicht zu diesem Gerätetyp.")
|
||||||
|
setattr(device, field_key, restored)
|
||||||
|
update_fields = [field_key, "updated_at"]
|
||||||
|
if field_key == "device_type":
|
||||||
|
cleared = clear_inapplicable(device)
|
||||||
|
update_fields.extend(HARDWARE_FIELDS - applicable_fields(restored))
|
||||||
|
if cleared:
|
||||||
|
update_fields.append("custom_values")
|
||||||
|
device.full_clean(exclude=["custom_values"])
|
||||||
|
device.save(update_fields=update_fields)
|
||||||
|
reversal = add_audit(
|
||||||
|
user, device, AuditLog.Action.UPDATE, FIELD_LABELS[field_key],
|
||||||
|
display_device_value(field_key, current), display_device_value(field_key, restored), field_key=field_key,
|
||||||
|
old_data=current_data, new_data=serialize_audit_value(field_key, restored),
|
||||||
|
)
|
||||||
|
|
||||||
|
locked_log.reverted_at = timezone.now()
|
||||||
|
locked_log.reverted_by = user
|
||||||
|
locked_log.save(update_fields=["reverted_at", "reverted_by"])
|
||||||
|
return reversal
|
||||||
|
|
||||||
|
|
||||||
|
def validate_custom_value(definition: FieldDefinition, raw: Any) -> Any:
|
||||||
|
if raw in (None, "", []):
|
||||||
|
if definition.required:
|
||||||
|
raise ValidationError(f"{definition.name} ist erforderlich.")
|
||||||
|
return None
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.INTEGER:
|
||||||
|
try:
|
||||||
|
value = Decimal(str(raw))
|
||||||
|
if not value.is_finite() or value != value.to_integral_value():
|
||||||
|
raise ValueError
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError, InvalidOperation, OverflowError) as exc:
|
||||||
|
raise ValidationError(f"{definition.name}: Bitte eine ganze Zahl eingeben.") from exc
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.DECIMAL:
|
||||||
|
try:
|
||||||
|
value = Decimal(str(raw).replace(",", "."))
|
||||||
|
if not value.is_finite():
|
||||||
|
raise InvalidOperation
|
||||||
|
return str(value)
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValidationError(f"{definition.name}: Bitte eine Dezimalzahl eingeben.") from exc
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.BOOLEAN:
|
||||||
|
return str(raw).lower() in {"1", "true", "yes", "ja", "on"}
|
||||||
|
if definition.field_type == FieldDefinition.FieldType.DATE:
|
||||||
|
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw), date_format).date().isoformat()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValidationError(f"{definition.name}: Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.")
|
||||||
|
if definition.field_type in {FieldDefinition.FieldType.SELECT, FieldDefinition.FieldType.MULTI_SELECT}:
|
||||||
|
values = raw if isinstance(raw, list) else [str(raw)]
|
||||||
|
allowed = set(definition.options.filter(active=True).values_list("label", flat=True))
|
||||||
|
if not definition.allow_custom_values and not set(values).issubset(allowed):
|
||||||
|
raise ValidationError(f"Ungültige Auswahl für {definition.name}.")
|
||||||
|
return values if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT else values[0]
|
||||||
|
return str(raw)
|
||||||
12
inventory/signals.py
Normal file
12
inventory/signals.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def ensure_user_profile(sender, instance: User, created: bool, raw=False, **kwargs) -> None:
|
||||||
|
if created and not raw:
|
||||||
|
role = UserProfile.Role.ADMIN if instance.is_superuser else UserProfile.Role.VIEWER
|
||||||
|
UserProfile.objects.create(user=instance, role=role)
|
||||||
179
inventory/static/inventory/css/app.css
Normal file
179
inventory/static/inventory/css/app.css
Normal file
File diff suppressed because one or more lines are too long
BIN
inventory/static/inventory/img/csr-logo.png
Normal file
BIN
inventory/static/inventory/img/csr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
404
inventory/static/inventory/js/app.js
Normal file
404
inventory/static/inventory/js/app.js
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
(() => {
|
||||||
|
const csrftoken = document.body?.dataset.csrf;
|
||||||
|
const toast = (message, type = "success") => { const root=document.querySelector("#toast-root"),item=document.createElement("div");item.className=`toast ${type}`;item.textContent=message;root.append(item);setTimeout(()=>item.remove(),2800); };
|
||||||
|
const updateKey="csr:data-updated";
|
||||||
|
const publishUpdate=()=>{try{localStorage.setItem(updateKey,String(Date.now()));}catch{}};
|
||||||
|
if(document.querySelector(".toast-stack .toast.success"))publishUpdate();
|
||||||
|
if(document.querySelector("[data-live-refresh]")){
|
||||||
|
const loadedAt=Date.now();
|
||||||
|
let dirty=false,pending=false;
|
||||||
|
const markDirty=event=>{if(event.target.closest("[data-live-refresh] form[method=post]"))dirty=true;};
|
||||||
|
document.addEventListener("input",markDirty);
|
||||||
|
document.addEventListener("change",markDirty);
|
||||||
|
const refreshIfSafe=()=>{if(document.visibilityState!=="visible"||dirty||document.querySelector(".editing, tr.selected"))return;if(pending||Date.now()-loadedAt>30000)window.location.reload();};
|
||||||
|
window.addEventListener("storage",event=>{if(event.key===updateKey){pending=true;refreshIfSafe();}});
|
||||||
|
document.addEventListener("visibilitychange",refreshIfSafe);
|
||||||
|
window.addEventListener("focus",refreshIfSafe);
|
||||||
|
}
|
||||||
|
const germanDate=value=>{const text=String(value||"").trim(),iso=text.match(/^(\d{4})-(\d{2})-(\d{2})$/);if(iso)return `${iso[3]}.${iso[2]}.${iso[1]}`;const digits=text.replace(/\D/g,"");return digits.length===8?`${digits.slice(0,2)}.${digits.slice(2,4)}.${digits.slice(4)}`:text;};
|
||||||
|
const bindGermanDates=(root=document)=>root.querySelectorAll("[data-german-date]").forEach(input=>{if(input.dataset.dateBound)return;input.dataset.dateBound="1";input.value=germanDate(input.value);input.addEventListener("blur",()=>input.value=germanDate(input.value));});bindGermanDates();
|
||||||
|
const typeFieldsScript=document.querySelector("#device-type-fields");
|
||||||
|
if(typeFieldsScript){
|
||||||
|
const typeFields=JSON.parse(typeFieldsScript.textContent);
|
||||||
|
document.querySelectorAll("[data-device-type-form]").forEach(form=>{
|
||||||
|
const typeSelect=form.querySelector('[name="device_type"]'),section=form.querySelector("[data-hardware-section]");
|
||||||
|
if(!typeSelect||!section)return;
|
||||||
|
const updateHardware=()=>{
|
||||||
|
const noType=form.hasAttribute("data-hide-hardware-without-type")&&!typeSelect.value;
|
||||||
|
const visible=new Set(noType?[]:(typeFields[typeSelect.value]||typeFields[""]||[]));
|
||||||
|
section.hidden=noType;
|
||||||
|
const selectedLabel=section.querySelector("[data-selected-type-label]");
|
||||||
|
if(selectedLabel)selectedLabel.textContent=typeSelect.value?`· ${typeSelect.selectedOptions[0]?.textContent.trim()||""}`:"";
|
||||||
|
section.querySelectorAll("[data-hardware-field]").forEach(wrapper=>{
|
||||||
|
const active=visible.has(wrapper.dataset.hardwareField);
|
||||||
|
wrapper.hidden=!active;
|
||||||
|
wrapper.querySelectorAll("input,select,textarea").forEach(field=>field.disabled=!active||form.dataset.readonly==="true");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
typeSelect.addEventListener("change",updateHardware);
|
||||||
|
updateHardware();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelector("[data-sidebar-toggle]")?.addEventListener("click",()=>document.querySelector("#sidebar")?.classList.toggle("open"));
|
||||||
|
const filterPanel=document.querySelector("[data-filter-panel]"),filterToggle=document.querySelector("[data-filter-toggle]");
|
||||||
|
const setFilterOpen=open=>{if(!filterPanel||!filterToggle)return;filterPanel.toggleAttribute("hidden",!open);filterToggle.setAttribute("aria-expanded",String(open));};
|
||||||
|
filterToggle?.addEventListener("click",()=>setFilterOpen(filterPanel.hasAttribute("hidden")));
|
||||||
|
document.querySelector("[data-filter-close]")?.addEventListener("click",()=>setFilterOpen(false));
|
||||||
|
const updateMultiFilter=details=>{const selected=[...details.querySelectorAll('input[type="checkbox"]:checked')],value=details.querySelector("[data-multi-value]");if(!value)return;value.textContent=selected.length===0?"Alle":selected.length===1?selected[0].nextElementSibling.textContent:`${selected.length} ausgewählt`;};
|
||||||
|
document.querySelectorAll("[data-multi-filter]").forEach(details=>{updateMultiFilter(details);details.addEventListener("change",()=>updateMultiFilter(details));});
|
||||||
|
const filterForm=document.querySelector("#filter-form"),updateFilterCount=()=>{if(!filterForm)return;const ignored=new Set(["q","page_size","sort","dir","page","partial","exact"]),active=new Set();[...filterForm.elements].forEach(field=>{if(!field.name||ignored.has(field.name))return;const hasValue=(field.type==="checkbox"||field.type==="radio")?field.checked:Boolean(field.value);if(hasValue)active.add(field.name);});const badge=filterToggle?.querySelector(".filter-count");if(badge){badge.textContent=active.size;badge.toggleAttribute("hidden",active.size===0);}filterToggle?.classList.toggle("active",active.size>0);};
|
||||||
|
filterForm?.addEventListener("input",updateFilterCount);filterForm?.addEventListener("change",updateFilterCount);updateFilterCount();
|
||||||
|
const bindCpuAutocomplete=(root=document)=>root.querySelectorAll("[data-cpu-autocomplete]").forEach(input=>{if(input.dataset.cpuBound)return;input.dataset.cpuBound="1";input.setAttribute("role","combobox");input.setAttribute("aria-autocomplete","list");input.setAttribute("aria-expanded","false");const host=input.parentElement,menu=document.createElement("div");host.classList.add("cpu-autocomplete-host");menu.className="cpu-suggestions";menu.setAttribute("role","listbox");menu.hidden=true;input.insertAdjacentElement("afterend",menu);let timer,controller,items=[],active=-1;const close=()=>{menu.hidden=true;menu.replaceChildren();items=[];active=-1;input.setAttribute("aria-expanded","false");};const markActive=index=>{if(!items.length)return;active=(index+items.length)%items.length;items.forEach((item,itemIndex)=>item.classList.toggle("active",itemIndex===active));items[active].scrollIntoView({block:"nearest"});};const choose=value=>{input.value=value;input.dispatchEvent(new Event("change",{bubbles:true}));close();input.focus();};const load=async()=>{const term=input.value.trim();controller?.abort();if(!term){close();return;}controller=new AbortController();try{const response=await fetch(`/autocomplete/cpu/?${new URLSearchParams({q:term})}`,{signal:controller.signal}),data=await response.json();menu.replaceChildren();items=data.results.map((result,index)=>{const button=document.createElement("button"),label=document.createElement("strong");button.type="button";button.setAttribute("role","option");label.textContent=result.label;button.append(label);if(index===0){const hint=document.createElement("small");hint.textContent="Wahrscheinlichster Treffer";button.append(hint);}button.addEventListener("mousedown",event=>event.preventDefault());button.addEventListener("click",()=>choose(result.label));menu.append(button);return button;});active=items.length?0:-1;items.forEach((item,index)=>item.classList.toggle("active",index===0));menu.hidden=!items.length;input.setAttribute("aria-expanded",String(items.length>0));}catch(error){if(error.name!=="AbortError")close();}};input.addEventListener("input",()=>{clearTimeout(timer);timer=setTimeout(load,120);});input.addEventListener("focus",()=>{if(input.value.trim())load();});input.addEventListener("keydown",event=>{if(menu.hidden||!items.length)return;if(event.key==="ArrowDown"){event.preventDefault();markActive(active+1);}else if(event.key==="ArrowUp"){event.preventDefault();markActive(active-1);}else if(event.key==="Enter"){event.preventDefault();event.stopImmediatePropagation();choose(items[Math.max(active,0)].querySelector("strong").textContent);}else if(event.key==="Escape"){event.preventDefault();close();}});input.addEventListener("blur",()=>setTimeout(close,160));});bindCpuAutocomplete();
|
||||||
|
document.querySelectorAll("[data-auto-submit]").forEach(el=>el.addEventListener("change",()=>{const size=Number(el.value);fetch("/preferences/",{method:"POST",headers:{"X-CSRFToken":csrftoken,"Content-Type":"application/json"},body:JSON.stringify({page_size:size})});el.form.submit();}));
|
||||||
|
document.addEventListener("submit", event => {
|
||||||
|
const form = event.target.closest?.("form[data-confirm]");
|
||||||
|
if (!form || form.dataset.confirmed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const overlay = document.createElement("div"), box = document.createElement("div");
|
||||||
|
overlay.className = "drawer-backdrop";
|
||||||
|
box.className = "confirm-box";
|
||||||
|
const heading = document.createElement("h2"), message = document.createElement("p");
|
||||||
|
heading.textContent = "Bitte bestätigen";
|
||||||
|
message.textContent = form.dataset.confirm;
|
||||||
|
const actions = document.createElement("div"), cancel = document.createElement("button"), confirm = document.createElement("button");
|
||||||
|
cancel.type = confirm.type = "button";
|
||||||
|
cancel.className = "btn";
|
||||||
|
confirm.className = "btn primary";
|
||||||
|
cancel.textContent = "Abbrechen";
|
||||||
|
confirm.textContent = form.dataset.confirmAction || "Bestätigen";
|
||||||
|
actions.append(cancel, confirm);
|
||||||
|
box.append(heading, message, actions);
|
||||||
|
box.setAttribute("role", "dialog");
|
||||||
|
box.setAttribute("aria-modal", "true");
|
||||||
|
const close = () => { overlay.remove(); box.remove(); document.removeEventListener("keydown", onKey); };
|
||||||
|
const onKey = event => { if (event.key === "Escape") close(); };
|
||||||
|
overlay.onclick = cancel.onclick = close;
|
||||||
|
confirm.onclick = () => { form.dataset.confirmed = "1"; close(); form.requestSubmit(); delete form.dataset.confirmed; };
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
document.body.append(overlay, box);
|
||||||
|
cancel.focus();
|
||||||
|
});
|
||||||
|
let searchTimer,searchController;const refreshDeviceTable=async(form,exact=false)=>{const container=document.querySelector("#device-table-container"),params=new URLSearchParams(new FormData(form)),endpoint=form.getAttribute("action")||window.location.pathname;params.set("partial","1");exact&¶ms.get("q")?params.set("exact","1"):params.delete("exact");searchController?.abort();searchController=new AbortController();container?.setAttribute("aria-busy","true");try{const response=await fetch(`${endpoint}?${params.toString()}`,{signal:searchController.signal,headers:{"X-Requested-With":"XMLHttpRequest"}});if(!response.ok)throw new Error();container.innerHTML=await response.text();const total=container.querySelector("[data-table-total]")?.dataset.tableTotal;const totalLabel=document.querySelector("[data-total-count]");if(totalLabel&&total!==undefined)totalLabel.textContent=total;const browserParams=new URLSearchParams(params);browserParams.delete("partial");browserParams.delete("page");history.replaceState({},"",`${window.location.pathname}${browserParams.toString()?`?${browserParams.toString()}`:""}`);bindDeviceTable();}catch(error){if(error.name!=="AbortError")toast("Die Nummernsuche konnte nicht aktualisiert werden.","error");}finally{container?.removeAttribute("aria-busy");}};const numberSearch=document.querySelector("[data-search-input]");numberSearch?.addEventListener("input",event=>{event.target.value=event.target.value.replace(/\D/g,"");clearTimeout(searchTimer);searchTimer=setTimeout(()=>refreshDeviceTable(event.target.form),200);});numberSearch?.addEventListener("keydown",event=>{if(event.key==="Enter"){event.preventDefault();clearTimeout(searchTimer);refreshDeviceTable(event.target.form,true);}});
|
||||||
|
let clickTimer;
|
||||||
|
const displayCell=(cell,display,color="")=>{const field=cell.dataset.field;if(["ram_gb","ssd_gb","hdd_gb"].includes(field)&&display)cell.textContent=`${display} GB`;else if(field==="price"&&display)cell.textContent=`${String(display).replace(".",",")} €`;else if(field==="display_inches"&&display)cell.textContent=`${String(display).replace(".",",")} Zoll`;else if(field==="status"){const badge=document.createElement("span");badge.className="status status-colored";badge.textContent=display||"—";badge.style.setProperty("--status-color",color||"#64748b");cell.replaceChildren(badge);}else cell.textContent=display||"—";};
|
||||||
|
const editCell = async cell => {
|
||||||
|
if (cell.classList.contains("editing")) return;
|
||||||
|
cell.classList.add("editing");
|
||||||
|
const original = cell.innerHTML, oldValue = cell.dataset.value || "";
|
||||||
|
const row = cell.closest("tr"), expectedRevision = row.dataset.revision;
|
||||||
|
let input;
|
||||||
|
if (cell.dataset.input === "select") {
|
||||||
|
input = document.createElement("select");
|
||||||
|
input.add(new Option("—", ""));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/autocomplete/${cell.dataset.field}/?q=`);
|
||||||
|
if (!response.ok) throw new Error();
|
||||||
|
const data = await response.json();
|
||||||
|
data.results.forEach(item => input.add(new Option(item.label, String(item.id))));
|
||||||
|
// Retain inactive values and values outside any server-side result limit.
|
||||||
|
if (oldValue && ![...input.options].some(option => option.value === oldValue)) {
|
||||||
|
input.add(new Option(cell.textContent.trim(), oldValue));
|
||||||
|
}
|
||||||
|
input.value = oldValue;
|
||||||
|
} catch {
|
||||||
|
cell.classList.remove("editing");
|
||||||
|
toast("Auswahlwerte konnten nicht geladen werden.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (cell.dataset.input === "tristate") {
|
||||||
|
input = document.createElement("select");
|
||||||
|
[["YES", "JA"], ["NO", "NEIN"], ["UNKNOWN", "UNBEKANNT"]].forEach(([value, label]) => input.add(new Option(label, value, false, value === oldValue)));
|
||||||
|
} else {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = cell.dataset.input === "number" ? "number" : "text";
|
||||||
|
if (input.type === "number") input.step = cell.dataset.step || "1";
|
||||||
|
input.value = cell.dataset.input === "date" ? germanDate(oldValue) : oldValue;
|
||||||
|
if (cell.dataset.input === "date") {
|
||||||
|
input.dataset.germanDate = "";
|
||||||
|
input.placeholder = "TT.MM.JJJJ";
|
||||||
|
input.inputMode = "numeric";
|
||||||
|
}
|
||||||
|
if (cell.dataset.field === "cpu") { input.dataset.cpuAutocomplete = ""; input.autocomplete = "off"; }
|
||||||
|
}
|
||||||
|
cell.replaceChildren(input);
|
||||||
|
bindGermanDates(cell);
|
||||||
|
bindCpuAutocomplete(cell);
|
||||||
|
const initialValue = input.value;
|
||||||
|
input.focus();
|
||||||
|
input.select?.();
|
||||||
|
let finished = false;
|
||||||
|
const restore = () => { cell.classList.remove("editing"); cell.innerHTML = original; };
|
||||||
|
const cancel = () => { if (!finished) { finished = true; restore(); } };
|
||||||
|
const nextCell = () => {
|
||||||
|
const cells = [...row.querySelectorAll("td[data-field]")], next = cells[cells.indexOf(cell) + 1];
|
||||||
|
if (next) editCell(next);
|
||||||
|
};
|
||||||
|
const save = async (moveNext = false) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
if (input.value === initialValue) { restore(); if (moveNext) nextCell(); return; }
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/devices/${row.dataset.deviceId}/inline/`, {
|
||||||
|
method: "POST", headers: {"X-CSRFToken": csrftoken, "Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({field: cell.dataset.field, value: input.value, expected_revision: expectedRevision})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || "Speichern fehlgeschlagen");
|
||||||
|
cell.dataset.value = data.value ?? "";
|
||||||
|
row.dataset.updatedAt = data.updated_at;
|
||||||
|
row.dataset.revision = data.revision;
|
||||||
|
cell.classList.remove("editing");
|
||||||
|
displayCell(cell, data.display, data.color);
|
||||||
|
toast("Gespeichert");
|
||||||
|
publishUpdate();
|
||||||
|
if (moveNext) nextCell();
|
||||||
|
} catch (error) { restore(); toast(error.message, "error"); }
|
||||||
|
};
|
||||||
|
input.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Enter") { event.preventDefault(); save(); }
|
||||||
|
else if (event.key === "Escape") { event.preventDefault(); cancel(); }
|
||||||
|
else if (event.key === "Tab") { event.preventDefault(); save(true); }
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", () => setTimeout(() => { if (!finished) save(); }, 220));
|
||||||
|
};
|
||||||
|
let tableBindingController;
|
||||||
|
const readJSON=(value,fallback)=>{try{return value?JSON.parse(value):fallback;}catch{return fallback;}};
|
||||||
|
const savePreferences=payload=>fetch("/preferences/",{method:"POST",headers:{"X-CSRFToken":csrftoken,"Content-Type":"application/json"},body:JSON.stringify(payload)}).catch(()=>{});
|
||||||
|
const reorderTableColumns=(table,order)=>table.querySelectorAll("tr").forEach(row=>{const cells=new Map([...row.children].filter(cell=>cell.dataset.column).map(cell=>[cell.dataset.column,cell]));order.forEach(key=>{const cell=cells.get(key);if(cell)row.append(cell);});});
|
||||||
|
const bindDeviceTable=()=>{
|
||||||
|
tableBindingController?.abort();
|
||||||
|
tableBindingController=new AbortController();
|
||||||
|
const {signal}=tableBindingController;
|
||||||
|
const container=document.querySelector("#device-table-container");
|
||||||
|
container?.querySelectorAll("tr[data-device-id]").forEach(row=>{
|
||||||
|
row.addEventListener("click",event=>{if(event.target.closest("td.editing,input,button,label,a"))return;clearTimeout(clickTimer);clickTimer=setTimeout(()=>{window.location.href=row.dataset.detailUrl;},220);},{signal});
|
||||||
|
row.addEventListener("dblclick",()=>clearTimeout(clickTimer),{signal});
|
||||||
|
});
|
||||||
|
container?.querySelectorAll("td[data-field]").forEach(cell=>cell.addEventListener("dblclick",event=>{event.preventDefault();event.stopPropagation();clearTimeout(clickTimer);editCell(cell);},{signal}));
|
||||||
|
const table=container?.querySelector("[data-device-table]"),panel=document.querySelector("[data-columns-panel]");
|
||||||
|
if(!table||!panel)return;
|
||||||
|
|
||||||
|
const bulkForm=document.querySelector("[data-bulk-form]");
|
||||||
|
if(bulkForm){
|
||||||
|
const boxes=[...table.querySelectorAll("[data-device-select]")],selectAll=table.querySelector("[data-select-all]"),count=bulkForm.querySelector("[data-bulk-count]"),inputs=bulkForm.querySelector("[data-bulk-inputs]"),submit=bulkForm.querySelector("[data-bulk-submit]"),fields=[...bulkForm.querySelectorAll("[data-bulk-field]")];
|
||||||
|
const returnQuery=bulkForm.querySelector('[name="return_query"]');
|
||||||
|
if(returnQuery)returnQuery.value=window.location.search.replace(/^\?/,"");
|
||||||
|
const updateSelection=()=>{
|
||||||
|
const selected=boxes.filter(box=>box.checked);
|
||||||
|
boxes.forEach(box=>box.closest("tr")?.classList.toggle("selected",box.checked));
|
||||||
|
if(selectAll){selectAll.checked=Boolean(boxes.length)&&selected.length===boxes.length;selectAll.indeterminate=selected.length>0&&selected.length<boxes.length;}
|
||||||
|
count.textContent=selected.length;
|
||||||
|
inputs.replaceChildren(...selected.map(box=>{const input=document.createElement("input");input.type="hidden";input.name="device_ids";input.value=box.value;return input;}));
|
||||||
|
bulkForm.hidden=selected.length===0;
|
||||||
|
submit.disabled=selected.length===0||!fields.some(field=>field.value);
|
||||||
|
};
|
||||||
|
boxes.forEach(box=>box.addEventListener("change",updateSelection,{signal}));
|
||||||
|
selectAll?.addEventListener("change",()=>{boxes.forEach(box=>box.checked=selectAll.checked);updateSelection();},{signal});
|
||||||
|
fields.forEach(field=>field.addEventListener("change",updateSelection,{signal}));
|
||||||
|
bulkForm.querySelector("[data-bulk-clear]")?.addEventListener("click",()=>{boxes.forEach(box=>box.checked=false);updateSelection();},{signal});
|
||||||
|
boxes.forEach(box=>box.checked=false);
|
||||||
|
fields.forEach(field=>field.value="");
|
||||||
|
updateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKey=document.body.dataset.userId||"guest";
|
||||||
|
const orderStorageKey=`csr-column-order-${userKey}`;
|
||||||
|
const visibleStorageKey=`csr-visible-columns-${userKey}`;
|
||||||
|
const defaultColumns=[...table.querySelectorAll("thead th[data-column]")].map(th=>({key:th.dataset.column,label:th.textContent.trim()}));
|
||||||
|
const availableKeys=defaultColumns.map(column=>column.key);
|
||||||
|
const requiredPhotoColumns=["recipient","location","price","battery","lan","display_inches","donor","comment"];
|
||||||
|
const serverOrder=readJSON(document.querySelector("#server-column-order")?.textContent,[]);
|
||||||
|
const localOrder=readJSON(localStorage.getItem(orderStorageKey),null);
|
||||||
|
const storedOrder=Array.isArray(localOrder)?localOrder:serverOrder;
|
||||||
|
const orderNeedsUpgrade=!storedOrder?.length||requiredPhotoColumns.some(key=>!storedOrder.includes(key));
|
||||||
|
let order=orderNeedsUpgrade?[...availableKeys]:[...storedOrder.filter(key=>availableKeys.includes(key)),...availableKeys.filter(key=>!storedOrder.includes(key))];
|
||||||
|
if(orderNeedsUpgrade){localStorage.setItem(orderStorageKey,JSON.stringify(order));savePreferences({column_order:order});}
|
||||||
|
reorderTableColumns(table,order);
|
||||||
|
|
||||||
|
const columns=order.map(key=>defaultColumns.find(column=>column.key===key)).filter(Boolean);
|
||||||
|
const serverVisible=readJSON(document.querySelector("#server-visible-columns")?.textContent,[]);
|
||||||
|
const localVisible=readJSON(localStorage.getItem(visibleStorageKey),null);
|
||||||
|
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)));
|
||||||
|
if(!localStorage.getItem(visibilityUpgradeKey)){
|
||||||
|
requiredPhotoColumns.forEach(key=>visible.add(key));
|
||||||
|
localStorage.setItem(visibilityUpgradeKey,"1");
|
||||||
|
const migratedVisible=order.filter(key=>visible.has(key));
|
||||||
|
localStorage.setItem(visibleStorageKey,JSON.stringify(migratedVisible));
|
||||||
|
savePreferences({visible_columns:migratedVisible});
|
||||||
|
}
|
||||||
|
const applyVisibility=()=>columns.forEach(column=>container.querySelectorAll(`[data-column="${column.key}"]`).forEach(element=>element.style.display=visible.has(column.key)?"":"none"));
|
||||||
|
|
||||||
|
panel.replaceChildren();
|
||||||
|
const hint=document.createElement("p");
|
||||||
|
hint.className="column-picker-hint";
|
||||||
|
hint.textContent="Reihenfolge direkt im Tabellenkopf durch Ziehen ändern.";
|
||||||
|
panel.append(hint);
|
||||||
|
columns.forEach(column=>{
|
||||||
|
const label=document.createElement("label"),check=document.createElement("input");
|
||||||
|
label.dataset.column=column.key;
|
||||||
|
check.type="checkbox";
|
||||||
|
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));
|
||||||
|
localStorage.setItem(visibleStorageKey,JSON.stringify(values));
|
||||||
|
savePreferences({visible_columns:values});
|
||||||
|
applyVisibility();
|
||||||
|
},{signal});
|
||||||
|
label.append(check,column.label);
|
||||||
|
panel.append(label);
|
||||||
|
});
|
||||||
|
applyVisibility();
|
||||||
|
document.querySelector("[data-columns-toggle]").onclick=()=>panel.toggleAttribute("hidden");
|
||||||
|
|
||||||
|
const syncPickerOrder=()=>order.forEach(key=>{const label=panel.querySelector(`label[data-column="${key}"]`);if(label)panel.append(label);});
|
||||||
|
const persistOrder=()=>{
|
||||||
|
localStorage.setItem(orderStorageKey,JSON.stringify(order));
|
||||||
|
savePreferences({column_order:order});
|
||||||
|
syncPickerOrder();
|
||||||
|
};
|
||||||
|
const animateOrder=(nextOrder,visualBefore=null)=>{
|
||||||
|
if(!visualBefore&&nextOrder.every((key,index)=>key===order[index]))return;
|
||||||
|
const headers=[...table.querySelectorAll("thead th[data-column]")];
|
||||||
|
const before=visualBefore||new Map(headers.map(header=>[header.dataset.column,header.getBoundingClientRect().left]));
|
||||||
|
headers.forEach(header=>{header.style.transition="none";header.style.transform="";});
|
||||||
|
order=nextOrder;
|
||||||
|
reorderTableColumns(table,order);
|
||||||
|
headers.forEach(header=>{
|
||||||
|
const delta=(before.get(header.dataset.column)??header.getBoundingClientRect().left)-header.getBoundingClientRect().left;
|
||||||
|
if(!delta)return;
|
||||||
|
header.style.transition="none";
|
||||||
|
header.style.transform=`translateX(${delta}px)`;
|
||||||
|
});
|
||||||
|
table.getBoundingClientRect();
|
||||||
|
requestAnimationFrame(()=>headers.forEach(header=>{
|
||||||
|
header.style.transition="transform 180ms cubic-bezier(.2,.8,.2,1)";
|
||||||
|
header.style.transform="";
|
||||||
|
}));
|
||||||
|
setTimeout(()=>headers.forEach(header=>header.style.removeProperty("transition")),210);
|
||||||
|
};
|
||||||
|
|
||||||
|
let drag=null,suppressClick=false;
|
||||||
|
const beginDrag=(event,header)=>{
|
||||||
|
const rect=header.getBoundingClientRect();
|
||||||
|
drag={header,key:header.dataset.column,startX:event.clientX,startY:event.clientY,offsetX:event.clientX-rect.left,active:false,ghost:null,placeholder:null,pendingOrder:[...order],targetToken:""};
|
||||||
|
};
|
||||||
|
const moveDrag=event=>{
|
||||||
|
if(!drag)return;
|
||||||
|
if(!drag.active&&Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)<6)return;
|
||||||
|
if(!drag.active){
|
||||||
|
drag.active=true;
|
||||||
|
suppressClick=true;
|
||||||
|
drag.ghost=document.createElement("div");
|
||||||
|
drag.ghost.className="column-drag-ghost";
|
||||||
|
drag.ghost.textContent=drag.header.textContent.trim();
|
||||||
|
document.body.append(drag.ghost);
|
||||||
|
drag.placeholder=document.createElement("div");
|
||||||
|
drag.placeholder.className="column-drop-placeholder";
|
||||||
|
const sourceRect=drag.header.getBoundingClientRect();
|
||||||
|
const visibleHeaders=order.map(key=>table.querySelector(`thead th[data-column="${key}"]`)).filter(header=>header&&getComputedStyle(header).display!=="none");
|
||||||
|
drag.layout=new Map(visibleHeaders.map(header=>[header.dataset.column,{header,left:header.offsetLeft,width:header.getBoundingClientRect().width}]));
|
||||||
|
drag.baseStart=Math.min(...[...drag.layout.values()].map(item=>item.left));
|
||||||
|
drag.sourceWidth=drag.layout.get(drag.key)?.width||sourceRect.width;
|
||||||
|
drag.placeholder.style.width=`${Math.max(44,drag.sourceWidth-8)}px`;
|
||||||
|
drag.placeholder.style.height=`${Math.max(24,sourceRect.height-8)}px`;
|
||||||
|
table.closest(".table-shell").append(drag.placeholder);
|
||||||
|
document.body.classList.add("column-dragging");
|
||||||
|
table.classList.add("column-reordering");
|
||||||
|
drag.header.classList.add("column-drag-source");
|
||||||
|
drag.header.setAttribute("aria-grabbed","true");
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const ghostX=event.clientX-Math.min(drag.offsetX,Math.max(24,drag.header.getBoundingClientRect().width-24));
|
||||||
|
drag.ghost.style.transform=`translate3d(${ghostX}px,${event.clientY-18}px,0)`;
|
||||||
|
const shell=table.closest(".table-shell"),shellRect=shell.getBoundingClientRect(),edge=52;
|
||||||
|
if(event.clientX<shellRect.left+edge)shell.scrollLeft-=Math.ceil((shellRect.left+edge-event.clientX)/7);
|
||||||
|
else if(event.clientX>shellRect.right-edge)shell.scrollLeft+=Math.ceil((event.clientX-(shellRect.right-edge))/7);
|
||||||
|
const visibleOrder=order.filter(key=>drag.layout.has(key));
|
||||||
|
const otherKeys=visibleOrder.filter(key=>key!==drag.key);
|
||||||
|
const pointerX=event.clientX-shellRect.left+shell.scrollLeft-table.offsetLeft;
|
||||||
|
let cursor=drag.baseStart,beforeKey=null;
|
||||||
|
for(const key of otherKeys){
|
||||||
|
const width=drag.layout.get(key).width;
|
||||||
|
if(pointerX<cursor+width/2){beforeKey=key;break;}
|
||||||
|
cursor+=width;
|
||||||
|
}
|
||||||
|
const nextOrder=order.filter(key=>key!==drag.key);
|
||||||
|
let insertAt;
|
||||||
|
if(beforeKey)insertAt=nextOrder.indexOf(beforeKey);
|
||||||
|
else{
|
||||||
|
const lastVisible=otherKeys.at(-1);
|
||||||
|
insertAt=lastVisible?nextOrder.indexOf(lastVisible)+1:nextOrder.length;
|
||||||
|
}
|
||||||
|
nextOrder.splice(Math.max(0,insertAt),0,drag.key);
|
||||||
|
drag.pendingOrder=nextOrder;
|
||||||
|
const pendingVisible=nextOrder.filter(key=>drag.layout.has(key));
|
||||||
|
const targetLefts=new Map();
|
||||||
|
cursor=drag.baseStart;
|
||||||
|
pendingVisible.forEach(key=>{targetLefts.set(key,cursor);cursor+=drag.layout.get(key).width;});
|
||||||
|
drag.layout.forEach((item,key)=>{
|
||||||
|
const delta=(targetLefts.get(key)??item.left)-item.left;
|
||||||
|
item.header.style.transform=delta?`translateX(${delta}px)`:"";
|
||||||
|
});
|
||||||
|
const targetLeft=targetLefts.get(drag.key)??drag.layout.get(drag.key).left;
|
||||||
|
const headerRect=drag.header.getBoundingClientRect();
|
||||||
|
drag.placeholder.style.left=`${table.offsetLeft+targetLeft+drag.sourceWidth/2}px`;
|
||||||
|
drag.placeholder.style.top=`${headerRect.top-shellRect.top+shell.scrollTop+4}px`;
|
||||||
|
const targetToken=beforeKey||"__end__";
|
||||||
|
if(targetToken!==drag.targetToken){
|
||||||
|
drag.targetToken=targetToken;
|
||||||
|
drag.placeholder.classList.remove("pop");
|
||||||
|
void drag.placeholder.offsetWidth;
|
||||||
|
drag.placeholder.classList.add("pop");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const endDrag=event=>{
|
||||||
|
if(!drag)return;
|
||||||
|
const {header,active,ghost,placeholder,pendingOrder}=drag;
|
||||||
|
drag=null;
|
||||||
|
if(active){
|
||||||
|
const headers=[...table.querySelectorAll("thead th[data-column]")];
|
||||||
|
const visualBefore=new Map(headers.map(item=>[item.dataset.column,item.getBoundingClientRect().left]));
|
||||||
|
ghost?.remove();
|
||||||
|
placeholder?.remove();
|
||||||
|
header.classList.remove("column-drag-source");
|
||||||
|
header.setAttribute("aria-grabbed","false");
|
||||||
|
table.classList.remove("column-reordering");
|
||||||
|
document.body.classList.remove("column-dragging");
|
||||||
|
if(event?.type!=="pointercancel"&&pendingOrder.some((key,index)=>key!==order[index])){
|
||||||
|
animateOrder(pendingOrder,visualBefore);
|
||||||
|
persistOrder();
|
||||||
|
}else animateOrder(order,visualBefore);
|
||||||
|
setTimeout(()=>suppressClick=false,0);
|
||||||
|
requestAnimationFrame(()=>header.focus({preventScroll:true}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
table.addEventListener("click",event=>{if(suppressClick){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;}},{capture:true,signal});
|
||||||
|
window.addEventListener("pointermove",moveDrag,{signal});
|
||||||
|
window.addEventListener("pointerup",endDrag,{signal});
|
||||||
|
window.addEventListener("pointercancel",endDrag,{signal});
|
||||||
|
table.querySelectorAll("thead th[data-column]").forEach(header=>{
|
||||||
|
header.tabIndex=0;
|
||||||
|
header.setAttribute("aria-grabbed","false");
|
||||||
|
header.title=`${header.textContent.trim()} verschieben: ziehen oder Alt + Pfeiltaste verwenden`;
|
||||||
|
header.querySelectorAll("a").forEach(link=>link.draggable=false);
|
||||||
|
header.addEventListener("pointerdown",event=>{const rect=header.getBoundingClientRect();if(event.button!==0||rect.right-event.clientX<10)return;beginDrag(event,header);},{signal});
|
||||||
|
header.addEventListener("keydown",event=>{
|
||||||
|
if(!event.altKey||!["ArrowLeft","ArrowRight"].includes(event.key))return;
|
||||||
|
event.preventDefault();
|
||||||
|
const visibleOrder=order.filter(key=>visible.has(key)),index=visibleOrder.indexOf(header.dataset.column),swapIndex=index+(event.key==="ArrowLeft"?-1:1);
|
||||||
|
if(index<0||swapIndex<0||swapIndex>=visibleOrder.length)return;
|
||||||
|
const nextOrder=[...order],otherKey=visibleOrder[swapIndex],first=nextOrder.indexOf(header.dataset.column),second=nextOrder.indexOf(otherKey);
|
||||||
|
[nextOrder[first],nextOrder[second]]=[nextOrder[second],nextOrder[first]];
|
||||||
|
animateOrder(nextOrder);
|
||||||
|
persistOrder();
|
||||||
|
requestAnimationFrame(()=>header.focus({preventScroll:true}));
|
||||||
|
},{signal});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bindDeviceTable();
|
||||||
|
document.querySelectorAll(".toast-stack .toast").forEach(item=>setTimeout(()=>item.remove(),3500));const initialDevice=document.querySelector("#device-table-container")?.dataset.initialDevice;if(initialDevice){const row=document.querySelector(`tr[data-device-id="${CSS.escape(initialDevice)}"]`);if(row)window.location.href=row.dataset.detailUrl;}
|
||||||
|
})();
|
||||||
39
inventory/static/inventory/js/theme.js
Normal file
39
inventory/static/inventory/js/theme.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
(() => {
|
||||||
|
const key = "csr-theme";
|
||||||
|
const systemTheme = () => window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
const savedTheme = () => {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
return value === "dark" || value === "light" ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const applyTheme = theme => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
document.querySelectorAll("[data-theme-toggle]").forEach(button => {
|
||||||
|
const dark = theme === "dark";
|
||||||
|
button.setAttribute("aria-pressed", String(dark));
|
||||||
|
button.setAttribute("aria-label", dark ? "Hellmodus einschalten" : "Dunkelmodus einschalten");
|
||||||
|
const icon = button.querySelector("[data-theme-icon]");
|
||||||
|
const label = button.querySelector("[data-theme-label]");
|
||||||
|
if (icon) icon.textContent = dark ? "☀" : "☾";
|
||||||
|
if (label) label.textContent = dark ? "Hellmodus" : "Dunkelmodus";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
applyTheme(savedTheme() || systemTheme());
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
applyTheme(document.documentElement.dataset.theme);
|
||||||
|
document.querySelectorAll("[data-theme-toggle]").forEach(button => button.addEventListener("click", () => {
|
||||||
|
const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||||
|
try { localStorage.setItem(key, theme); } catch {}
|
||||||
|
applyTheme(theme);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
window.addEventListener("storage", event => {
|
||||||
|
if (event.key === key) applyTheme(savedTheme() || systemTheme());
|
||||||
|
});
|
||||||
|
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
|
||||||
|
if (!savedTheme()) applyTheme(systemTheme());
|
||||||
|
});
|
||||||
|
})();
|
||||||
1
inventory/templates/403.html
Normal file
1
inventory/templates/403.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Keine Berechtigung{% endblock %}{% block content %}<section class="narrow-page"><div class="form-card"><h1>Keine Berechtigung</h1><p>Ihr Konto darf diese Aktion nicht ausführen. Wenden Sie sich bei Bedarf an einen Administrator.</p><a class="btn primary" href="{% url 'inventory:device-list' %}">Zur Geräteliste</a></div></section>{% endblock %}
|
||||||
1
inventory/templates/404.html
Normal file
1
inventory/templates/404.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Nicht gefunden{% endblock %}{% block content %}<section class="narrow-page"><div class="form-card"><h1>Seite nicht gefunden</h1><p>Die angeforderte Seite oder das Gerät existiert nicht mehr.</p><a class="btn primary" href="{% url 'inventory:device-list' %}">Zur Geräteliste</a></div></section>{% endblock %}
|
||||||
13
inventory/templates/500.html
Normal file
13
inventory/templates/500.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Technischer Fehler · CS-R</title>
|
||||||
|
<link rel="icon" type="image/png" href="{% static 'inventory/img/csr-logo.png' %}">
|
||||||
|
<script src="{% static 'inventory/js/theme.js' %}?v=20260928-3"></script>
|
||||||
|
<style>body{font:16px system-ui;background:#f6f8fc;color:#111827;display:grid;place-items:center;min-height:100vh;margin:0}main{background:white;border:1px solid #e5e7eb;border-radius:10px;padding:28px;max-width:500px}a{display:inline-block;background:#0066ff;color:white;text-decoration:none;padding:9px 14px;border-radius:7px}.theme-toggle{display:block;margin-top:20px;padding:7px 10px;border:1px solid #cbd5e1;border-radius:7px;background:transparent;color:inherit;cursor:pointer}html[data-theme=dark] body{background:#101827;color:#e9eef7}html[data-theme=dark] main{background:#1a2637;border-color:#344154}html[data-theme=dark] .theme-toggle{border-color:#56677f}</style>
|
||||||
|
</head>
|
||||||
|
<body><main><h1>Das hat nicht funktioniert</h1><p>Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es erneut. Falls der Fehler bleibt, informieren Sie einen Administrator.</p><a href="/">Erneut versuchen</a><button class="theme-toggle" type="button" data-theme-toggle aria-pressed="false"><span data-theme-icon aria-hidden="true">☾</span> <span data-theme-label>Dunkelmodus</span></button></main></body>
|
||||||
|
</html>
|
||||||
61
inventory/templates/inventory/_device_table.html
Normal file
61
inventory/templates/inventory/_device_table.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% load inventory_tags %}
|
||||||
|
<div class="table-shell" data-table-total="{{ total_count }}">
|
||||||
|
<table class="device-table" data-device-table>
|
||||||
|
<thead><tr>
|
||||||
|
{% if can_edit_devices %}<th class="selection-column"><input type="checkbox" data-select-all aria-label="Alle sichtbaren Geräte auswählen" title="Alle sichtbaren auswählen"></th>{% endif %}
|
||||||
|
<th data-column="number"><a href="?{% sort_query 'number' %}">Nr.</a></th>
|
||||||
|
<th data-column="shelf"><a href="?{% sort_query 'shelf' %}">Regal</a></th>
|
||||||
|
<th data-column="stage"><a href="?{% sort_query 'stage' %}">Arbeitsschritt</a></th>
|
||||||
|
<th data-column="status"><a href="?{% sort_query 'status' %}">Status</a></th>
|
||||||
|
<th data-column="date"><a href="?{% sort_query 'date' %}">Datum</a></th>
|
||||||
|
<th data-column="recipient"><a href="?{% sort_query 'recipient' %}">Empfänger</a></th>
|
||||||
|
<th data-column="location">Ort</th>
|
||||||
|
<th data-column="ticket"><a href="?{% sort_query 'ticket' %}">Ticket</a></th>
|
||||||
|
<th data-column="device_type"><a href="?{% sort_query 'device_type' %}">Typ</a></th>
|
||||||
|
<th data-column="price"><a href="?{% sort_query 'price' %}">Preis</a></th>
|
||||||
|
{% if "battery" in visible_hardware_fields %} <th data-column="battery">Akku</th>{% endif %}
|
||||||
|
{% if "wifi" in visible_hardware_fields %} <th data-column="wifi">WLAN</th>{% endif %}
|
||||||
|
{% if "lan" in visible_hardware_fields %} <th data-column="lan">LAN</th>{% endif %}
|
||||||
|
{% if "display_inches" in visible_hardware_fields %} <th data-column="display_inches"><a href="?{% sort_query 'display_inches' %}">Zoll</a></th>{% endif %}
|
||||||
|
{% if "ram_gb" in visible_hardware_fields %} <th data-column="ram_gb"><a href="?{% sort_query 'ram_gb' %}">RAM</a></th>{% endif %}
|
||||||
|
{% 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 %}
|
||||||
|
<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>
|
||||||
|
<th data-column="comment">Kommentar</th>
|
||||||
|
{% for field in custom_columns %}<th data-column="custom_{{ field.key }}">{{ field.name }}</th>{% endfor %}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for device in page_obj %}
|
||||||
|
<tr data-device-id="{{ device.pk }}" data-revision="{{ device.revision }}" data-updated-at="{{ device.updated_at.isoformat }}" data-detail-url="{% url 'inventory:device-detail' device.pk %}{% if navigation_query %}?{{ navigation_query }}{% endif %}">
|
||||||
|
{% if can_edit_devices %}<td class="selection-column"><input type="checkbox" value="{{ device.pk }}" data-device-select aria-label="Gerät {{ device.number }} auswählen"></td>{% endif %}
|
||||||
|
<td data-column="number" class="mono strong"><a class="device-link" href="{% url 'inventory:device-detail' device.pk %}{% if navigation_query %}?{{ navigation_query }}{% endif %}">{{ device.number }}</a></td>
|
||||||
|
<td data-column="shelf" data-field="shelf" data-value="{{ device.shelf }}">{{ device.shelf|default:'—' }}</td>
|
||||||
|
<td data-column="stage">{{ device.get_stage_display }}</td>
|
||||||
|
<td data-column="status" data-field="status" data-input="select" data-value="{{ device.status_id }}"><span class="status status-colored" style="--status-color:{{ device.status.color|default:'#64748b' }}">{{ device.status.label }}</span></td>
|
||||||
|
<td data-column="date" data-field="date" data-input="date" data-value="{{ device.date|date:'Y-m-d' }}">{{ device.date|date:'d.m.Y' }}</td>
|
||||||
|
<td data-column="recipient" data-field="recipient" data-input="select" data-value="{{ device.recipient_id|default:'' }}">{{ device.recipient.label|default:'—' }}</td>
|
||||||
|
<td data-column="location" data-field="location" data-input="select" data-value="{{ device.location_id|default:'' }}">{{ device.location.label|default:'—' }}</td>
|
||||||
|
<td data-column="ticket" data-field="ticket" data-value="{{ device.ticket }}">{{ device.ticket|default:'—' }}</td>
|
||||||
|
<td data-column="device_type" data-field="device_type" data-input="select" data-value="{{ device.device_type_id|default:'' }}">{{ device.device_type.label|default:'—' }}</td>
|
||||||
|
<td data-column="price" data-field="price" data-input="number" data-step="0.01" data-value="{{ device.price|default:'' }}">{% if device.price != None %}{{ device.price|floatformat:2 }} €{% else %}—{% endif %}</td>
|
||||||
|
{% if "battery" in visible_hardware_fields %} {% if "battery" in device.hardware_fields %}<td data-column="battery" data-field="battery" data-input="tristate" data-value="{{ device.battery }}">{{ device.get_battery_display }}</td>{% else %}<td data-column="battery">—</td>{% endif %}{% endif %}
|
||||||
|
{% if "wifi" in visible_hardware_fields %} {% if "wifi" in device.hardware_fields %}<td data-column="wifi" data-field="wifi" data-input="tristate" data-value="{{ device.wifi }}">{{ device.get_wifi_display }}</td>{% else %}<td data-column="wifi">—</td>{% endif %}{% endif %}
|
||||||
|
{% if "lan" in visible_hardware_fields %} {% if "lan" in device.hardware_fields %}<td data-column="lan" data-field="lan" data-input="tristate" data-value="{{ device.lan }}">{{ device.get_lan_display }}</td>{% else %}<td data-column="lan">—</td>{% endif %}{% endif %}
|
||||||
|
{% if "display_inches" in visible_hardware_fields %} {% if "display_inches" in device.hardware_fields %}<td data-column="display_inches" data-field="display_inches" data-input="number" data-step="0.1" data-value="{{ device.display_inches|default:'' }}">{% if device.display_inches != None %}{{ device.display_inches|floatformat:-1 }} Zoll{% else %}—{% endif %}</td>{% else %}<td data-column="display_inches">—</td>{% endif %}{% endif %}
|
||||||
|
{% if "ram_gb" in visible_hardware_fields %} {% if "ram_gb" in device.hardware_fields %}<td data-column="ram_gb" data-field="ram_gb" data-input="number" data-value="{{ device.ram_gb|default:'' }}">{% if device.ram_gb %}{{ device.ram_gb }} GB{% else %}—{% endif %}</td>{% else %}<td data-column="ram_gb">—</td>{% endif %}{% endif %}
|
||||||
|
{% 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 %}
|
||||||
|
<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 %}
|
||||||
|
</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>
|
||||||
|
</table></div>
|
||||||
|
<div class="pagination"><span>Seite {{ page_obj.number }} von {{ page_obj.paginator.num_pages }}</span><div>{% if page_obj.has_previous %}<a class="btn" href="?page={{ page_obj.previous_page_number }}&{{ query_string }}">← Zurück</a>{% endif %}{% if page_obj.has_next %}<a class="btn" href="?page={{ page_obj.next_page_number }}&{{ query_string }}">Weiter →</a>{% endif %}</div></div>
|
||||||
1
inventory/templates/inventory/archive_list.html
Normal file
1
inventory/templates/inventory/archive_list.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Archiv{% endblock %}{% block content %}<section class="page-head"><div><h1>Archiv</h1><p>{{ total_count }} archivierte Geräte</p></div></section><div class="table-shell"><table class="device-table"><thead><tr><th>Nr.</th><th>Status</th><th>Typ</th><th>Modell</th><th>Archiviert</th><th></th></tr></thead><tbody>{% for device in page_obj %}<tr><td class="mono strong">{{ device.number }}</td><td><span class="status status-colored" style="--status-color:{{ device.status.color|default:'#64748b' }}">{{ device.status }}</span></td><td>{{ device.device_type|default:'—' }}</td><td>{{ device.model|default:'—' }}</td><td>{{ device.archived_at|date:'d.m.Y H:i' }}</td><td><form method="post" action="{% url 'inventory:restore-device' device.pk %}">{% csrf_token %}<button class="btn small">Wiederherstellen</button></form></td></tr>{% empty %}<tr><td colspan="6" class="empty">Das Archiv ist leer.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
|
||||||
5
inventory/templates/inventory/audit_list.html
Normal file
5
inventory/templates/inventory/audit_list.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Änderungshistorie{% endblock %}{% block content %}
|
||||||
|
<section class="page-head"><div><h1>Änderungshistorie</h1><p>Nachvollziehbare Änderungen an allen Geräten</p></div></section>
|
||||||
|
<form class="toolbar compact" method="get"><div class="search"><span>⌕</span><input name="q" value="{{ request.GET.q }}" placeholder="Änderungen durchsuchen …"></div></form>
|
||||||
|
<div class="history-list">{% for log in page_obj %}<article><div class="history-icon">{% if log.action == 'CREATE' %}+{% elif log.action == 'ARCHIVE' %}□{% elif log.action == 'RESTORE' %}↺{% else %}↻{% endif %}</div><div class="history-content"><header><strong>Gerät #{{ log.device.number }}</strong><span>{{ log.created_at|date:'d.m.Y H:i' }}</span></header><p><b>{{ log.field|default:log.get_action_display }}</b>{% if log.old_value or log.new_value %}: <del>{{ log.old_value|default:'leer' }}</del> → <ins>{{ log.new_value|default:'leer' }}</ins>{% endif %}</p><footer><small>{{ log.user.get_full_name|default:log.user.username|default:'System' }}{% if log.api_client %} · API: {{ log.api_client }}{% endif %}</small>{% if log.reverted_at %}<span class="reverted-badge">Zurückgesetzt am {{ log.reverted_at|date:'d.m.Y H:i' }}</span>{% elif can_revert_audits and log.action == 'UPDATE' %}<form method="post" action="{% url 'inventory:audit-revert' log.pk %}" data-confirm="Soll diese einzelne Änderung wirklich auf den vorherigen Wert zurückgesetzt werden?" data-confirm-action="Änderung zurücksetzen">{% csrf_token %}<button class="btn small">↶ Zurücksetzen</button></form>{% endif %}</footer></div></article>{% empty %}<div class="empty">Noch keine Änderungen protokolliert.</div>{% endfor %}</div>
|
||||||
|
{% endblock %}
|
||||||
49
inventory/templates/inventory/base.html
Normal file
49
inventory/templates/inventory/base.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Interne CSR Geräteverwaltung">
|
||||||
|
<title>{% block title %}Geräteverwaltung{% endblock %} · CS-R</title>
|
||||||
|
<link rel="icon" type="image/png" href="{% static 'inventory/img/csr-logo.png' %}">
|
||||||
|
<script src="{% static 'inventory/js/theme.js' %}?v=20260928-3"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'inventory/css/app.css' %}?v=20260928-3">
|
||||||
|
<script defer src="{% static 'inventory/js/app.js' %}?v=20260928-2"></script>
|
||||||
|
</head>
|
||||||
|
<body class="{% block body_class %}{% endblock %}" data-csrf="{{ csrf_token }}" data-user-id="{{ request.user.pk|default:'' }}">
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<a class="brand" href="{% url 'inventory:overview' %}">
|
||||||
|
<img src="{% static 'inventory/img/csr-logo.png' %}" alt="CS-R">
|
||||||
|
<span><strong>CS-R</strong><small>Geräteverwaltung</small></span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Hauptnavigation">
|
||||||
|
<a href="{% url 'inventory:overview' %}" class="{% if request.resolver_match.url_name == 'overview' %}active{% endif %}"><span>◫</span> Übersicht</a>
|
||||||
|
<a href="{% url 'inventory:device-list' %}" class="{% if request.resolver_match.url_name == 'device-list' %}active{% endif %}"><span>▦</span> Geräte</a>
|
||||||
|
<a href="{% url 'inventory:workflow-board' %}" class="{% if request.resolver_match.url_name == 'workflow-board' %}active{% endif %}"><span>?</span> Ablauf</a>
|
||||||
|
<a href="{% url 'inventory:task-list' %}" class="{% if request.resolver_match.url_name == 'task-list' %}active{% endif %}"><span>☑</span> Aufgaben</a>
|
||||||
|
{% if current_role != 'VIEWER' %}<a href="{% url 'inventory:quick-add' %}" class="{% if request.resolver_match.url_name == 'quick-add' %}active{% endif %}"><span>+</span> Neues Gerät</a>{% endif %}
|
||||||
|
<a href="{% url 'inventory:audit-list' %}" class="{% if request.resolver_match.url_name == 'audit-list' %}active{% endif %}"><span>◷</span> Änderungshistorie</a>
|
||||||
|
{% if current_role == 'ADMIN' %}
|
||||||
|
<div class="nav-label">Verwaltung</div>
|
||||||
|
<a href="{% url 'inventory:configuration' %}" class="{% if 'configuration' in request.path %}active{% endif %}"><span>⚙</span> Werte & Felder</a>
|
||||||
|
<a href="{% url 'inventory:import-excel' %}" class="{% if 'import' in request.path %}active{% endif %}"><span>⇅</span> Import / Export</a>
|
||||||
|
<a href="{% url 'inventory:archive-list' %}" class="{% if request.resolver_match.url_name == 'archive-list' %}active{% endif %}"><span>□</span> Archiv</a>
|
||||||
|
<a href="{% url 'inventory:user-list' %}" class="{% if 'users' in request.path %}active{% endif %}"><span>♙</span> Benutzer</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
<button class="theme-toggle sidebar-theme" type="button" data-theme-toggle aria-pressed="false"><span data-theme-icon aria-hidden="true">☾</span><span data-theme-label>Dunkelmodus</span></button>
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<span class="avatar">{{ request.user.username|slice:':1'|upper }}</span>
|
||||||
|
<span><strong>{{ request.user.get_full_name|default:request.user.username }}</strong><small>{{ current_role_label }}</small></span>
|
||||||
|
<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button title="Abmelden" aria-label="Abmelden">↪</button></form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main">
|
||||||
|
<header class="mobile-header"><button data-sidebar-toggle aria-label="Navigation öffnen">☰</button><strong>CS-R Geräte</strong><button class="theme-toggle mobile-theme" type="button" data-theme-toggle aria-pressed="false"><span data-theme-icon aria-hidden="true">☾</span><span class="sr-only" data-theme-label>Dunkelmodus</span></button></header>
|
||||||
|
{% if messages %}<div class="toast-stack" aria-live="polite">{% for message in messages %}<div class="toast {{ message.tags }}">{{ message }}</div>{% endfor %}</div>{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<div id="toast-root" class="toast-stack" aria-live="polite"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
inventory/templates/inventory/configuration.html
Normal file
21
inventory/templates/inventory/configuration.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'inventory/base.html' %}
|
||||||
|
{% block title %}Werte & Felder{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-head"><div><h1>Werte & Felder</h1><p>Bearbeiter, Auswahllisten und Zusatzfelder verwalten</p></div></section>
|
||||||
|
<div class="config-grid config-grid-three">
|
||||||
|
<section class="panel">
|
||||||
|
<header><div><h2>Bearbeiter</h2><p>Namen und Kürzel für die Geräteerfassung.</p></div><a class="btn primary small" href="{% url 'inventory:employee-new' %}">+ Bearbeiter</a></header>
|
||||||
|
<div class="config-list employee-list">
|
||||||
|
{% for employee in employees %}<div class="config-row"><a href="{% url 'inventory:employee-edit' employee.pk %}" class="config-item-main"><span><strong>{{ employee.name }}</strong><small>{{ employee.code }}</small></span><em class="{% if employee.active %}active{% endif %}">{% if employee.active %}Aktiv{% else %}Inaktiv{% endif %}</em></a><form method="post" action="{% url 'inventory:employee-remove' employee.pk %}" data-confirm="Bearbeiter {{ employee.name }} wirklich aus der Auswahl entfernen?" data-confirm-action="Entfernen">{% csrf_token %}<button class="btn small danger" aria-label="{{ employee.name }} entfernen">Entfernen</button></form></div>{% empty %}<p class="empty">Noch keine Bearbeiter vorhanden.</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<header><div><h2>Auswahlwerte</h2><p>Deaktivieren statt löschen – bestehende Verknüpfungen bleiben erhalten.</p></div><a class="btn primary small" href="{% url 'inventory:choice-new' %}">+ Wert</a></header>
|
||||||
|
<div class="config-list">{% regroup choices by get_kind_display as grouped %}{% for group in grouped %}<h3>{{ group.grouper }}</h3>{% for item in group.list %}<a href="{% url 'inventory:choice-edit' item.pk %}"><span class="config-value-label">{% if item.kind == 'status' %}<i class="status-color-dot" style="--status-color:{{ item.color|default:'#64748b' }}"></i>{% endif %}{{ item.label }}{% if item.numeric_value != None %} · {{ item.numeric_value }}{% endif %}</span><em class="{% if item.active %}active{% endif %}">{% if item.active %}Aktiv{% else %}Inaktiv{% endif %}</em></a>{% endfor %}{% endfor %}</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<header><div><h2>Benutzerdefinierte Felder</h2><p>Neue Felder ohne Datenbankmigration hinzufügen.</p></div><a class="btn primary small" href="{% url 'inventory:field-new' %}">+ Feld</a></header>
|
||||||
|
<div class="config-list">{% for field in definitions %}<a href="{% url 'inventory:field-edit' field.pk %}"><span><strong>{{ field.name }}</strong><small>{{ field.get_field_type_display }} · {{ field.key }}</small></span><em class="{% if field.active %}active{% endif %}">{% if field.active %}Aktiv{% else %}Inaktiv{% endif %}</em></a>{% empty %}<p class="empty">Noch keine Zusatzfelder.</p>{% endfor %}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
37
inventory/templates/inventory/device_detail.html
Normal file
37
inventory/templates/inventory/device_detail.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% load inventory_tags %}{% 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>
|
||||||
|
<p>Eingang → Inspektion → bei Bedarf Reparatur → Lagerung → Ausgabe</p>
|
||||||
|
{% if can_edit_devices and not device.archived_at and next_stages %}
|
||||||
|
<form method="post" action="{% url 'inventory:device-advance' device.pk %}" class="app-form">
|
||||||
|
{% csrf_token %}<input type="hidden" name="expected_revision" value="{{ device.revision }}">
|
||||||
|
<label>Nächster Schritt<select name="stage" required>{% for value, label in next_stages %}<option value="{{ value }}">{{ label }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Notiz<input name="note" maxlength="500"></label><button class="btn primary">Schritt übernehmen</button>
|
||||||
|
</form>{% endif %}
|
||||||
|
<div class="mini-history">{% for event in workflow_events %}<div><span>{{ event.created_at|date:'d.m.Y H:i' }}</span><strong>{{ event.get_from_stage_display }} → {{ event.get_to_stage_display }}</strong><small>{{ event.note }}{% if event.user %} · {{ event.user.username }}{% endif %}</small></div>{% empty %}<p>Noch keine Schritte erfasst.</p>{% endfor %}</div>
|
||||||
|
{% if inspections %}<h3>Inspektionsbefunde</h3><div class="mini-history">{% for record in inspections %}<div><span>{{ record.created_at|date:'d.m.Y H:i' }}</span><strong>{{ record.defects|linebreaksbr }}</strong><small>{% if record.recorded_by %}{{ record.recorded_by.username }}{% endif %}</small></div>{% endfor %}</div>{% endif %}
|
||||||
|
{% if repairs %}<h3>Reparaturen</h3><div class="mini-history">{% for record in repairs %}<div><span>{{ record.created_at|date:'d.m.Y H:i' }}</span><strong>{{ record.work_done|linebreaksbr }}</strong><small>{% if record.recorded_by %}{{ record.recorded_by.username }}{% endif %}</small></div>{% endfor %}</div>{% endif %}
|
||||||
|
</section>
|
||||||
|
<section><h2>Aufgaben</h2>
|
||||||
|
{% for task in tasks %}<div class="form-card"><strong>{{ task.title }}</strong> · {{ task.get_state_display }}{% if task.due_date %} · fällig {{ task.due_date|date:'d.m.Y' }}{% endif %}{% if task.assigned_to %} · {{ task.assigned_to.username }}{% endif %}{% if task.description %}<p>{{ task.description|linebreaksbr }}</p>{% endif %}
|
||||||
|
{% if can_edit_devices and task.state == 'OPEN' %}<form method="post" action="{% url 'inventory:device-task-complete' device.pk task.pk %}">{% csrf_token %}<button class="btn">Erledigt</button></form>{% endif %}</div>{% empty %}<p>Keine Aufgaben vorhanden.</p>{% endfor %}
|
||||||
|
{% if can_edit_devices and not device.archived_at and device.stage != 'ISSUED' %}<form method="post" action="{% url 'inventory:device-task-add' device.pk %}" class="app-form">
|
||||||
|
{% csrf_token %}<label>Neue Aufgabe<input name="title" maxlength="200" required></label>
|
||||||
|
<label>Beschreibung<textarea name="description" rows="2"></textarea></label>
|
||||||
|
<label>Zuständig<select name="assigned_to"><option value="">Noch offen</option>{% for assignee in task_assignees %}<option value="{{ assignee.pk }}">{{ assignee.username }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Fällig am<input name="due_date" type="date"></label><button class="btn primary">Aufgabe anlegen</button>
|
||||||
|
</form>{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="detail-form" data-device-type-form data-readonly="{{ can_edit_devices|yesno:'false,true' }}">{% csrf_token %}<input type="hidden" name="expected_revision" value="{{ device.revision }}">{{ form.non_field_errors }}
|
||||||
|
<datalist id="suggestions-ram_gb">{% for item in ram_suggestions %}<option value="{{ item.numeric_value|floatformat:'-2' }}">{% endfor %}</datalist><datalist id="suggestions-ssd_gb">{% for item in ssd_suggestions %}<option value="{{ item.numeric_value|floatformat:'-2' }}">{% endfor %}</datalist><datalist id="suggestions-hdd_gb">{% for item in hdd_suggestions %}<option value="{{ item.numeric_value|floatformat:'-2' }}">{% endfor %}</datalist><datalist id="suggestions-display_inches">{% for item in display_suggestions %}<option value="{{ item.numeric_value|floatformat:'-2' }}">{% endfor %}</datalist>
|
||||||
|
<section><h2>Allgemein</h2><div class="form-grid">{% for field in form %}{% if field.name == 'shelf' or field.name == 'status' or field.name == 'date' or field.name == 'device_type' or field.name == 'model' %}<label>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</div></section>
|
||||||
|
<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 %}
|
||||||
|
<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>
|
||||||
|
{{ type_field_map|json_script:'device-type-fields' }}
|
||||||
|
</section>{% endblock %}
|
||||||
113
inventory/templates/inventory/device_list.html
Normal file
113
inventory/templates/inventory/device_list.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{% extends 'inventory/base.html' %}
|
||||||
|
{% load inventory_tags %}
|
||||||
|
{% block body_class %}device-list-page{% endblock %}
|
||||||
|
{% block title %}Geräte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-head" data-live-refresh>
|
||||||
|
<div><h1>Geräte</h1><p><span data-total-count>{{ total_count }}</span> Einträge</p></div>
|
||||||
|
<div class="actions">
|
||||||
|
<details class="menu export-menu"><summary class="btn">⇩ Exportieren</summary><div>
|
||||||
|
<strong>Aktuelle Auswahl</strong><small>{{ total_count }} gefundene Geräte</small>
|
||||||
|
<a href="{% url 'inventory:export-devices' 'csv' %}?scope=filtered{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">Als CSV exportieren</a>
|
||||||
|
<a href="{% url 'inventory:export-devices' 'xlsx' %}?scope=filtered{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">Als XLSX exportieren</a>
|
||||||
|
<a href="{% url 'inventory:export-devices' 'ods' %}?scope=filtered{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">Als ODS für LibreOffice</a>
|
||||||
|
<hr><a href="{% url 'inventory:export-devices' 'xlsx' %}">Alle Geräte als XLSX</a><a href="{% url 'inventory:export-devices' 'ods' %}">Alle Geräte als ODS</a>
|
||||||
|
</div></details>
|
||||||
|
{% if can_edit_devices %}<a class="btn primary" href="{% url 'inventory:quick-add' %}">+ Neues Gerät</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav aria-label="Gerätetypen" class="form-actions"><a class="btn" href="{% url 'inventory:device-list' %}">Alle Typen</a>{% for option in types %}<a class="btn" href="?type={{ option.pk }}">{{ option.label }}</a>{% endfor %}</nav>
|
||||||
|
<form class="toolbar" method="get" id="filter-form">
|
||||||
|
<div class="search"><span>⌕</span><input name="q" value="{{ request.GET.q }}" placeholder="Gerätenummer suchen …" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Nach Gerätenummer suchen" data-search-input></div>
|
||||||
|
<button type="button" class="btn filter-toggle {% if has_filters %}active{% endif %}" data-filter-toggle aria-expanded="{% if has_filters %}true{% else %}false{% endif %}">☷ Filter <span class="filter-count" {% if not active_filter_count %}hidden{% endif %}>{{ active_filter_count }}</span></button>
|
||||||
|
<label class="page-size">Zeilen <select name="page_size" data-auto-submit><option {% if page_size == 25 %}selected{% endif %}>25</option><option {% if page_size == 50 %}selected{% endif %}>50</option><option {% if page_size == 100 %}selected{% endif %}>100</option></select></label>
|
||||||
|
<button type="button" class="btn icon" data-columns-toggle title="Spalten auswählen" aria-label="Spalten auswählen">☷</button>
|
||||||
|
<div class="column-picker" hidden data-columns-panel></div>
|
||||||
|
|
||||||
|
<div class="filter-panel" {% if not has_filters %}hidden{% endif %} data-filter-panel>
|
||||||
|
<header class="filter-panel-head"><div><h2>Geräte filtern</h2><p>Mehrere Angaben lassen sich miteinander kombinieren.</p></div><button type="button" class="icon-close" data-filter-close aria-label="Filter schließen">×</button></header>
|
||||||
|
<div class="filter-panel-body">
|
||||||
|
<section class="filter-section">
|
||||||
|
<header><h3>Einordnung</h3><p>Status, Geräteart und Zuständigkeit</p></header>
|
||||||
|
<div class="filter-grid filter-grid-four">
|
||||||
|
<label>Arbeitsschritt<select name="stage"><option value="">Alle</option>{% for value, label in stages %}<option value="{{ value }}" {% if request.GET.stage == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||||
|
<details class="multi-filter" data-multi-filter><summary><span>Status</span><b data-multi-value>Alle</b></summary><div>{% for option in statuses %}<label><input type="checkbox" name="status" value="{{ option.pk }}" {% option_checked request 'status' option.pk %}><span>{{ option.label }}</span></label>{% endfor %}</div></details>
|
||||||
|
<details class="multi-filter" data-multi-filter><summary><span>Typ</span><b data-multi-value>Alle</b></summary><div>{% for option in types %}<label><input type="checkbox" name="type" value="{{ option.pk }}" {% option_checked request 'type' option.pk %}><span>{{ option.label }}</span></label>{% endfor %}</div></details>
|
||||||
|
<details class="multi-filter" data-multi-filter><summary><span>Bearbeiter</span><b data-multi-value>Alle</b></summary><div>{% for option in employees %}<label><input type="checkbox" name="employee" value="{{ option.pk }}" {% option_checked request 'employee' option.pk %}><span>{{ option.code }} – {{ option.name }}</span></label>{% endfor %}</div></details>
|
||||||
|
<details class="multi-filter" data-multi-filter><summary><span>Empfänger</span><b data-multi-value>Alle</b></summary><div>{% for option in recipients %}<label><input type="checkbox" name="recipient" value="{{ option.pk }}" {% option_checked request 'recipient' option.pk %}><span>{{ option.label }}</span></label>{% endfor %}</div></details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="filter-section">
|
||||||
|
<header><h3>Zeitraum und Preis</h3><p>Eingangsdatum und erfasster Gerätepreis</p></header>
|
||||||
|
<div class="filter-grid filter-grid-four">
|
||||||
|
<label>Datum von<input type="text" name="date_from" value="{{ request.GET.date_from }}" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date></label>
|
||||||
|
<label>Datum bis<input type="text" name="date_to" value="{{ request.GET.date_to }}" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date></label>
|
||||||
|
<label>Preis von<input type="number" step="0.01" min="0" name="price_min" value="{{ request.GET.price_min }}" placeholder="0,00 €"></label>
|
||||||
|
<label>Preis bis<input type="number" step="0.01" min="0" name="price_max" value="{{ request.GET.price_max }}" placeholder="0,00 €"></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="filter-section">
|
||||||
|
<header><h3>Technische Daten</h3><p>Speicher und Bildschirmgröße eingrenzen</p></header>
|
||||||
|
<div class="filter-grid filter-grid-hardware">
|
||||||
|
<fieldset><legend>RAM in GB</legend><div><label>Genau<input type="number" min="0" name="ram_exact" value="{{ request.GET.ram_exact }}"></label><label>Mindestens<input type="number" min="0" name="ram_min" value="{{ request.GET.ram_min }}"></label><label>Höchstens<input type="number" min="0" name="ram_max" value="{{ request.GET.ram_max }}"></label></div></fieldset>
|
||||||
|
<fieldset><legend>SSD in GB</legend><div><label>Genau<input type="number" min="0" name="ssd_exact" value="{{ request.GET.ssd_exact }}"></label><label>Mindestens<input type="number" min="0" name="ssd_min" value="{{ request.GET.ssd_min }}"></label><label>Höchstens<input type="number" min="0" name="ssd_max" value="{{ request.GET.ssd_max }}"></label></div></fieldset>
|
||||||
|
<fieldset><legend>HDD in GB</legend><div><label>Genau<input type="number" min="0" name="hdd_exact" value="{{ request.GET.hdd_exact }}"></label><label>Mindestens<input type="number" min="0" name="hdd_min" value="{{ request.GET.hdd_min }}"></label><label>Höchstens<input type="number" min="0" name="hdd_max" value="{{ request.GET.hdd_max }}"></label></div></fieldset>
|
||||||
|
<fieldset><legend>Bildschirm in Zoll</legend><div><label>Mindestens<input type="number" step="0.1" min="0" name="display_min" value="{{ request.GET.display_min }}"></label><label>Höchstens<input type="number" step="0.1" min="0" name="display_max" value="{{ request.GET.display_max }}"></label></div></fieldset>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="filter-section">
|
||||||
|
<header><h3>Prozessor</h3><p>Nach Hersteller, Leistungsklasse oder konkretem Modell filtern</p></header>
|
||||||
|
<div class="filter-grid cpu-filter-grid">
|
||||||
|
<details class="multi-filter" data-multi-filter><summary><span>Hersteller</span><b data-multi-value>Alle</b></summary><div>{% for value, label in cpu_vendors %}<label><input type="checkbox" name="cpu_vendor" value="{{ value }}" {% option_checked request 'cpu_vendor' value %}><span>{{ label }}</span></label>{% endfor %}</div></details>
|
||||||
|
<details class="multi-filter" data-multi-filter><summary><span>CPU-Klasse</span><b data-multi-value>Alle</b></summary><div>{% for value, label in cpu_families %}<label><input type="checkbox" name="cpu_family" value="{{ value }}" {% option_checked request 'cpu_family' value %}><span>{{ label }}</span></label>{% endfor %}</div></details>
|
||||||
|
<label class="cpu-search-field">CPU-Modell<input name="cpu_contains" value="{{ request.GET.cpu_contains }}" placeholder="z. B. i5 8500 oder Ryzen 5" autocomplete="off" data-cpu-autocomplete></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="filter-section">
|
||||||
|
<header><h3>Ausstattung und Stichwörter</h3><p>Verbindungen und Textangaben durchsuchen</p></header>
|
||||||
|
<div class="filter-grid filter-grid-six">
|
||||||
|
<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>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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if custom_filters %}<section class="filter-section"><header><h3>Zusatzfelder</h3></header><div class="filter-grid filter-grid-four">{% for field in custom_filters %}{% with param='custom_'|add:field.key %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="{{ param }}"><option value="">Alle</option>{% for option in field.options.all %}{% if option.active %}<option value="{{ option.label }}" {% option_selected request param option.label %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% else %}<input name="{{ param }}" value="{{ request.GET|get_item:param }}">{% endif %}</label>{% endwith %}{% endfor %}</div></section>{% endif %}
|
||||||
|
</div>
|
||||||
|
<footer class="filter-actions"><span>Die aktuelle Filterung wird auch für den Export verwendet.</span><a class="btn" href="{% url 'inventory:device-list' %}">Alles zurücksetzen</a><button class="btn primary">Filter anwenden</button></footer>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if can_edit_devices %}
|
||||||
|
<form class="bulk-bar" method="post" action="{% url 'inventory:bulk-update-devices' %}" data-bulk-form hidden data-confirm="Die ausgewählten Geräte wirklich gemeinsam ändern?" data-confirm-action="Änderungen übernehmen">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="return_query" value="{{ navigation_query }}">
|
||||||
|
<strong><span data-bulk-count>0</span> ausgewählt</strong>
|
||||||
|
<label>Status
|
||||||
|
<select name="status" data-bulk-field>
|
||||||
|
<option value="">Nicht ändern</option>
|
||||||
|
{% for option in statuses %}<option value="{{ option.pk }}">{{ option.label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Empfänger
|
||||||
|
<select name="recipient" data-bulk-field>
|
||||||
|
<option value="">Nicht ändern</option>
|
||||||
|
<option value="__clear__">Empfänger entfernen</option>
|
||||||
|
{% for option in recipients %}<option value="{{ option.pk }}">{{ option.label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span data-bulk-inputs></span>
|
||||||
|
<button type="button" class="btn small" data-bulk-clear>Auswahl aufheben</button>
|
||||||
|
<button class="btn primary small" data-bulk-submit disabled>Änderungen übernehmen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div id="device-table-container" data-initial-device="{{ request.GET.open|default:'' }}">{% include 'inventory/_device_table.html' %}</div>
|
||||||
|
{{ preference.visible_columns|json_script:"server-visible-columns" }}
|
||||||
|
{{ preference.column_order|json_script:"server-column-order" }}
|
||||||
|
{% endblock %}
|
||||||
20
inventory/templates/inventory/import_map.html
Normal file
20
inventory/templates/inventory/import_map.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends 'inventory/base.html' %}
|
||||||
|
{% load inventory_tags %}
|
||||||
|
{% block title %}Spalten zuordnen{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-head">
|
||||||
|
<div><a class="back" href="{% url 'inventory:import-excel' %}">← Andere Datei</a><h1>Spalten zuordnen</h1><p>{{ job.original_name }}</p></div>
|
||||||
|
</section>
|
||||||
|
<form method="post">{% csrf_token %}
|
||||||
|
<div class="mapping-grid">
|
||||||
|
{% for header in job.headers %}
|
||||||
|
<label><span>{{ header|default:'Unbenannte Spalte' }}</span><select name="column_{{ forloop.counter0 }}">
|
||||||
|
{% for value,label in fields %}<option value="{{ value }}" {% if suggestions|get_item:forloop.parentloop.counter0 == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<h2 class="section-title">Vorschau</h2>
|
||||||
|
<div class="table-shell"><table class="device-table"><thead><tr>{% for header in job.headers %}<th>{{ header }}</th>{% endfor %}</tr></thead><tbody>{% for row in job.preview_rows %}<tr>{% for cell in row %}<td>{{ cell|default:'—' }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div>
|
||||||
|
<div class="sticky-actions"><a class="btn" href="{% url 'inventory:import-excel' %}">Abbrechen</a><button class="btn primary">Import bestätigen</button></div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
1
inventory/templates/inventory/import_upload.html
Normal file
1
inventory/templates/inventory/import_upload.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Import / Export{% endblock %}{% block content %}<section class="narrow-page"><div class="page-head"><div><h1>Import / Export</h1><p>Excel-Dateien kontrolliert und transaktional übernehmen.</p></div></div><div class="form-card"><h2>Excel importieren</h2><ol class="steps"><li class="active">Datei hochladen</li><li>Spalten zuordnen</li><li>Import bestätigen</li></ol><form method="post" enctype="multipart/form-data" class="app-form">{% csrf_token %}{{ form.non_field_errors }}{% for field in form %}<label class="file-drop">{{ field }}<strong>.xlsx-Datei auswählen</strong><small>Maximal 10 MB</small>{{ field.errors }}</label>{% endfor %}<div class="form-actions"><button class="btn primary">Datei analysieren</button></div></form></div><div class="form-card export-card"><h2>Direkt exportieren</h2><p>Die vollständige Geräteliste als weiterverwendbare Datei laden.</p><div class="actions"><a class="btn" href="{% url 'inventory:export-devices' 'csv' %}">CSV herunterladen</a><a class="btn" href="{% url 'inventory:export-devices' 'xlsx' %}">XLSX herunterladen</a><a class="btn" href="{% url 'inventory:export-devices' 'ods' %}">ODS für LibreOffice</a></div></div></section>{% endblock %}
|
||||||
59
inventory/templates/inventory/overview.html
Normal file
59
inventory/templates/inventory/overview.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends 'inventory/base.html' %}
|
||||||
|
{% block title %}Übersicht{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-head overview-head" data-live-refresh>
|
||||||
|
<div><h1>Übersicht</h1><p>Bestand, Eingänge und Datenqualität auf einen Blick</p></div>
|
||||||
|
<nav class="period-tabs" aria-label="Zeitraum auswählen">
|
||||||
|
<a href="?months=3" class="{% if period_months == 3 %}active{% endif %}">3 Monate</a>
|
||||||
|
<a href="?months=6" class="{% if period_months == 6 %}active{% endif %}">6 Monate</a>
|
||||||
|
<a href="?months=12" class="{% if period_months == 12 %}active{% endif %}">12 Monate</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metric-grid" aria-label="Wichtige Kennzahlen">
|
||||||
|
<article class="metric-card"><span>Aktive Geräte</span><strong>{{ active_count }}</strong><small>derzeit im Bestand</small></article>
|
||||||
|
<article class="metric-card accent"><span>Neueingänge</span><strong>{{ period_count }}</strong><small>{{ period_start|date:'d.m.Y' }} bis {{ today|date:'d.m.Y' }}</small><em class="{% if difference > 0 %}up{% elif difference < 0 %}down{% endif %}">{% if difference > 0 %}+{% endif %}{{ difference }} zum vorherigen Zeitraum</em></article>
|
||||||
|
<article class="metric-card"><span>Davon Laptops</span><strong>{{ period_laptops }}</strong><small>in den letzten {{ period_months }} Monaten</small></article>
|
||||||
|
<article class="metric-card"><span>Davon fertig</span><strong>{{ period_finished }}</strong><small>aktuell mit Status „Fertig“</small></article>
|
||||||
|
<article class="metric-card"><span>Im Archiv</span><strong>{{ archived_count }}</strong><small>historisch abgelegte Geräte</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-grid">
|
||||||
|
<article class="chart-panel monthly-panel">
|
||||||
|
<header><div><h2>Geräteeingänge</h2><p>Erfasste Geräte pro Monat · letzte 12 Monate</p></div><strong class="chart-total">{{ maximum_month }}<small>Höchstwert</small></strong></header>
|
||||||
|
<div class="line-chart-wrap">
|
||||||
|
<svg class="line-chart" viewBox="0 0 900 230" role="img" aria-labelledby="arrivals-title">
|
||||||
|
<title id="arrivals-title">Geräteeingänge der letzten zwölf Monate</title>
|
||||||
|
<line x1="40" y1="35" x2="860" y2="35" class="chart-grid-line"/><line x1="40" y1="120" x2="860" y2="120" class="chart-grid-line"/><line x1="40" y1="205" x2="860" y2="205" class="chart-grid-line"/>
|
||||||
|
<text x="31" y="39" text-anchor="end">{{ maximum_month }}</text><text x="31" y="124" text-anchor="end">{{ half_month }}</text><text x="31" y="209" text-anchor="end">0</text>
|
||||||
|
<polygon points="{{ area_points }}" class="chart-area"/><polyline points="{{ line_points }}" class="chart-line"/>
|
||||||
|
{% for item in month_series %}<circle cx="{{ item.x }}" cy="{{ item.y }}" r="4" class="chart-point"><title>{{ item.label }}: {{ item.count }} Geräte</title></circle>{% endfor %}
|
||||||
|
</svg>
|
||||||
|
<div class="month-labels">{% for item in month_series %}<span>{{ item.label }}</span>{% endfor %}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="chart-panel">
|
||||||
|
<header><div><h2>Gerätearten</h2><p>Neueingänge im gewählten Zeitraum</p></div></header>
|
||||||
|
<div class="bar-chart">{% for item in type_rows %}<div class="bar-row"><div><span>{{ item.label }}</span><strong>{{ item.count }}</strong></div><span class="bar-track"><i style="width:{{ item.percentage }}%"></i></span></div>{% empty %}<p class="chart-empty">In diesem Zeitraum gibt es keine Eingänge.</p>{% endfor %}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="chart-panel">
|
||||||
|
<header><div><h2>Statusverteilung</h2><p>Alle aktiven Geräte</p></div></header>
|
||||||
|
<div class="donut-layout">
|
||||||
|
<div class="donut" style="background:conic-gradient({{ status_gradient }})"><div><strong>{{ status_total }}</strong><span>Geräte</span></div></div>
|
||||||
|
<div class="legend">{% for item in status_rows %}<div><i style="background:{{ item.color }}"></i><span>{{ item.label }}</span><strong>{{ item.count }} <small>{{ item.percentage }} %</small></strong></div>{% empty %}<p class="chart-empty">Keine aktiven Geräte vorhanden.</p>{% endfor %}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="chart-panel">
|
||||||
|
<header><div><h2>Häufigste Empfänger</h2><p>Neueingänge im gewählten Zeitraum</p></div></header>
|
||||||
|
<div class="bar-chart recipient-chart">{% for item in recipient_rows %}<div class="bar-row"><div><span>{{ item.label }}</span><strong>{{ item.count }}</strong></div><span class="bar-track"><i style="width:{{ item.percentage }}%"></i></span></div>{% empty %}<p class="chart-empty">Noch keine Empfänger im Zeitraum erfasst.</p>{% endfor %}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="chart-panel">
|
||||||
|
<header><div><h2>Datenvollständigkeit</h2><p>Anteil ausgefüllter Angaben bei aktiven Geräten</p></div></header>
|
||||||
|
<div class="completion-chart">{% for item in completeness %}<div><span>{{ item.label }}</span><span class="completion-track"><i style="width:{{ item.percentage }}%"></i></span><strong>{{ item.percentage }} %</strong></div>{% endfor %}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
13
inventory/templates/inventory/quick_add.html
Normal file
13
inventory/templates/inventory/quick_add.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Neues Gerät{% endblock %}{% block content %}
|
||||||
|
<section class="narrow-page"><div class="page-head"><div><h1>Neues Gerät</h1><p>Typ auswählen und die passenden Daten direkt erfassen.</p></div></div>
|
||||||
|
<div class="form-card"><form method="post" class="app-form" data-device-type-form data-hide-hardware-without-type>
|
||||||
|
{% csrf_token %}{{ form.non_field_errors }}
|
||||||
|
{% for field in form %}{% if field.name not in all_hardware_fields %}<label>{{ field.label }}{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{{ field.errors }}</label>{% endif %}{% endfor %}
|
||||||
|
<section class="quick-hardware-section" data-hardware-section hidden>
|
||||||
|
<h2>Technische Daten <small data-selected-type-label></small></h2>
|
||||||
|
<div class="quick-hardware-grid">{% for field in form.hardware_fields %}<label data-hardware-field="{{ field.name }}" hidden>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endfor %}</div>
|
||||||
|
</section>
|
||||||
|
<div class="form-actions"><a class="btn" href="{% url 'inventory:device-list' %}">Abbrechen</a><button class="btn primary">Gerät anlegen</button></div>
|
||||||
|
</form></div></section>
|
||||||
|
{{ type_field_map|json_script:'device-type-fields' }}
|
||||||
|
{% endblock %}
|
||||||
17
inventory/templates/inventory/simple_form.html
Normal file
17
inventory/templates/inventory/simple_form.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'inventory/base.html' %}
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="narrow-page">
|
||||||
|
<div class="page-head"><div><a class="back" href="{{ cancel_url|default:'/configuration/' }}">← Zurück</a><h1>{{ title }}</h1></div></div>
|
||||||
|
<div class="form-card"><form method="post" class="app-form">{% csrf_token %}{{ form.non_field_errors }}
|
||||||
|
{% for field in form %}
|
||||||
|
{% if field.name == 'color' %}
|
||||||
|
<fieldset class="color-field"><legend>{{ field.label }}</legend><div class="color-options">{% for option in field %}<label class="color-option" style="--choice-color:{{ option.data.value }}">{{ option.tag }}<i></i><span>{{ option.choice_label }}</span></label>{% endfor %}</div>{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{{ field.errors }}</fieldset>
|
||||||
|
{% else %}
|
||||||
|
<label{% if field.field.widget.input_type == 'checkbox' %} class="check-label"{% endif %}>{{ field.label }}{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{{ field.errors }}</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="form-actions"><a class="btn" href="{{ cancel_url|default:'/configuration/' }}">Abbrechen</a><button class="btn primary">Speichern</button></div>
|
||||||
|
</form></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
8
inventory/templates/inventory/task_list.html
Normal file
8
inventory/templates/inventory/task_list.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Aufgaben{% endblock %}{% block content %}
|
||||||
|
<section class="narrow-page" data-live-refresh><div class="page-head"><div><h1>Aufgaben</h1><p>Offene Arbeiten an Geräten im Überblick.</p></div></div>
|
||||||
|
<p><a class="btn{% if state == 'OPEN' %} primary{% endif %}" href="?state=OPEN">Offen</a> <a class="btn{% if state == 'DONE' %} primary{% endif %}" href="?state=DONE">Erledigt</a></p>
|
||||||
|
{% for task in tasks %}<div class="form-card"><h2><a href="{% url 'inventory:device-detail' task.device.pk %}">Gerät #{{ task.device.number }}</a> · {{ task.title }}</h2>
|
||||||
|
<p>{{ task.device.get_stage_display }}{% if task.assigned_to %} · {{ task.assigned_to.username }}{% endif %}{% if task.due_date %} · fällig {{ task.due_date|date:'d.m.Y' }}{% endif %}</p>
|
||||||
|
{% if task.description %}<p>{{ task.description|linebreaksbr }}</p>{% endif %}
|
||||||
|
{% if can_edit_devices and task.state == 'OPEN' %}<form method="post" action="{% url 'inventory:device-task-complete' task.device.pk task.pk %}">{% csrf_token %}<button class="btn">Erledigt</button></form>{% endif %}
|
||||||
|
</div>{% empty %}<p>Keine Aufgaben vorhanden.</p>{% endfor %}</section>{% endblock %}
|
||||||
4
inventory/templates/inventory/user_list.html
Normal file
4
inventory/templates/inventory/user_list.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Benutzer{% endblock %}{% block content %}
|
||||||
|
<section class="page-head"><div><h1>Benutzer</h1><p>Konten und Rollen der Geräteverwaltung</p></div><a class="btn primary" href="{% url 'inventory:user-new' %}">+ Benutzer</a></section>
|
||||||
|
<div class="table-shell"><table class="device-table"><thead><tr><th>Benutzername</th><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th></th></tr></thead><tbody>{% for user in users %}<tr><td class="strong">{{ user.username }}</td><td>{{ user.get_full_name|default:'—' }}</td><td>{{ user.email|default:'—' }}</td><td>{{ user.profile.get_role_display }}</td><td>{% if user.is_active %}<span class="status status-fertig">Aktiv</span>{% else %}<span class="status status-defekt">Inaktiv</span>{% endif %}</td><td><a class="btn small" href="{% url 'inventory:user-edit' user.pk %}">Bearbeiten</a></td></tr>{% empty %}<tr><td colspan="6" class="empty">Keine Benutzer vorhanden.</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% endblock %}
|
||||||
42
inventory/templates/inventory/workflow_board.html
Normal file
42
inventory/templates/inventory/workflow_board.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends 'inventory/base.html' %}{% block title %}Ablauf{% endblock %}{% block content %}
|
||||||
|
<section class="page-head" data-live-refresh><div><h1>Ablauf</h1><p>Geräte prüfen, reparieren, lagern und ausgeben.</p></div><a class="btn" href="{% url 'inventory:task-list' %}">Alle Aufgaben</a></section>
|
||||||
|
<div class="workflow-board">
|
||||||
|
{% for column in columns %}<section class="workflow-column" aria-labelledby="stage-{{ column.stage }}">
|
||||||
|
<header><h2 id="stage-{{ column.stage }}">{{ column.label }}</h2><span>{{ column.total }}</span></header>
|
||||||
|
<div class="workflow-cards">
|
||||||
|
{% for device in column.devices %}<article class="workflow-card">
|
||||||
|
<div class="workflow-card-head"><a href="{% url 'inventory:device-detail' device.pk %}">#{{ device.number }} · {{ device.model|default:device.device_type.label|default:'Gerät' }}</a><small>{{ device.device_type.label|default:'Ohne Typ' }}</small></div>
|
||||||
|
<p>{{ device.status.label }}{% if device.employee %} · {{ device.employee.code }}{% endif %}{% if device.shelf %} · Regal {{ device.shelf }}{% endif %}</p>
|
||||||
|
{% if device.stage == 'INSPECTION' %}
|
||||||
|
{% with latest=device.inspection_history.0 %}{% if latest %}<p class="workflow-record"><strong>Letzter Befund:</strong> {{ latest.defects|truncatechars:180 }}<small>{{ latest.created_at|date:'d.m.Y H:i' }}{% if latest.recorded_by %} · {{ latest.recorded_by.username }}{% endif %}</small></p>{% endif %}{% endwith %}
|
||||||
|
{% if can_edit_devices %}<form method="post" action="{% url 'inventory:device-inspection-save' device.pk %}" class="workflow-stage-form">
|
||||||
|
{% csrf_token %}<input type="hidden" name="return_to" value="workflow"><input type="hidden" name="expected_revision" value="{{ device.revision }}">
|
||||||
|
<h3>Inspektion erfassen</h3><label>{{ device.stage_form.defects.label }}{{ device.stage_form.defects }}</label>
|
||||||
|
<div class="workflow-spec-grid">{% for field in device.stage_form.specification_fields %}<label>{{ field.label }}{{ field }}</label>{% endfor %}</div>
|
||||||
|
<button class="btn small primary">Befund und Daten speichern</button>
|
||||||
|
</form>{% endif %}
|
||||||
|
{% elif device.stage == 'REPAIR' %}
|
||||||
|
{% with latest=device.repair_history.0 %}{% if latest %}<p class="workflow-record"><strong>Letzte Reparatur:</strong> {{ latest.work_done|truncatechars:180 }}<small>{{ latest.created_at|date:'d.m.Y H:i' }}{% if latest.recorded_by %} · {{ latest.recorded_by.username }}{% endif %}</small></p>{% endif %}{% endwith %}
|
||||||
|
{% if can_edit_devices %}<form method="post" action="{% url 'inventory:device-repair-add' device.pk %}" class="workflow-stage-form">
|
||||||
|
{% csrf_token %}<input type="hidden" name="return_to" value="workflow"><input type="hidden" name="expected_revision" value="{{ device.revision }}">
|
||||||
|
<h3>Reparatur dokumentieren</h3><label>{{ device.stage_form.work_done.label }}{{ device.stage_form.work_done }}</label>
|
||||||
|
<button class="btn small primary">Arbeiten speichern</button>
|
||||||
|
</form>{% endif %}
|
||||||
|
{% elif device.stage == 'STORAGE' %}
|
||||||
|
{% if can_edit_devices %}<form method="post" action="{% url 'inventory:device-storage-assign' device.pk %}" class="workflow-stage-form">
|
||||||
|
{% csrf_token %}<input type="hidden" name="return_to" value="workflow"><input type="hidden" name="expected_revision" value="{{ device.revision }}">
|
||||||
|
<h3>Ausgabe vorbereiten</h3><label>{{ device.stage_form.recipient.label }}{{ device.stage_form.recipient }}</label>
|
||||||
|
<button class="btn small primary">Empfänger speichern</button>
|
||||||
|
</form>{% elif device.recipient %}<p class="workflow-record"><strong>Empfänger:</strong> {{ device.recipient.label }}</p>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="workflow-task-line"><strong>{{ device.open_task_count }} offen</strong>{% for task in device.open_tasks|slice:':2' %}<span>{{ task.title }}</span>{% endfor %}</div>
|
||||||
|
{% if can_edit_devices %}
|
||||||
|
{% if device.stage != 'ISSUED' %}<form method="post" action="{% url 'inventory:device-task-add' device.pk %}" class="workflow-quick-form">{% csrf_token %}<input type="hidden" name="return_to" value="workflow"><input name="title" maxlength="200" required placeholder="Neue Aufgabe" aria-label="Neue Aufgabe für Gerät {{ device.number }}"><button class="btn small" title="Aufgabe anlegen">+</button></form>{% endif %}
|
||||||
|
{% if device.next_stages %}<form method="post" action="{% url 'inventory:device-advance' device.pk %}" class="workflow-quick-form">{% csrf_token %}<input type="hidden" name="return_to" value="workflow"><input type="hidden" name="expected_revision" value="{{ device.revision }}"><select name="stage" aria-label="Nächster Schritt für Gerät {{ device.number }}">{% for value, label in device.next_stages %}<option value="{{ value }}">{{ label }}</option>{% endfor %}</select><button class="btn small primary">Weiter</button></form>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</article>{% empty %}<p class="workflow-empty">Keine Geräte</p>{% endfor %}
|
||||||
|
{% if column.total > 30 %}<p class="workflow-empty">Die neuesten 30 von {{ column.total }} Geräten. <a href="{% url 'inventory:device-list' %}?stage={{ column.stage }}">Alle anzeigen</a></p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
5
inventory/templates/registration/login.html
Normal file
5
inventory/templates/registration/login.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Anmelden · CS-R</title><link rel="icon" type="image/png" href="{% static 'inventory/img/csr-logo.png' %}"><script src="{% static 'inventory/js/theme.js' %}?v=20260928-3"></script><link rel="stylesheet" href="{% static 'inventory/css/app.css' %}?v=20260928-3"></head>
|
||||||
|
<body class="login-page"><main class="login-card"><img src="{% static 'inventory/img/csr-logo.png' %}" alt="CS-R"><h1>Geräteverwaltung</h1><p>Melden Sie sich mit Ihrem internen Konto an.</p>
|
||||||
|
{% if form.errors %}<div class="form-error">Benutzername oder Passwort ist nicht korrekt.</div>{% endif %}
|
||||||
|
<form method="post">{% csrf_token %}<label>Benutzername{{ form.username }}</label><label>Passwort{{ form.password }}</label><input type="hidden" name="next" value="{{ next }}"><button class="btn primary full">Anmelden</button></form><button class="theme-toggle login-theme" type="button" data-theme-toggle aria-pressed="false"><span data-theme-icon aria-hidden="true">☾</span><span data-theme-label>Dunkelmodus</span></button></main></body></html>
|
||||||
1
inventory/templatetags/__init__.py
Normal file
1
inventory/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
66
inventory/templatetags/inventory_tags.py
Normal file
66
inventory/templatetags/inventory_tags.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def get_item(mapping, key):
|
||||||
|
return (mapping or {}).get(key, "")
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def query_replace(context, **kwargs):
|
||||||
|
query = context["request"].GET.copy()
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
query[key] = value
|
||||||
|
return query.urlencode()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def option_selected(request, key, value):
|
||||||
|
return "selected" if str(value) in request.GET.getlist(key) else ""
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def option_checked(request, key, value):
|
||||||
|
return "checked" if str(value) in request.GET.getlist(key) else ""
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def sort_query(context, key):
|
||||||
|
query = context["request"].GET.copy()
|
||||||
|
current = query.get("sort")
|
||||||
|
direction = query.get("dir", "desc")
|
||||||
|
query["sort"] = key
|
||||||
|
query["dir"] = "desc" if current == key and direction == "asc" else "asc"
|
||||||
|
query.pop("page", None)
|
||||||
|
return query.urlencode()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def contains_value(values, value):
|
||||||
|
return value in (values or [])
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def display_custom(value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ", ".join(str(item) for item in value)
|
||||||
|
if value is True:
|
||||||
|
return "JA"
|
||||||
|
if value is False:
|
||||||
|
return "NEIN"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def german_date(value):
|
||||||
|
if isinstance(value, (date, datetime)):
|
||||||
|
return value.strftime("%d.%m.%Y")
|
||||||
|
text = str(value or "")
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, "%Y-%m-%d").strftime("%d.%m.%Y")
|
||||||
|
except ValueError:
|
||||||
|
return text
|
||||||
53
inventory/test_migration.py
Normal file
53
inventory/test_migration.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.management import CommandError
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from .management.commands.import_sqlite_export import Command
|
||||||
|
from .models import ChoiceValue, Device, NumberSequence, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationImportTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.fixture = Path(self.temporary.name) / "export.json"
|
||||||
|
|
||||||
|
def write_fixture(self, objects):
|
||||||
|
self.fixture.write_text(json.dumps(objects), encoding="utf-8")
|
||||||
|
|
||||||
|
def test_import_accepts_migration_defaults_and_preserves_ids_and_roles(self):
|
||||||
|
# Fresh databases have these rows from migration 0003. Fixture IDs differ.
|
||||||
|
ChoiceValue.objects.all().delete()
|
||||||
|
for order, label in enumerate(("Bildschirm", "Drucker"), 3):
|
||||||
|
ChoiceValue.objects.create(kind="device_type", label=label, sort_order=order)
|
||||||
|
stamp = "2026-09-23T10:00:00Z"
|
||||||
|
self.write_fixture([
|
||||||
|
{"model": "auth.user", "pk": 50, "fields": {"username": "migrated", "password": "!", "is_active": True}},
|
||||||
|
{"model": "inventory.userprofile", "pk": 60, "fields": {"user": ["migrated"], "role": "EDITOR", "created_at": stamp, "updated_at": stamp}},
|
||||||
|
{"model": "inventory.choicevalue", "pk": 70, "fields": {"kind": "status", "label": "Imported", "created_at": stamp, "updated_at": stamp}},
|
||||||
|
{"model": "inventory.choicevalue", "pk": 80, "fields": {"kind": "device_type", "label": "Bildschirm", "created_at": stamp, "updated_at": stamp}},
|
||||||
|
{"model": "inventory.device", "pk": 90, "fields": {"number": 2000, "status": 70, "device_type": 80, "created_at": stamp, "updated_at": stamp, "revision": 1}},
|
||||||
|
])
|
||||||
|
self.assertEqual(Command().load_export(self.fixture, verbosity=0), 1)
|
||||||
|
self.assertEqual(User.objects.get(username="migrated").pk, 50)
|
||||||
|
self.assertEqual(UserProfile.objects.get(user_id=50).role, "EDITOR")
|
||||||
|
self.assertEqual(Device.objects.get(pk=90).number, 2000)
|
||||||
|
self.assertEqual(NumberSequence.objects.get(name="device").last_value, 2000)
|
||||||
|
|
||||||
|
def test_import_refuses_existing_business_data(self):
|
||||||
|
user = User.objects.create_user("existing")
|
||||||
|
self.write_fixture([])
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
Command().load_export(self.fixture, verbosity=0)
|
||||||
|
self.assertTrue(User.objects.filter(pk=user.pk).exists())
|
||||||
|
|
||||||
|
def test_bad_fixture_rolls_back_default_removal_and_import(self):
|
||||||
|
before = list(ChoiceValue.objects.values_list("pk", "label"))
|
||||||
|
self.write_fixture([{"model": "inventory.unknown", "pk": 1, "fields": {}}])
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
Command().load_export(self.fixture, verbosity=0)
|
||||||
|
self.assertEqual(list(ChoiceValue.objects.values_list("pk", "label")), before)
|
||||||
161
inventory/test_security.py
Normal file
161
inventory/test_security.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
from threading import Barrier
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.db import close_old_connections
|
||||||
|
from django.test import Client, TestCase, TransactionTestCase, skipUnlessDBFeature
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from .models import AuditLog, ChoiceValue, Device, Employee, InventoryAPIKey, UserPreference
|
||||||
|
from .services import coerce_inline_value
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityRegressionTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user("security-admin", is_superuser=True)
|
||||||
|
cls.status = ChoiceValue.objects.create(kind="status", label="Test")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.device = Device.objects.create(status=self.status, ram_gb=4)
|
||||||
|
|
||||||
|
def post_inline(self, payload):
|
||||||
|
return self.client.post(reverse("inventory:inline-update", args=[self.device.pk]),
|
||||||
|
json.dumps(payload), content_type="application/json")
|
||||||
|
|
||||||
|
def test_revisions_and_legacy_timestamps_change_on_a_frozen_clock(self):
|
||||||
|
old_time = self.device.updated_at
|
||||||
|
with patch("inventory.models.timezone.now", return_value=old_time):
|
||||||
|
self.device.ram_gb = 8
|
||||||
|
self.device.save(update_fields=["ram_gb"])
|
||||||
|
self.assertEqual(self.device.revision, 2)
|
||||||
|
self.assertGreater(self.device.updated_at, old_time)
|
||||||
|
response = self.post_inline({"field": "ram_gb", "value": 16, "expected_revision": 1})
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
response = self.post_inline({"field": "ram_gb", "value": 16, "expected_updated_at": old_time.isoformat()})
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertEqual(self.device.ram_gb, 8)
|
||||||
|
|
||||||
|
def test_detail_rejects_stale_and_missing_versions(self):
|
||||||
|
self.device.ram_gb = 8
|
||||||
|
self.device.save()
|
||||||
|
for token in ({"expected_revision": 1}, {}):
|
||||||
|
response = self.client.post(reverse("inventory:device-detail", args=[self.device.pk]), {
|
||||||
|
"status": self.status.pk, "date": "23.09.2026", "ram_gb": 16,
|
||||||
|
"battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", **token,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertEqual(self.device.ram_gb, 8)
|
||||||
|
self.assertFalse(AuditLog.objects.filter(device=self.device).exists())
|
||||||
|
|
||||||
|
def test_malformed_inline_payloads_return_400(self):
|
||||||
|
for payload in ([], None, {"field": "ram_gb", "value": 8},
|
||||||
|
{"field": [], "value": 8, "expected_revision": 1},
|
||||||
|
{"field": "price", "value": "NaN", "expected_revision": 1},
|
||||||
|
{"field": "price", "value": "Infinity", "expected_revision": 1}):
|
||||||
|
with self.subTest(payload=payload):
|
||||||
|
self.assertEqual(self.post_inline(payload).status_code, 400)
|
||||||
|
|
||||||
|
def test_integer_fields_do_not_silently_truncate(self):
|
||||||
|
for value in (1.5, "NaN", "Infinity", True):
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||||
|
coerce_inline_value("ram_gb", value)
|
||||||
|
|
||||||
|
def test_invalid_filters_do_not_raise_server_errors(self):
|
||||||
|
for query in ("status=abc", "ram_min=abc", "price_min=NaN", "price_min=Infinity", "employee=9999999999999999999999999"):
|
||||||
|
with self.subTest(query=query):
|
||||||
|
response = self.client.get(reverse("inventory:device-list") + "?" + query)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.context["page_obj"].paginator.count, 0)
|
||||||
|
|
||||||
|
def test_invalid_preferences_are_rejected_without_persisting(self):
|
||||||
|
for payload in ([], {"page_size": []}, {"column_order": "number"},
|
||||||
|
{"visible_columns": ["<script>"]}, {"column_widths": {"number": "wide"}}):
|
||||||
|
response = self.client.post(reverse("inventory:save-preferences"), json.dumps(payload), content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertFalse(UserPreference.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_exports_preserve_formula_like_text_without_executing_it(self):
|
||||||
|
self.device.comment = '=HYPERLINK("https://example.invalid", "test")'
|
||||||
|
self.device.model = " +1+1"
|
||||||
|
self.device.save()
|
||||||
|
response = self.client.get(reverse("inventory:export-devices", args=["xlsx"]))
|
||||||
|
workbook = load_workbook(BytesIO(response.content))
|
||||||
|
row = dict(zip([cell.value for cell in workbook.active[1]], workbook.active[2]))
|
||||||
|
self.assertEqual(row["Kommentar"].data_type, "s")
|
||||||
|
self.assertEqual(row["Kommentar"].value, self.device.comment)
|
||||||
|
self.assertEqual(row["RAM"].data_type, "n")
|
||||||
|
response = self.client.get(reverse("inventory:export-devices", args=["csv"]))
|
||||||
|
row = next(csv.DictReader(StringIO(response.content.decode("utf-8-sig")), delimiter=";"))
|
||||||
|
self.assertEqual(row["Kommentar"], "'" + self.device.comment)
|
||||||
|
self.assertEqual(row["Modell"], "'" + self.device.model)
|
||||||
|
|
||||||
|
def test_autocomplete_includes_choices_beyond_first_fifteen(self):
|
||||||
|
Employee.objects.bulk_create([Employee(code=f"E{i:02}", name=f"Employee {i}") for i in range(25)])
|
||||||
|
response = self.client.get(reverse("inventory:autocomplete", args=["employee"]))
|
||||||
|
self.assertEqual(len(response.json()["results"]), 25)
|
||||||
|
|
||||||
|
def test_seed_preserves_administrator_changes(self):
|
||||||
|
call_command("seed_initial_data", verbosity=0)
|
||||||
|
status = ChoiceValue.objects.get(kind="status", label="Eingang")
|
||||||
|
status.color, status.active = "#123456", False
|
||||||
|
status.save()
|
||||||
|
employee = Employee.objects.get(code="JH")
|
||||||
|
employee.name, employee.active = "Changed name", False
|
||||||
|
employee.save()
|
||||||
|
call_command("seed_initial_data", verbosity=0)
|
||||||
|
status.refresh_from_db()
|
||||||
|
employee.refresh_from_db()
|
||||||
|
self.assertEqual(status.color, "#123456")
|
||||||
|
self.assertFalse(status.active)
|
||||||
|
self.assertEqual(employee.name, "Changed name")
|
||||||
|
self.assertFalse(employee.active)
|
||||||
|
|
||||||
|
def test_api_supports_revisions_and_rejects_stale_revision(self):
|
||||||
|
_, key = InventoryAPIKey.objects.create_key(name="test", service_user=self.user,
|
||||||
|
can_read=True, can_write=True, expiry_date=timezone.now() + timezone.timedelta(days=1))
|
||||||
|
url = f"/api/v1/devices/{self.device.number}/"
|
||||||
|
for expected_status in (200, 409):
|
||||||
|
response = self.client.patch(url, json.dumps({"ram_gb": 8, "expected_revision": 1}),
|
||||||
|
content_type="application/json", HTTP_AUTHORIZATION=f"Api-Key {key}")
|
||||||
|
self.assertEqual(response.status_code, expected_status)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgreSQLConcurrencyTests(TransactionTestCase):
|
||||||
|
@skipUnlessDBFeature("has_select_for_update")
|
||||||
|
def test_two_detail_saves_with_same_revision_only_commit_once(self):
|
||||||
|
user = User.objects.create_user("concurrent-admin", is_superuser=True)
|
||||||
|
status = ChoiceValue.objects.create(kind="status", label="Test")
|
||||||
|
device = Device.objects.create(status=status, ram_gb=4)
|
||||||
|
clients = [Client(), Client()]
|
||||||
|
for client in clients:
|
||||||
|
client.force_login(user)
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def save(index):
|
||||||
|
close_old_connections()
|
||||||
|
try:
|
||||||
|
barrier.wait(timeout=10)
|
||||||
|
return clients[index].post(reverse("inventory:device-detail", args=[device.pk]), {
|
||||||
|
"status": status.pk, "date": device.date.isoformat(), "ram_gb": 8 + index,
|
||||||
|
"battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", "expected_revision": 1,
|
||||||
|
}).status_code
|
||||||
|
finally:
|
||||||
|
close_old_connections()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
self.assertEqual(list(executor.map(save, range(2))), [302, 302])
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.revision, 2)
|
||||||
|
self.assertEqual(AuditLog.objects.filter(device=device, field_key="ram_gb").count(), 1)
|
||||||
72
inventory/test_specification_migration.py
Normal file
72
inventory/test_specification_migration.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""The destructive column move must preserve existing device hardware."""
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
|
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")]
|
||||||
|
|
||||||
|
|
||||||
|
class SpecificationMigrationTests(TransactionTestCase):
|
||||||
|
def test_existing_rows_move_to_the_correct_type_table(self):
|
||||||
|
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")
|
||||||
|
status, _ = Choice.objects.get_or_create(kind="status", label="Eingang")
|
||||||
|
laptop, _ = Choice.objects.get_or_create(kind="device_type", label="Laptop")
|
||||||
|
printer, _ = Choice.objects.get_or_create(kind="device_type", label="Drucker")
|
||||||
|
screen, _ = Choice.objects.get_or_create(kind="device_type", label="Bildschirm")
|
||||||
|
laptop_id = Device.objects.create(number=91001, status=status, device_type=laptop, cpu="Core i5", ram_gb=16).pk
|
||||||
|
printer_id = Device.objects.create(number=91002, status=status, device_type=printer, wifi="YES").pk
|
||||||
|
screen_id = Device.objects.create(number=91003, status=status, device_type=screen, display_inches="24.0").pk
|
||||||
|
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
executor.migrate(NEW)
|
||||||
|
apps = executor.loader.project_state(NEW).apps
|
||||||
|
Device = apps.get_model("inventory", "Device")
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
Printer = apps.get_model("inventory", "PrinterSpecification")
|
||||||
|
Monitor = apps.get_model("inventory", "MonitorSpecification")
|
||||||
|
self.assertNotIn("ram_gb", {field.name for field in Device._meta.fields})
|
||||||
|
self.assertEqual(Computer.objects.get(device_id=laptop_id).ram_gb, 16)
|
||||||
|
self.assertEqual(Computer.objects.get(device_id=laptop_id).cpu, "Core i5")
|
||||||
|
self.assertEqual(Printer.objects.get(device_id=printer_id).wifi, "YES")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTypeTableMigrationTests(TransactionTestCase):
|
||||||
|
def test_existing_computers_move_to_their_own_tables(self):
|
||||||
|
old = [("inventory", "0011_inspectionrecord_repairrecord")]
|
||||||
|
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")
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
status, _ = Choice.objects.get_or_create(kind="status", label="Eingang")
|
||||||
|
ids = {}
|
||||||
|
for index, label in enumerate(("Laptop", "Desktop", "AiO", "Sondergerät"), start=1):
|
||||||
|
device_type, _ = Choice.objects.get_or_create(kind="device_type", label=label)
|
||||||
|
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
|
||||||
|
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")
|
||||||
|
Computer = apps.get_model("inventory", "ComputerSpecification")
|
||||||
|
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)
|
||||||
266
inventory/test_workflow.py
Normal file
266
inventory/test_workflow.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import QueryDict
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from .device_types import applicable_fields
|
||||||
|
from .api import DeviceSerializer
|
||||||
|
from .forms import DeviceForm
|
||||||
|
from .models import AllInOneSpecification, ChoiceValue, ComputerSpecification, DesktopSpecification, Device, DeviceTask, InspectionRecord, LaptopSpecification, MonitorSpecification, PrinterSpecification, RepairRecord, UserProfile, WorkflowEvent
|
||||||
|
from .query import device_queryset
|
||||||
|
from .services import update_device_field
|
||||||
|
from .workflow import advance_device, complete_task, create_task
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.status, _ = ChoiceValue.objects.get_or_create(kind="status", label="Eingang")
|
||||||
|
for label in ("Defekt", "Fertig", "Ausgegeben"):
|
||||||
|
ChoiceValue.objects.get_or_create(kind="status", label=label)
|
||||||
|
cls.laptop, _ = ChoiceValue.objects.get_or_create(kind="device_type", label="Laptop")
|
||||||
|
cls.printer, _ = ChoiceValue.objects.get_or_create(kind="device_type", label="Drucker")
|
||||||
|
cls.user = User.objects.create_user("editor", password="test-password")
|
||||||
|
cls.user.profile.role = UserProfile.Role.EDITOR
|
||||||
|
cls.user.profile.save()
|
||||||
|
cls.viewer = User.objects.create_user("viewer", password="test-password")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.device = Device.objects.create(status=self.status, device_type=self.laptop)
|
||||||
|
|
||||||
|
def test_flow_blocks_open_tasks_and_requires_recipient_for_issue(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
advance_device(self.device.pk, Device.Stage.ISSUED, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
self.device = advance_device(self.device.pk, Device.Stage.INSPECTION, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
task = create_task(self.device.pk, title="Akku prüfen", user=self.user)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
advance_device(self.device.pk, Device.Stage.STORAGE, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
complete_task(self.device.pk, task.pk, user=self.user)
|
||||||
|
self.device = advance_device(self.device.pk, Device.Stage.STORAGE, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
advance_device(self.device.pk, Device.Stage.ISSUED, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
recipient = ChoiceValue.objects.create(kind="recipient", label="Verein")
|
||||||
|
self.device.recipient = recipient
|
||||||
|
self.device.save()
|
||||||
|
self.device = advance_device(self.device.pk, Device.Stage.ISSUED, user=self.user,
|
||||||
|
expected_revision=self.device.revision, note="Abgeholt")
|
||||||
|
self.assertEqual(WorkflowEvent.objects.filter(device=self.device).count(), 3)
|
||||||
|
self.assertEqual(self.device.tasks.get().state, DeviceTask.State.DONE)
|
||||||
|
self.assertEqual(self.device.workflow_events.first().note, "Abgeholt")
|
||||||
|
self.assertEqual(self.device.status.label, "Ausgegeben")
|
||||||
|
|
||||||
|
def test_stale_transition_and_viewer_cannot_change_workflow(self):
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
response = self.client.post(reverse("inventory:device-advance", args=[self.device.pk]),
|
||||||
|
{"stage": "INSPECTION", "expected_revision": self.device.revision})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertFalse(WorkflowEvent.objects.exists())
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
advance_device(self.device.pk, Device.Stage.INSPECTION, user=self.user,
|
||||||
|
expected_revision=self.device.revision + 1)
|
||||||
|
|
||||||
|
def test_printer_has_no_computer_fields_in_form_and_inline_write_is_rejected(self):
|
||||||
|
self.assertNotIn("ram_gb", applicable_fields(self.printer))
|
||||||
|
self.device.device_type = self.printer
|
||||||
|
self.device.save()
|
||||||
|
form = DeviceForm(instance=self.device)
|
||||||
|
self.assertNotIn("ram_gb", applicable_fields(self.printer))
|
||||||
|
self.assertIn("ram_gb", form.fields)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
update_device_field(self.device, "ram_gb", "8", self.user)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("inventory:device-detail", args=[self.device.pk]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'data-hardware-field="ram_gb" hidden')
|
||||||
|
self.assertContains(response, "Aufgaben")
|
||||||
|
listing = self.client.get(reverse("inventory:device-list"), {"type": self.printer.pk})
|
||||||
|
self.assertEqual(listing.status_code, 200)
|
||||||
|
self.assertNotContains(listing, 'data-column="ram_gb"')
|
||||||
|
|
||||||
|
def test_task_board_and_form_work_for_editor(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(reverse("inventory:device-task-add", args=[self.device.pk]), {
|
||||||
|
"title": "Gehäuse prüfen", "due_date": "2026-10-01", "assigned_to": self.user.pk,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
task = DeviceTask.objects.get(device=self.device)
|
||||||
|
self.assertEqual(task.assigned_to, self.user)
|
||||||
|
response = self.client.get(reverse("inventory:task-list"))
|
||||||
|
self.assertContains(response, "Gehäuse prüfen")
|
||||||
|
self.client.post(reverse("inventory:device-task-complete", args=[self.device.pk, task.pk]))
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.state, DeviceTask.State.DONE)
|
||||||
|
|
||||||
|
def test_changing_to_printer_preserves_old_hardware_in_legacy_data(self):
|
||||||
|
self.device.ram_gb = 16
|
||||||
|
self.device.cpu = "Core i5"
|
||||||
|
self.device.save()
|
||||||
|
update_device_field(self.device, "device_type", str(self.printer.pk), self.user)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertIsNone(self.device.ram_gb)
|
||||||
|
self.assertEqual(self.device.cpu, "")
|
||||||
|
self.assertEqual(self.device.custom_values["legacy_hardware"]["ram_gb"], "16")
|
||||||
|
self.assertFalse(LaptopSpecification.objects.filter(device=self.device).exists())
|
||||||
|
self.assertTrue(PrinterSpecification.objects.filter(device=self.device).exists())
|
||||||
|
|
||||||
|
def test_detail_form_switches_type_and_saves_only_printer_fields(self):
|
||||||
|
self.device.ram_gb = 16
|
||||||
|
self.device.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(reverse("inventory:device-detail", args=[self.device.pk]), {
|
||||||
|
"expected_revision": self.device.revision,
|
||||||
|
"status": self.status.pk,
|
||||||
|
"device_type": self.printer.pk,
|
||||||
|
"date": "28.09.2026",
|
||||||
|
"print_technology": "Laser",
|
||||||
|
"duplex": "YES",
|
||||||
|
"color_printing": "NO",
|
||||||
|
"ports": "USB",
|
||||||
|
"ram_gb": "999",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertEqual(self.device.device_type, self.printer)
|
||||||
|
self.assertEqual(self.device.print_technology, "Laser")
|
||||||
|
self.assertEqual(self.device.ports, "USB")
|
||||||
|
self.assertIsNone(self.device.ram_gb)
|
||||||
|
self.assertEqual(self.device.custom_values["legacy_hardware"]["ram_gb"], "16")
|
||||||
|
|
||||||
|
response = self.client.post(reverse("inventory:device-detail", args=[self.device.pk]), {
|
||||||
|
"expected_revision": self.device.revision,
|
||||||
|
"status": "",
|
||||||
|
"device_type": self.laptop.pk,
|
||||||
|
"ram_gb": "32",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'data-hardware-field="ram_gb"')
|
||||||
|
self.assertEqual(set(response.context["hardware_fields"]), applicable_fields(self.laptop))
|
||||||
|
|
||||||
|
def test_type_specific_tables_and_direct_workflow_board(self):
|
||||||
|
self.device.ram_gb = 8
|
||||||
|
self.device.save(update_fields=["ram_gb"])
|
||||||
|
self.assertEqual(LaptopSpecification.objects.get(device=self.device).ram_gb, 8)
|
||||||
|
printer = Device.objects.create(status=self.status, device_type=self.printer, wifi=Device.TriState.YES, print_technology="Laser", duplex=Device.TriState.YES)
|
||||||
|
self.assertTrue(PrinterSpecification.objects.filter(device=printer).exists())
|
||||||
|
self.assertEqual(PrinterSpecification.objects.get(device=printer).print_technology, "Laser")
|
||||||
|
self.assertFalse(ComputerSpecification.objects.filter(device=printer).exists())
|
||||||
|
screen, _ = ChoiceValue.objects.get_or_create(kind="device_type", label="Bildschirm")
|
||||||
|
monitor = Device.objects.create(status=self.status, device_type=screen, display_inches="24.0", resolution="1920x1080", ports="HDMI, DisplayPort")
|
||||||
|
self.assertTrue(MonitorSpecification.objects.filter(device=monitor).exists())
|
||||||
|
self.assertEqual(MonitorSpecification.objects.get(device=monitor).ports, "HDMI, DisplayPort")
|
||||||
|
self.assertFalse(ComputerSpecification.objects.filter(device=monitor).exists())
|
||||||
|
self.assertIn("resolution", DeviceForm(instance=monitor).fields)
|
||||||
|
self.assertIn("ports", DeviceForm(instance=monitor).fields)
|
||||||
|
self.assertNotIn("ram_gb", applicable_fields(screen))
|
||||||
|
self.assertIn("print_technology", DeviceForm(instance=printer).fields)
|
||||||
|
self.assertEqual(DeviceSerializer(monitor).data["ports"], "HDMI, DisplayPort")
|
||||||
|
desktop_type, _ = ChoiceValue.objects.get_or_create(kind="device_type", label="Desktop")
|
||||||
|
desktop = Device.objects.create(status=self.status, device_type=desktop_type, ram_gb=32, ports="USB-C, HDMI")
|
||||||
|
self.assertEqual(DesktopSpecification.objects.get(device=desktop).ports, "USB-C, HDMI")
|
||||||
|
self.assertIn(desktop, device_queryset(QueryDict("ram_min=32")))
|
||||||
|
self.assertNotIn(self.device, device_queryset(QueryDict("ram_min=32")))
|
||||||
|
aio_type, _ = ChoiceValue.objects.get_or_create(kind="device_type", label="AiO")
|
||||||
|
aio = Device.objects.create(status=self.status, device_type=aio_type, display_inches="27.0")
|
||||||
|
self.assertEqual(str(AllInOneSpecification.objects.get(device=aio).display_inches), "27.0")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
board = self.client.get(reverse("inventory:workflow-board"))
|
||||||
|
self.assertContains(board, f"#{self.device.number}")
|
||||||
|
self.assertContains(board, "Neue Aufgabe")
|
||||||
|
listing = self.client.get(reverse("inventory:device-list"))
|
||||||
|
self.assertContains(listing, reverse("inventory:device-detail", args=[self.device.pk]))
|
||||||
|
|
||||||
|
def test_board_quick_actions_return_to_board(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
board_url = reverse("inventory:workflow-board")
|
||||||
|
response = self.client.post(reverse("inventory:device-task-add", args=[self.device.pk]), {
|
||||||
|
"title": "Gerät prüfen", "return_to": "workflow",
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, board_url)
|
||||||
|
task = DeviceTask.objects.get(device=self.device)
|
||||||
|
self.assertEqual(task.title, "Gerät prüfen")
|
||||||
|
response = self.client.post(reverse("inventory:device-advance", args=[self.device.pk]), {
|
||||||
|
"stage": Device.Stage.INSPECTION,
|
||||||
|
"expected_revision": self.device.revision,
|
||||||
|
"return_to": "workflow",
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, board_url)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertEqual(self.device.stage, Device.Stage.INSPECTION)
|
||||||
|
board = self.client.get(board_url)
|
||||||
|
self.assertContains(board, "Gerät prüfen")
|
||||||
|
self.assertContains(board, f"#{self.device.number}")
|
||||||
|
|
||||||
|
def test_stage_forms_capture_inspection_repair_and_recipient(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.device = advance_device(self.device.pk, Device.Stage.INSPECTION, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
board_url = reverse("inventory:workflow-board")
|
||||||
|
board = self.client.get(board_url)
|
||||||
|
self.assertContains(board, "Festgestellte Mängel")
|
||||||
|
self.assertContains(board, 'name="ram_gb"')
|
||||||
|
inspection_url = reverse("inventory:device-inspection-save", args=[self.device.pk])
|
||||||
|
response = self.client.post(inspection_url, {
|
||||||
|
"defects": "Akku defekt", "model": "ThinkPad", "cpu": "Core i5", "ram_gb": "16",
|
||||||
|
"ssd_gb": "512", "battery": "NO", "wifi": "YES", "lan": "YES",
|
||||||
|
"expected_revision": self.device.revision, "return_to": "workflow",
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, board_url)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertEqual(self.device.ram_gb, 16)
|
||||||
|
self.assertEqual(self.device.model, "ThinkPad")
|
||||||
|
self.assertEqual(InspectionRecord.objects.get(device=self.device).defects, "Akku defekt")
|
||||||
|
stale = self.client.post(inspection_url, {
|
||||||
|
"defects": "Falscher Befund", "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN",
|
||||||
|
"expected_revision": self.device.revision - 1,
|
||||||
|
})
|
||||||
|
self.assertEqual(stale.status_code, 302)
|
||||||
|
self.assertEqual(InspectionRecord.objects.count(), 1)
|
||||||
|
self.device = advance_device(self.device.pk, Device.Stage.REPAIR, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
response = self.client.post(reverse("inventory:device-repair-add", args=[self.device.pk]), {
|
||||||
|
"work_done": "Akku ersetzt", "expected_revision": self.device.revision, "return_to": "workflow",
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, board_url)
|
||||||
|
self.assertEqual(RepairRecord.objects.get(device=self.device).work_done, "Akku ersetzt")
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.device = advance_device(self.device.pk, Device.Stage.STORAGE, user=self.user,
|
||||||
|
expected_revision=self.device.revision)
|
||||||
|
response = self.client.post(reverse("inventory:device-storage-assign", args=[self.device.pk]), {
|
||||||
|
"recipient": "Verein Nord", "expected_revision": self.device.revision,
|
||||||
|
"return_to": "workflow",
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, board_url)
|
||||||
|
self.device.refresh_from_db()
|
||||||
|
self.assertEqual(self.device.recipient.label, "Verein Nord")
|
||||||
|
board = self.client.get(board_url)
|
||||||
|
self.assertContains(board, "Verein Nord")
|
||||||
|
|
||||||
|
def test_inspection_respects_device_type_and_viewer_is_read_only(self):
|
||||||
|
printer = Device.objects.create(status=self.status, device_type=self.printer)
|
||||||
|
printer = advance_device(printer.pk, Device.Stage.INSPECTION, user=self.user,
|
||||||
|
expected_revision=printer.revision)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
board = self.client.get(reverse("inventory:workflow-board"))
|
||||||
|
self.assertContains(board, "Festgestellte Mängel")
|
||||||
|
self.assertNotContains(board, 'name="ram_gb"')
|
||||||
|
response = self.client.post(reverse("inventory:device-inspection-save", args=[printer.pk]), {
|
||||||
|
"defects": "Papierstau", "ram_gb": "64", "wifi": "YES", "lan": "NO",
|
||||||
|
"duplex": "UNKNOWN", "color_printing": "UNKNOWN",
|
||||||
|
"expected_revision": printer.revision,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
printer.refresh_from_db()
|
||||||
|
self.assertIsNone(printer.ram_gb)
|
||||||
|
self.assertEqual(printer.wifi, Device.TriState.YES)
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
response = self.client.post(reverse("inventory:device-repair-add", args=[printer.pk]), {
|
||||||
|
"work_done": "Unberechtigt", "expected_revision": printer.revision,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertFalse(RepairRecord.objects.exists())
|
||||||
662
inventory/tests.py
Normal file
662
inventory/tests.py
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.utils import timezone
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
|
||||||
|
from .forms import DeviceForm
|
||||||
|
from .import_export import import_workbook
|
||||||
|
from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, InventoryAPIKey, NumberSequence, UserPreference, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryTestCase(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
call_command("seed_initial_data", verbosity=0)
|
||||||
|
cls.status = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Eingang")
|
||||||
|
cls.done = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Fertig")
|
||||||
|
cls.device_type = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Laptop")
|
||||||
|
cls.employee = Employee.objects.get(code="JH")
|
||||||
|
cls.admin = User.objects.create_user("admin", password="strong-test-password", is_superuser=True, is_staff=True)
|
||||||
|
cls.editor = User.objects.create_user("editor", password="strong-test-password")
|
||||||
|
cls.editor.profile.role = UserProfile.Role.EDITOR
|
||||||
|
cls.editor.profile.save()
|
||||||
|
cls.viewer = User.objects.create_user("viewer", password="strong-test-password")
|
||||||
|
|
||||||
|
def make_device(self, **kwargs):
|
||||||
|
defaults = {"status": self.status, "model": "Lenovo ThinkPad P50", "cpu": "Intel Core i5-8250U"}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return Device.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_device_number_is_automatic_unique_and_not_reused_after_archive(self):
|
||||||
|
first = self.make_device()
|
||||||
|
second = self.make_device()
|
||||||
|
self.assertEqual(second.number, first.number + 1)
|
||||||
|
first.archived_at = first.updated_at
|
||||||
|
first.save()
|
||||||
|
third = self.make_device()
|
||||||
|
self.assertEqual(third.number, second.number + 1)
|
||||||
|
self.assertEqual(Device.objects.values("number").distinct().count(), 3)
|
||||||
|
|
||||||
|
def test_imported_high_number_advances_sequence(self):
|
||||||
|
imported = self.make_device(number=3068)
|
||||||
|
following = self.make_device()
|
||||||
|
self.assertEqual(following.number, 3069)
|
||||||
|
|
||||||
|
def test_quick_add_creates_device_and_audit(self):
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(reverse("inventory:quick-add"), {"number": 25, "quantity": 1, "shelf": "A1", "status": self.status.pk, "date": "19.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "donor": "Testspender"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
device = Device.objects.get(shelf="A1")
|
||||||
|
self.assertEqual(device.number, 25)
|
||||||
|
self.assertEqual(device.date, date(2026, 9, 19))
|
||||||
|
self.assertEqual(device.device_type, self.device_type)
|
||||||
|
self.assertEqual(device.employee, self.employee)
|
||||||
|
self.assertEqual(device.donor, "Testspender")
|
||||||
|
self.assertTrue(AuditLog.objects.filter(device=device, action=AuditLog.Action.CREATE).exists())
|
||||||
|
self.assertEqual(response["Location"], reverse("inventory:quick-add"))
|
||||||
|
|
||||||
|
duplicate = self.client.post(reverse("inventory:quick-add"), {"number": 25, "quantity": 1, "shelf": "A2", "status": self.status.pk, "date": "19.09.2026", "device_type": "", "employee": self.employee.pk, "donor": ""})
|
||||||
|
self.assertEqual(duplicate.status_code, 200)
|
||||||
|
self.assertContains(duplicate, "bereits vergeben")
|
||||||
|
self.assertEqual(Device.objects.filter(number=25).count(), 1)
|
||||||
|
|
||||||
|
def test_quick_add_prefills_next_number_and_today(self):
|
||||||
|
self.make_device(number=40)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.get(reverse("inventory:quick-add"))
|
||||||
|
self.assertContains(response, 'name="number"')
|
||||||
|
self.assertContains(response, 'value="41"')
|
||||||
|
self.assertContains(response, timezone.localdate().strftime("%d.%m.%Y"))
|
||||||
|
self.assertContains(response, "TT.MM.JJJJ")
|
||||||
|
|
||||||
|
def test_quick_add_shows_type_fields_and_saves_hardware(self):
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
page = self.client.get(reverse("inventory:quick-add"))
|
||||||
|
self.assertContains(page, 'data-device-type-form')
|
||||||
|
self.assertContains(page, 'data-hardware-field="ram_gb"')
|
||||||
|
self.assertContains(page, 'id="device-type-fields"')
|
||||||
|
response = self.client.post(reverse("inventory:quick-add"), {
|
||||||
|
"number": 45, "quantity": 2, "status": self.status.pk,
|
||||||
|
"date": "28.09.2026", "device_type": self.device_type.pk,
|
||||||
|
"employee": self.employee.pk, "model": "ThinkPad T14",
|
||||||
|
"cpu": "Ryzen 5", "ram_gb": "16", "ssd_gb": "512",
|
||||||
|
"battery": "YES", "wifi": "YES", "lan": "NO", "ports": "USB-C, HDMI",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
devices = list(Device.objects.filter(number__in=(45, 46)).order_by("number"))
|
||||||
|
self.assertEqual(len(devices), 2)
|
||||||
|
self.assertTrue(all(device.ram_gb == 16 and device.ssd_gb == 512 for device in devices))
|
||||||
|
self.assertTrue(all(device.ports == "USB-C, HDMI" and device.model == "ThinkPad T14" for device in devices))
|
||||||
|
|
||||||
|
printer = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Drucker")
|
||||||
|
response = self.client.post(reverse("inventory:quick-add"), {
|
||||||
|
"number": 47, "quantity": 1, "status": self.status.pk,
|
||||||
|
"date": "28.09.2026", "device_type": printer.pk,
|
||||||
|
"employee": self.employee.pk, "print_technology": "Laser",
|
||||||
|
"duplex": "YES", "color_printing": "NO", "ram_gb": "999",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
device = Device.objects.get(number=47)
|
||||||
|
self.assertEqual(device.print_technology, "Laser")
|
||||||
|
self.assertIsNone(device.ram_gb)
|
||||||
|
|
||||||
|
def test_quick_add_creates_consecutive_device_batch(self):
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(reverse("inventory:quick-add"), {
|
||||||
|
"number": 50, "quantity": 4, "shelf": "B2", "status": self.status.pk,
|
||||||
|
"date": "19.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "donor": "Serienspender",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
devices = list(Device.objects.filter(number__range=(50, 53)).order_by("number"))
|
||||||
|
self.assertEqual([device.number for device in devices], [50, 51, 52, 53])
|
||||||
|
self.assertTrue(all(device.device_type == self.device_type for device in devices))
|
||||||
|
self.assertTrue(all(device.employee == self.employee for device in devices))
|
||||||
|
self.assertTrue(all(device.donor == "Serienspender" for device in devices))
|
||||||
|
self.assertEqual(AuditLog.objects.filter(device__in=devices, action=AuditLog.Action.CREATE).count(), 4)
|
||||||
|
self.assertEqual(NumberSequence.suggested_device_number(), 54)
|
||||||
|
|
||||||
|
def test_quick_add_batch_rejects_number_range_conflict(self):
|
||||||
|
self.make_device(number=62)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(reverse("inventory:quick-add"), {
|
||||||
|
"number": 60, "quantity": 5, "shelf": "", "status": self.status.pk,
|
||||||
|
"date": "19.09.2026", "device_type": "", "employee": self.employee.pk, "donor": "",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Gerätenummer 62 ist bereits vergeben")
|
||||||
|
self.assertFalse(Device.objects.filter(number__in=[60, 61, 63, 64]).exists())
|
||||||
|
|
||||||
|
def test_viewer_cannot_create_or_inline_edit(self):
|
||||||
|
device = self.make_device(ram_gb=4)
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
self.assertEqual(self.client.get(reverse("inventory:quick-add")).status_code, 403)
|
||||||
|
response = self.client.post(reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": 8}), content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.ram_gb, 4)
|
||||||
|
|
||||||
|
def test_quick_add_requires_employee(self):
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(reverse("inventory:quick-add"), {
|
||||||
|
"number": 70, "quantity": 1, "shelf": "", "status": self.status.pk,
|
||||||
|
"date": "19.09.2026", "device_type": self.device_type.pk, "employee": "", "donor": "",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Bitte einen Bearbeiter auswählen")
|
||||||
|
self.assertFalse(Device.objects.filter(number=70).exists())
|
||||||
|
|
||||||
|
def test_inline_edit_validates_updates_and_audits(self):
|
||||||
|
device = self.make_device(ram_gb=4)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inventory:inline-update", args=[device.pk]),
|
||||||
|
data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.ram_gb, 8)
|
||||||
|
self.assertTrue(AuditLog.objects.filter(device=device, field="RAM", old_value="4", new_value="8").exists())
|
||||||
|
|
||||||
|
def test_inline_date_accepts_and_displays_german_format(self):
|
||||||
|
device = self.make_device(date=date(2026, 9, 1))
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inventory:inline-update", args=[device.pk]),
|
||||||
|
data=json.dumps({"field": "date", "value": "19.09.2026", "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["display"], "19.09.2026")
|
||||||
|
self.assertEqual(response.json()["value"], "2026-09-19")
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.date, date(2026, 9, 19))
|
||||||
|
|
||||||
|
def test_editor_can_bulk_update_status_and_recipient(self):
|
||||||
|
recipient = ChoiceValue.objects.get(kind=ChoiceValue.Kind.RECIPIENT, label="NGO")
|
||||||
|
first = self.make_device(number=7201)
|
||||||
|
second = self.make_device(number=7202)
|
||||||
|
untouched = self.make_device(number=7203)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("inventory:bulk-update-devices"), {
|
||||||
|
"device_ids": [first.pk, second.pk],
|
||||||
|
"status": self.done.pk,
|
||||||
|
"recipient": recipient.pk,
|
||||||
|
"return_query": f"status={self.status.pk}&sort=number&dir=asc",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn(f"status={self.status.pk}", response["Location"])
|
||||||
|
first.refresh_from_db(); second.refresh_from_db(); untouched.refresh_from_db()
|
||||||
|
self.assertEqual((first.status, second.status), (self.done, self.done))
|
||||||
|
self.assertEqual((first.recipient, second.recipient), (recipient, recipient))
|
||||||
|
self.assertEqual(untouched.status, self.status)
|
||||||
|
self.assertIsNone(untouched.recipient)
|
||||||
|
self.assertEqual(AuditLog.objects.filter(device__in=[first, second], field="Status").count(), 2)
|
||||||
|
self.assertEqual(AuditLog.objects.filter(device__in=[first, second], field="Empfänger").count(), 2)
|
||||||
|
|
||||||
|
def test_viewer_cannot_bulk_update_devices(self):
|
||||||
|
device = self.make_device(number=7210)
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
response = self.client.post(reverse("inventory:bulk-update-devices"), {
|
||||||
|
"device_ids": [device.pk], "status": self.done.pk,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.status, self.status)
|
||||||
|
|
||||||
|
def test_detail_navigation_respects_current_filters_and_sorting(self):
|
||||||
|
first = self.make_device(number=7301, status=self.done)
|
||||||
|
second = self.make_device(number=7302, status=self.done)
|
||||||
|
self.make_device(number=7303, status=self.status)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("inventory:device-detail", args=[first.pk]), {
|
||||||
|
"status": self.done.pk, "sort": "number", "dir": "asc",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.context["previous_device_id"])
|
||||||
|
self.assertEqual(response.context["next_device_id"], second.pk)
|
||||||
|
self.assertEqual(response.context["navigation_position"], 1)
|
||||||
|
self.assertEqual(response.context["navigation_total"], 2)
|
||||||
|
self.assertContains(response, f"/devices/{second.pk}/?status={self.done.pk}")
|
||||||
|
|
||||||
|
def test_editor_can_revert_a_single_change_from_history(self):
|
||||||
|
device = self.make_device(ram_gb=4)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
self.client.post(
|
||||||
|
reverse("inventory:inline-update", args=[device.pk]),
|
||||||
|
data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
change = AuditLog.objects.get(device=device, field="RAM", old_value="4", new_value="8")
|
||||||
|
|
||||||
|
response = self.client.post(reverse("inventory:audit-revert", args=[change.pk]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
device.refresh_from_db()
|
||||||
|
change.refresh_from_db()
|
||||||
|
self.assertEqual(device.ram_gb, 4)
|
||||||
|
self.assertIsNotNone(change.reverted_at)
|
||||||
|
self.assertEqual(change.reverted_by, self.editor)
|
||||||
|
self.assertTrue(AuditLog.objects.filter(device=device, field_key="ram_gb", old_value="8", new_value="4").exists())
|
||||||
|
|
||||||
|
def test_old_change_is_not_reverted_after_same_field_changed_again(self):
|
||||||
|
device = self.make_device(ram_gb=4)
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
first = self.client.post(
|
||||||
|
reverse("inventory:inline-update", args=[device.pk]),
|
||||||
|
data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
device.refresh_from_db()
|
||||||
|
old_change = AuditLog.objects.get(device=device, field="RAM", old_value="4", new_value="8")
|
||||||
|
self.client.post(
|
||||||
|
reverse("inventory:inline-update", args=[device.pk]),
|
||||||
|
data=json.dumps({"field": "ram_gb", "value": "16", "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.post(reverse("inventory:audit-revert", args=[old_change.pk]))
|
||||||
|
|
||||||
|
device.refresh_from_db()
|
||||||
|
old_change.refresh_from_db()
|
||||||
|
self.assertEqual(first.status_code, 200)
|
||||||
|
self.assertEqual(device.ram_gb, 16)
|
||||||
|
self.assertIsNone(old_change.reverted_at)
|
||||||
|
|
||||||
|
def test_optimistic_concurrency_returns_conflict(self):
|
||||||
|
device = self.make_device(ram_gb=4)
|
||||||
|
stale = device.updated_at.isoformat()
|
||||||
|
device.ram_gb = 8
|
||||||
|
device.save()
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
response = self.client.post(reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": 16, "expected_updated_at": stale}), content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
|
||||||
|
def test_search_matches_only_device_number_prefix(self):
|
||||||
|
self.make_device(number=1234, model="Lenovo ThinkPad P50", ram_gb=16, status=self.done, device_type=self.device_type)
|
||||||
|
self.make_device(number=1299, model="Dell OptiPlex", ram_gb=4)
|
||||||
|
self.make_device(number=9800, model="Another ThinkPad", ram_gb=16)
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
response = self.client.get(reverse("inventory:device-list"), {"q": "12", "ram_min": "8", "sort": "ram_gb", "dir": "asc"})
|
||||||
|
self.assertContains(response, "ThinkPad P50")
|
||||||
|
self.assertNotContains(response, "Dell OptiPlex")
|
||||||
|
response = self.client.get(reverse("inventory:device-list"), {"q": "ThinkPad"})
|
||||||
|
self.assertNotContains(response, "ThinkPad P50")
|
||||||
|
self.assertNotContains(response, "Another ThinkPad")
|
||||||
|
exact = self.client.get(reverse("inventory:device-list"), {"q": "12", "exact": "1"})
|
||||||
|
self.assertNotContains(exact, "ThinkPad P50")
|
||||||
|
exact = self.client.get(reverse("inventory:device-list"), {"q": "1234", "exact": "1"})
|
||||||
|
self.assertContains(exact, "ThinkPad P50")
|
||||||
|
self.assertNotContains(exact, "Dell OptiPlex")
|
||||||
|
|
||||||
|
def test_cpu_autocomplete_uses_masterlist_and_ranks_likely_match_first(self):
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
intel = self.client.get(reverse("inventory:autocomplete", args=["cpu"]), {"q": "i5 8500"})
|
||||||
|
amd = self.client.get(reverse("inventory:autocomplete", args=["cpu"]), {"q": "ryzen 5 5600x"})
|
||||||
|
|
||||||
|
self.assertEqual(intel.status_code, 200)
|
||||||
|
self.assertEqual(intel.json()["results"][0]["label"], "Intel Core i5-8500")
|
||||||
|
self.assertEqual(amd.json()["results"][0]["label"], "AMD Ryzen 5 5600X")
|
||||||
|
self.assertGreaterEqual(len(intel.json()["results"]), 2)
|
||||||
|
|
||||||
|
def test_cpu_vendor_family_and_free_text_filters_can_be_combined(self):
|
||||||
|
self.make_device(number=7101, cpu="Intel Core i5-8500", model="INTEL-I5-TREFFER")
|
||||||
|
self.make_device(number=7102, cpu="Intel Core i7-8700", model="INTEL-I7")
|
||||||
|
self.make_device(number=7103, cpu="AMD Ryzen 5 5600X", model="AMD-R5-TREFFER")
|
||||||
|
self.make_device(number=7104, cpu="AMD Ryzen 9 5950X", model="AMD-R9")
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
|
||||||
|
amd = self.client.get(reverse("inventory:device-list"), {"cpu_vendor": "amd", "cpu_family": "amd_ryzen_5"})
|
||||||
|
self.assertContains(amd, "AMD-R5-TREFFER")
|
||||||
|
self.assertNotContains(amd, "AMD-R9")
|
||||||
|
self.assertNotContains(amd, "INTEL-I5-TREFFER")
|
||||||
|
|
||||||
|
intel = self.client.get(reverse("inventory:device-list"), {"cpu_vendor": "intel", "cpu_family": ["intel_i5", "intel_i7"], "cpu_contains": "i5 8500"})
|
||||||
|
self.assertContains(intel, "INTEL-I5-TREFFER")
|
||||||
|
self.assertNotContains(intel, "INTEL-I7")
|
||||||
|
self.assertContains(intel, "data-cpu-autocomplete")
|
||||||
|
|
||||||
|
def test_overview_aggregates_recent_devices_and_is_available_to_viewers(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
month_index = today.year * 12 + today.month - 1 - 3
|
||||||
|
previous_period_date = date(month_index // 12, month_index % 12 + 1, 1)
|
||||||
|
desktop = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Desktop")
|
||||||
|
self.make_device(date=today, device_type=self.device_type)
|
||||||
|
self.make_device(date=today, device_type=self.device_type, status=self.done)
|
||||||
|
self.make_device(date=today, device_type=desktop)
|
||||||
|
self.make_device(date=previous_period_date, device_type=desktop)
|
||||||
|
|
||||||
|
self.client.force_login(self.viewer)
|
||||||
|
response = self.client.get(reverse("inventory:overview"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.context["period_months"], 3)
|
||||||
|
self.assertEqual(response.context["period_count"], 3)
|
||||||
|
self.assertEqual(response.context["period_laptops"], 2)
|
||||||
|
self.assertEqual(response.context["period_finished"], 1)
|
||||||
|
self.assertEqual(len(response.context["month_series"]), 12)
|
||||||
|
self.assertContains(response, "Geräteeingänge")
|
||||||
|
self.assertContains(response, "Datenvollständigkeit")
|
||||||
|
|
||||||
|
def test_device_form_uses_german_labels(self):
|
||||||
|
form = DeviceForm()
|
||||||
|
self.assertEqual(form.fields["device_type"].label, "Typ")
|
||||||
|
self.assertEqual(form.fields["employee"].label, "Bearbeiter")
|
||||||
|
self.assertEqual(form.fields["comment"].label, "Kommentar")
|
||||||
|
|
||||||
|
def test_archive_and_restore(self):
|
||||||
|
device = self.make_device()
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
self.client.post(reverse("inventory:archive-device", args=[device.pk]))
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertIsNotNone(device.archived_at)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
self.client.post(reverse("inventory:restore-device", args=[device.pk]))
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertIsNone(device.archived_at)
|
||||||
|
self.assertEqual(list(device.audit_entries.values_list("action", flat=True))[:2], [AuditLog.Action.RESTORE, AuditLog.Action.ARCHIVE])
|
||||||
|
|
||||||
|
def test_custom_field_saved_without_migration(self):
|
||||||
|
definition = FieldDefinition.objects.create(name="Windows-Version", key="windows-version", field_type=FieldDefinition.FieldType.TEXT, required=True)
|
||||||
|
device = self.make_device()
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
form_data = {
|
||||||
|
"shelf": "", "status": self.status.pk, "date": "2026-09-19", "recipient": "", "location": "",
|
||||||
|
"ticket": "", "device_type": "", "price": "", "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN",
|
||||||
|
"display_inches": "", "ram_gb": "", "ssd_gb": "", "hdd_gb": "", "cpu": "", "model": "",
|
||||||
|
"employee": "", "donor": "", "comment": "", "custom_windows-version": "Windows 11",
|
||||||
|
"expected_updated_at": device.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("inventory:device-detail", args=[device.pk]), form_data)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.custom_values[definition.key], "Windows 11")
|
||||||
|
|
||||||
|
def test_excel_import_transaction_and_number_preservation(self):
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.append(["Nr.", "Status", "Modell", "Bearbeiter"])
|
||||||
|
sheet.append([4200, "Eingang", "Lenovo ThinkPad", "RG"])
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
buffer.seek(0)
|
||||||
|
result = import_workbook(buffer, {0: "number", 1: "status", 2: "model", 3: "employee"}, self.admin)
|
||||||
|
self.assertEqual(result["created"], 1)
|
||||||
|
imported = Device.objects.get(number=4200)
|
||||||
|
self.assertEqual(imported.employee.code, "RG")
|
||||||
|
self.assertEqual(self.make_device().number, 4201)
|
||||||
|
|
||||||
|
def test_excel_import_rolls_back_on_invalid_row(self):
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.append(["Nr.", "Status", "Bearbeiter"])
|
||||||
|
sheet.append([5001, "Eingang", "RG"])
|
||||||
|
sheet.append([5002, "Eingang", "UNBEKANNT"])
|
||||||
|
buffer = BytesIO(); workbook.save(buffer); buffer.seek(0)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
import_workbook(buffer, {0: "number", 1: "status", 2: "employee"}, self.admin)
|
||||||
|
self.assertFalse(Device.objects.filter(number__in=[5001, 5002]).exists())
|
||||||
|
|
||||||
|
def test_excel_upload_mapping_workflow(self):
|
||||||
|
workbook = Workbook(); sheet = workbook.active
|
||||||
|
sheet.append(["Nr.", "Status", "Modell"]); sheet.append([7001, "Eingang", "Importgerät"])
|
||||||
|
buffer = BytesIO(); workbook.save(buffer)
|
||||||
|
upload = SimpleUploadedFile("geraete.xlsx", buffer.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
with tempfile.TemporaryDirectory() as media_root, self.settings(MEDIA_ROOT=media_root):
|
||||||
|
response = self.client.post(reverse("inventory:import-excel"), {"file": upload})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
mapping_url = response["Location"]
|
||||||
|
self.assertEqual(self.client.get(mapping_url).status_code, 200)
|
||||||
|
response = self.client.post(mapping_url, {"column_0": "number", "column_1": "status", "column_2": "model"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertTrue(Device.objects.filter(number=7001, model="Importgerät").exists())
|
||||||
|
|
||||||
|
def test_csv_xlsx_and_ods_export(self):
|
||||||
|
self.make_device(model="Exportmodell")
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
csv_response = self.client.get(reverse("inventory:export-devices", args=["csv"]))
|
||||||
|
self.assertEqual(csv_response.status_code, 200)
|
||||||
|
self.assertIn("Exportmodell", csv_response.content.decode("utf-8-sig"))
|
||||||
|
xlsx = self.client.get(reverse("inventory:export-devices", args=["xlsx"]))
|
||||||
|
workbook = load_workbook(BytesIO(xlsx.content), read_only=True)
|
||||||
|
self.assertEqual(workbook.active["A1"].value, "Nr.")
|
||||||
|
ods = self.client.get(reverse("inventory:export-devices", args=["ods"]))
|
||||||
|
self.assertEqual(ods.status_code, 200)
|
||||||
|
self.assertEqual(ods["Content-Type"], "application/vnd.oasis.opendocument.spreadsheet")
|
||||||
|
with zipfile.ZipFile(BytesIO(ods.content)) as archive:
|
||||||
|
self.assertEqual(archive.namelist()[0], "mimetype")
|
||||||
|
self.assertEqual(archive.read("mimetype"), b"application/vnd.oasis.opendocument.spreadsheet")
|
||||||
|
content = archive.read("content.xml").decode("utf-8")
|
||||||
|
self.assertIn("Exportmodell", content)
|
||||||
|
self.assertIn('table:name="Geräte"', content)
|
||||||
|
|
||||||
|
def test_admin_can_change_status_color_and_list_uses_it(self):
|
||||||
|
self.make_device(status=self.status)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(reverse("inventory:choice-edit", args=[self.status.pk]), {
|
||||||
|
"kind": ChoiceValue.Kind.STATUS,
|
||||||
|
"label": self.status.label,
|
||||||
|
"color": "#be185d",
|
||||||
|
"numeric_value": "",
|
||||||
|
"active": "on",
|
||||||
|
"sort_order": self.status.sort_order,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.status.refresh_from_db()
|
||||||
|
self.assertEqual(self.status.color, "#be185d")
|
||||||
|
|
||||||
|
device_list = self.client.get(reverse("inventory:device-list"))
|
||||||
|
self.assertContains(device_list, "device-list-page")
|
||||||
|
self.assertContains(device_list, "--status-color:#be185d")
|
||||||
|
self.assertContains(device_list, "Als ODS für LibreOffice")
|
||||||
|
|
||||||
|
def test_column_order_is_saved_per_user_and_rendered_for_the_list(self):
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
order = ["status", "number", "model", "cpu"]
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inventory:save-preferences"),
|
||||||
|
data=json.dumps({"column_order": order}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(UserPreference.objects.get(user=self.editor).column_order, order)
|
||||||
|
|
||||||
|
device_list = self.client.get(reverse("inventory:device-list"))
|
||||||
|
self.assertContains(device_list, 'id="server-column-order"')
|
||||||
|
self.assertContains(device_list, "Bearbeiter")
|
||||||
|
for column in [
|
||||||
|
"number", "shelf", "status", "date", "recipient", "location", "ticket", "device_type",
|
||||||
|
"price", "battery", "wifi", "lan", "display_inches", "ram_gb", "ssd_gb", "hdd_gb",
|
||||||
|
"cpu", "model", "employee", "donor", "comment",
|
||||||
|
]:
|
||||||
|
self.assertContains(device_list, f'data-column="{column}"')
|
||||||
|
|
||||||
|
def test_filtered_export_contains_only_matching_date_status_and_type(self):
|
||||||
|
matching = self.make_device(number=8101, date=date(2026, 9, 19), status=self.done, device_type=self.device_type, model="TREFFER")
|
||||||
|
self.make_device(number=8102, date=date(2025, 1, 1), status=self.done, device_type=self.device_type, model="ZU_ALT")
|
||||||
|
self.make_device(number=8103, date=date(2026, 9, 19), status=self.status, device_type=self.device_type, model="FALSCHER_STATUS")
|
||||||
|
self.client.force_login(self.editor)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("inventory:export-devices", args=["csv"]), {
|
||||||
|
"scope": "filtered", "date_from": "01.09.2026", "date_to": "30.09.2026",
|
||||||
|
"status": self.done.pk, "type": self.device_type.pk,
|
||||||
|
})
|
||||||
|
content = response.content.decode("utf-8-sig")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(str(matching.number), content)
|
||||||
|
self.assertIn("TREFFER", content)
|
||||||
|
self.assertNotIn("ZU_ALT", content)
|
||||||
|
self.assertNotIn("FALSCHER_STATUS", content)
|
||||||
|
|
||||||
|
def test_seed_command_is_idempotent(self):
|
||||||
|
counts = (ChoiceValue.objects.count(), Employee.objects.count())
|
||||||
|
call_command("seed_initial_data", verbosity=0)
|
||||||
|
self.assertEqual(counts, (ChoiceValue.objects.count(), Employee.objects.count()))
|
||||||
|
|
||||||
|
def test_screen_and_printer_device_types_are_seeded(self):
|
||||||
|
labels = set(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE).values_list("label", flat=True))
|
||||||
|
self.assertTrue({"Bildschirm", "Drucker"}.issubset(labels))
|
||||||
|
|
||||||
|
def test_admin_can_create_user_with_role(self):
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(reverse("inventory:user-new"), {
|
||||||
|
"username": "new-editor", "first_name": "Neue", "last_name": "Person", "email": "",
|
||||||
|
"is_active": "on", "role": UserProfile.Role.EDITOR, "password": "A-strong-unique-test-password-2026!",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
created = User.objects.get(username="new-editor")
|
||||||
|
self.assertEqual(created.profile.role, UserProfile.Role.EDITOR)
|
||||||
|
self.assertTrue(created.check_password("A-strong-unique-test-password-2026!"))
|
||||||
|
|
||||||
|
def test_admin_can_add_edit_and_remove_employee(self):
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(reverse("inventory:employee-new"), {"code": "NP", "name": "Neue Person", "active": "on"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
employee = Employee.objects.get(code="NP")
|
||||||
|
|
||||||
|
response = self.client.post(reverse("inventory:employee-edit", args=[employee.pk]), {"code": "NP", "name": "Neuer Name", "active": "on"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
employee.refresh_from_db()
|
||||||
|
self.assertEqual(employee.name, "Neuer Name")
|
||||||
|
|
||||||
|
self.make_device(employee=employee)
|
||||||
|
response = self.client.post(reverse("inventory:employee-remove", args=[employee.pk]))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
employee.refresh_from_db()
|
||||||
|
self.assertFalse(employee.active)
|
||||||
|
|
||||||
|
def api_header(self, key):
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Api-Key {key}"}
|
||||||
|
|
||||||
|
def test_api_rejects_anonymous_and_browser_session_access(self):
|
||||||
|
device = self.make_device(number=9101)
|
||||||
|
anonymous = self.client.get(reverse("inventory-api:device-detail", args=[device.number]))
|
||||||
|
self.assertEqual(anonymous.status_code, 401)
|
||||||
|
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
browser_session_only = self.client.get(reverse("inventory-api:device-detail", args=[device.number]))
|
||||||
|
self.assertEqual(browser_session_only.status_code, 401)
|
||||||
|
|
||||||
|
def test_read_only_api_key_can_read_but_not_write(self):
|
||||||
|
device = self.make_device(number=9102, ram_gb=8)
|
||||||
|
_, key = InventoryAPIKey.objects.create_key(
|
||||||
|
name="report-generator", service_user=self.viewer, can_read=True, can_write=False,
|
||||||
|
expiry_date=timezone.now() + timedelta(days=30),
|
||||||
|
)
|
||||||
|
detail_url = reverse("inventory-api:device-detail", args=[device.number])
|
||||||
|
response = self.client.get(detail_url, **self.api_header(key))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["number"], device.number)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
detail_url,
|
||||||
|
data=json.dumps({"ram_gb": 16, "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.api_header(key),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.ram_gb, 8)
|
||||||
|
|
||||||
|
def test_write_api_key_updates_device_and_records_client(self):
|
||||||
|
device = self.make_device(number=9103, ram_gb=8, hwid="old")
|
||||||
|
_, key = InventoryAPIKey.objects.create_key(
|
||||||
|
name="hardware-test-team2", service_user=self.editor, can_read=True, can_write=True,
|
||||||
|
expiry_date=timezone.now() + timedelta(days=30),
|
||||||
|
)
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("inventory-api:device-detail", args=[device.number]),
|
||||||
|
data=json.dumps({
|
||||||
|
"ram_gb": 32, "hwid": "abc-123",
|
||||||
|
"expected_updated_at": device.updated_at.isoformat(),
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.api_header(key),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertEqual(device.ram_gb, 32)
|
||||||
|
self.assertEqual(device.hwid, "abc-123")
|
||||||
|
logs = AuditLog.objects.filter(device=device, api_client="hardware-test-team2")
|
||||||
|
self.assertEqual(logs.count(), 2)
|
||||||
|
self.assertTrue(all(log.user == self.editor for log in logs))
|
||||||
|
|
||||||
|
def test_api_rejects_expired_key_password_fields_and_stale_updates(self):
|
||||||
|
device = self.make_device(number=9104)
|
||||||
|
_, expired_key = InventoryAPIKey.objects.create_key(
|
||||||
|
name="expired", service_user=self.editor, can_read=True,
|
||||||
|
expiry_date=timezone.now() - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
detail_url = reverse("inventory-api:device-detail", args=[device.number])
|
||||||
|
self.assertEqual(self.client.get(detail_url, **self.api_header(expired_key)).status_code, 401)
|
||||||
|
|
||||||
|
_, key = InventoryAPIKey.objects.create_key(
|
||||||
|
name="secure-writer", service_user=self.editor, can_read=True, can_write=True,
|
||||||
|
expiry_date=timezone.now() + timedelta(days=30),
|
||||||
|
)
|
||||||
|
rejected = self.client.patch(
|
||||||
|
detail_url,
|
||||||
|
data=json.dumps({"password": "secret", "expected_updated_at": device.updated_at.isoformat()}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.api_header(key),
|
||||||
|
)
|
||||||
|
self.assertEqual(rejected.status_code, 400)
|
||||||
|
self.assertIn("unbekannte_felder", rejected.json())
|
||||||
|
|
||||||
|
stale_timestamp = device.updated_at.isoformat()
|
||||||
|
device.comment = "andere Änderung"
|
||||||
|
device.save()
|
||||||
|
conflict = self.client.patch(
|
||||||
|
detail_url,
|
||||||
|
data=json.dumps({"ram_gb": 16, "expected_updated_at": stale_timestamp}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.api_header(key),
|
||||||
|
)
|
||||||
|
self.assertEqual(conflict.status_code, 409)
|
||||||
|
|
||||||
|
def test_api_bulk_update_is_atomic(self):
|
||||||
|
first = self.make_device(number=9110)
|
||||||
|
second = self.make_device(number=9111)
|
||||||
|
recipient = ChoiceValue.objects.get(kind=ChoiceValue.Kind.RECIPIENT, label="NGO")
|
||||||
|
_, key = InventoryAPIKey.objects.create_key(
|
||||||
|
name="bulk-team2", service_user=self.editor, can_read=True, can_write=True,
|
||||||
|
expiry_date=timezone.now() + timedelta(days=30),
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inventory-api:device-bulk-update"),
|
||||||
|
data=json.dumps({
|
||||||
|
"device_numbers": [first.number, second.number],
|
||||||
|
"changes": {"status": self.done.pk, "recipient": recipient.pk},
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.api_header(key),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
first.refresh_from_db(); second.refresh_from_db()
|
||||||
|
self.assertEqual(first.status, self.done)
|
||||||
|
self.assertEqual(second.recipient, recipient)
|
||||||
|
|
||||||
|
failed = self.client.post(
|
||||||
|
reverse("inventory-api:device-bulk-update"),
|
||||||
|
data=json.dumps({"device_numbers": [first.number, 999999], "changes": {"ram_gb": 64}}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.api_header(key),
|
||||||
|
)
|
||||||
|
self.assertEqual(failed.status_code, 400)
|
||||||
|
first.refresh_from_db()
|
||||||
|
self.assertNotEqual(first.ram_gb, 64)
|
||||||
43
inventory/urls.py
Normal file
43
inventory/urls.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "inventory"
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.device_list, name="device-list"),
|
||||||
|
path("uebersicht/", views.overview, name="overview"),
|
||||||
|
path("ablauf/", views.workflow_board, name="workflow-board"),
|
||||||
|
path("aufgaben/", views.task_list, name="task-list"),
|
||||||
|
path("devices/new/", views.quick_add, name="quick-add"),
|
||||||
|
path("devices/bulk-update/", views.bulk_update_devices, name="bulk-update-devices"),
|
||||||
|
path("devices/<int:pk>/", views.device_detail, name="device-detail"),
|
||||||
|
path("devices/<int:pk>/stage/", views.device_advance, name="device-advance"),
|
||||||
|
path("devices/<int:pk>/inspection/", views.device_inspection_save, name="device-inspection-save"),
|
||||||
|
path("devices/<int:pk>/repairs/", views.device_repair_add, name="device-repair-add"),
|
||||||
|
path("devices/<int:pk>/recipient/", views.device_storage_assign, name="device-storage-assign"),
|
||||||
|
path("devices/<int:pk>/tasks/", views.device_task_add, name="device-task-add"),
|
||||||
|
path("devices/<int:pk>/tasks/<int:task_id>/complete/", views.device_task_complete, name="device-task-complete"),
|
||||||
|
path("devices/<int:pk>/inline/", views.inline_update, name="inline-update"),
|
||||||
|
path("devices/<int:pk>/archive/", views.archive_device, name="archive-device"),
|
||||||
|
path("archive/", views.archive_list, name="archive-list"),
|
||||||
|
path("archive/<int:pk>/restore/", views.restore_device, name="restore-device"),
|
||||||
|
path("autocomplete/<str:field>/", views.autocomplete, name="autocomplete"),
|
||||||
|
path("history/", views.audit_list, name="audit-list"),
|
||||||
|
path("history/<int:pk>/revert/", views.audit_revert, name="audit-revert"),
|
||||||
|
path("configuration/", views.configuration, name="configuration"),
|
||||||
|
path("configuration/employee/new/", views.employee_edit, name="employee-new"),
|
||||||
|
path("configuration/employee/<int:pk>/", views.employee_edit, name="employee-edit"),
|
||||||
|
path("configuration/employee/<int:pk>/remove/", views.employee_remove, name="employee-remove"),
|
||||||
|
path("users/", views.user_list, name="user-list"),
|
||||||
|
path("users/new/", views.user_edit, name="user-new"),
|
||||||
|
path("users/<int:pk>/", views.user_edit, name="user-edit"),
|
||||||
|
path("configuration/value/new/", views.choice_edit, name="choice-new"),
|
||||||
|
path("configuration/value/<int:pk>/", views.choice_edit, name="choice-edit"),
|
||||||
|
path("configuration/field/new/", views.field_edit, name="field-new"),
|
||||||
|
path("configuration/field/<int:pk>/", views.field_edit, name="field-edit"),
|
||||||
|
path("import/", views.import_excel, name="import-excel"),
|
||||||
|
path("import/<int:pk>/mapping/", views.import_map, name="import-map"),
|
||||||
|
path("export/<str:file_format>/", views.export_devices, name="export-devices"),
|
||||||
|
path("preferences/", views.save_preferences, name="save-preferences"),
|
||||||
|
path("health/", views.health, name="health"),
|
||||||
|
]
|
||||||
868
inventory/views.py
Normal file
868
inventory/views.py
Normal file
@@ -0,0 +1,868 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db import IntegrityError, models, transaction
|
||||||
|
from django.db.models import Count, Prefetch, Q
|
||||||
|
from django.db.models.functions import TruncMonth
|
||||||
|
from django.http import FileResponse, HttpRequest, HttpResponse, JsonResponse, QueryDict
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
|
from .cpu_catalog import CPU_FAMILY_CHOICES, CPU_VENDOR_CHOICES, suggest_cpus
|
||||||
|
from .device_types import COMPUTER_FIELDS, HARDWARE_FIELDS, applicable_fields, clear_inapplicable
|
||||||
|
from .forms import ChoiceValueForm, DeviceForm, EmployeeForm, ExcelUploadForm, FieldDefinitionForm, InspectionForm, QuickAddForm, RepairForm, StorageRecipientForm, UserManagementForm
|
||||||
|
from .import_export import analyze_workbook, csv_response, import_workbook, ods_response, suggested_mapping, xlsx_response
|
||||||
|
from .models import AuditLog, AllInOneSpecification, ChoiceValue, ComputerSpecification, DesktopSpecification, Device, DeviceTask, Employee, FieldDefinition, ImportJob, InspectionRecord, LaptopSpecification, NumberSequence, RepairRecord, UserPreference, UserProfile
|
||||||
|
from .permissions import can_edit, is_admin, role_required
|
||||||
|
from .query import COMPUTER_RELATIONS, SPECIFICATION_RELATIONS, device_queryset
|
||||||
|
from .services import (
|
||||||
|
FIELD_LABELS, add_audit, check_device_version, create_device_batch, display_device_value, display_value,
|
||||||
|
revert_audit_change, serialize_audit_value, update_device_field, validate_custom_value,
|
||||||
|
)
|
||||||
|
from .workflow import TRANSITIONS, advance_device, assign_storage_recipient, complete_task, create_task, record_repair, save_inspection
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_MONTHS = ("", "Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez")
|
||||||
|
CHART_COLORS = ("#0066ff", "#17a673", "#f59e0b", "#8b5cf6", "#ef4444", "#06b6d4", "#64748b", "#ec4899")
|
||||||
|
|
||||||
|
|
||||||
|
def _month_start(value: date, offset: int = 0) -> date:
|
||||||
|
month_index = value.year * 12 + value.month - 1 + offset
|
||||||
|
return date(month_index // 12, month_index % 12 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def overview(request: HttpRequest) -> HttpResponse:
|
||||||
|
try:
|
||||||
|
period_months = int(request.GET.get("months", 3))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
period_months = 3
|
||||||
|
if period_months not in {3, 6, 12}:
|
||||||
|
period_months = 3
|
||||||
|
|
||||||
|
today = timezone.localdate()
|
||||||
|
period_start = _month_start(today, -(period_months - 1))
|
||||||
|
previous_start = _month_start(period_start, -period_months)
|
||||||
|
active_devices = Device.objects.filter(archived_at__isnull=True)
|
||||||
|
arrivals = Device.objects.filter(date__gte=period_start, date__lte=today)
|
||||||
|
previous_arrivals = Device.objects.filter(date__gte=previous_start, date__lt=period_start)
|
||||||
|
|
||||||
|
active_count = active_devices.count()
|
||||||
|
period_count = arrivals.count()
|
||||||
|
previous_count = previous_arrivals.count()
|
||||||
|
difference = period_count - previous_count
|
||||||
|
period_laptops = arrivals.filter(device_type__label__iexact="Laptop").count()
|
||||||
|
period_finished = arrivals.filter(status__label__iexact="Fertig").count()
|
||||||
|
archived_count = Device.objects.filter(archived_at__isnull=False).count()
|
||||||
|
|
||||||
|
monthly_start = _month_start(today, -11)
|
||||||
|
monthly_rows = (
|
||||||
|
Device.objects.filter(date__gte=monthly_start, date__lte=today)
|
||||||
|
.annotate(month=TruncMonth("date"))
|
||||||
|
.values("month")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("month")
|
||||||
|
)
|
||||||
|
monthly_counts = {row["month"].date() if hasattr(row["month"], "date") else row["month"]: row["count"] for row in monthly_rows}
|
||||||
|
month_series = []
|
||||||
|
for offset in range(12):
|
||||||
|
month = _month_start(monthly_start, offset)
|
||||||
|
month_series.append({"label": f"{GERMAN_MONTHS[month.month]} {str(month.year)[2:]}", "count": monthly_counts.get(month, 0)})
|
||||||
|
maximum_month = max((item["count"] for item in month_series), default=0)
|
||||||
|
chart_maximum = max(maximum_month, 1)
|
||||||
|
for index, item in enumerate(month_series):
|
||||||
|
item["x"] = f"{40 + index * (820 / 11):.1f}"
|
||||||
|
item["y"] = f"{205 - item['count'] / chart_maximum * 170:.1f}"
|
||||||
|
line_points = " ".join(f'{item["x"]},{item["y"]}' for item in month_series)
|
||||||
|
area_points = f"40,205 {line_points} 860,205"
|
||||||
|
|
||||||
|
type_rows = list(
|
||||||
|
arrivals.values("device_type__label")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count", "device_type__label")
|
||||||
|
)
|
||||||
|
type_maximum = max((row["count"] for row in type_rows), default=1)
|
||||||
|
for row in type_rows:
|
||||||
|
row["label"] = row.pop("device_type__label") or "Ohne Typ"
|
||||||
|
row["percentage"] = round(row["count"] / type_maximum * 100)
|
||||||
|
|
||||||
|
status_rows = list(
|
||||||
|
active_devices.values("status__label", "status__color")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count", "status__label")
|
||||||
|
)
|
||||||
|
status_total = sum(row["count"] for row in status_rows)
|
||||||
|
gradient_parts = []
|
||||||
|
gradient_cursor = 0.0
|
||||||
|
for index, row in enumerate(status_rows):
|
||||||
|
row["label"] = row.pop("status__label") or "Ohne Status"
|
||||||
|
row["color"] = row.pop("status__color") or CHART_COLORS[index % len(CHART_COLORS)]
|
||||||
|
row["percentage"] = round(row["count"] / status_total * 100) if status_total else 0
|
||||||
|
next_cursor = gradient_cursor + (row["count"] / status_total * 100 if status_total else 0)
|
||||||
|
gradient_parts.append(f'{row["color"]} {gradient_cursor:.2f}% {next_cursor:.2f}%')
|
||||||
|
gradient_cursor = next_cursor
|
||||||
|
status_gradient = ", ".join(gradient_parts) if gradient_parts else "#e5e7eb 0% 100%"
|
||||||
|
|
||||||
|
recipient_rows = list(
|
||||||
|
arrivals.exclude(recipient__isnull=True)
|
||||||
|
.values("recipient__label")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count", "recipient__label")[:7]
|
||||||
|
)
|
||||||
|
recipient_maximum = max((row["count"] for row in recipient_rows), default=1)
|
||||||
|
for row in recipient_rows:
|
||||||
|
row["label"] = row.pop("recipient__label")
|
||||||
|
row["percentage"] = round(row["count"] / recipient_maximum * 100)
|
||||||
|
|
||||||
|
cpu_present = Q()
|
||||||
|
ram_present = Q()
|
||||||
|
storage_present = Q()
|
||||||
|
for relation in COMPUTER_RELATIONS:
|
||||||
|
cpu_present |= Q(**{f"{relation}__cpu__isnull": False}) & ~Q(**{f"{relation}__cpu": ""})
|
||||||
|
ram_present |= Q(**{f"{relation}__ram_gb__isnull": False})
|
||||||
|
storage_present |= Q(**{f"{relation}__ssd_gb__isnull": False}) | Q(**{f"{relation}__hdd_gb__isnull": False})
|
||||||
|
completeness_counts = {
|
||||||
|
"model": active_devices.exclude(model="").count(),
|
||||||
|
"cpu": active_devices.filter(cpu_present).count(),
|
||||||
|
"ram": active_devices.filter(ram_present).count(),
|
||||||
|
"storage": active_devices.filter(storage_present).count(),
|
||||||
|
"employee": active_devices.filter(employee__isnull=False).count(),
|
||||||
|
}
|
||||||
|
completeness = []
|
||||||
|
for key, label in (("model", "Modell"), ("cpu", "Prozessor"), ("ram", "Arbeitsspeicher"), ("storage", "Datenspeicher"), ("employee", "Bearbeiter")):
|
||||||
|
percentage = round(completeness_counts[key] / active_count * 100) if active_count else 0
|
||||||
|
completeness.append({"label": label, "percentage": percentage})
|
||||||
|
|
||||||
|
return render(request, "inventory/overview.html", {
|
||||||
|
"period_months": period_months,
|
||||||
|
"period_start": period_start,
|
||||||
|
"today": today,
|
||||||
|
"active_count": active_count,
|
||||||
|
"period_count": period_count,
|
||||||
|
"period_laptops": period_laptops,
|
||||||
|
"period_finished": period_finished,
|
||||||
|
"archived_count": archived_count,
|
||||||
|
"difference": difference,
|
||||||
|
"month_series": month_series,
|
||||||
|
"maximum_month": maximum_month,
|
||||||
|
"half_month": round(maximum_month / 2),
|
||||||
|
"line_points": line_points,
|
||||||
|
"area_points": area_points,
|
||||||
|
"type_rows": type_rows,
|
||||||
|
"status_rows": status_rows,
|
||||||
|
"status_total": status_total,
|
||||||
|
"status_gradient": status_gradient,
|
||||||
|
"recipient_rows": recipient_rows,
|
||||||
|
"completeness": completeness,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _list_context(request: HttpRequest, archived: bool = False) -> dict:
|
||||||
|
queryset = device_queryset(request.GET, archived=archived)
|
||||||
|
selected_types = request.GET.getlist("type")
|
||||||
|
selected_type = ChoiceValue.objects.filter(
|
||||||
|
pk=selected_types[0], kind=ChoiceValue.Kind.DEVICE_TYPE,
|
||||||
|
).first() if len(selected_types) == 1 and selected_types[0].isascii() and selected_types[0].isdigit() and len(selected_types[0]) <= 18 else None
|
||||||
|
preference, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||||
|
try:
|
||||||
|
page_size = int(request.GET.get("page_size") or preference.page_size)
|
||||||
|
except ValueError:
|
||||||
|
page_size = 50
|
||||||
|
page_size = page_size if page_size in {25, 50, 100} else 50
|
||||||
|
page = Paginator(queryset, page_size).get_page(request.GET.get("page"))
|
||||||
|
params = request.GET.copy()
|
||||||
|
params.pop("page", None)
|
||||||
|
navigation_params = request.GET.copy()
|
||||||
|
for key in ("partial", "exact", "open"):
|
||||||
|
navigation_params.pop(key, None)
|
||||||
|
control_keys = {"q", "exact", "page", "page_size", "sort", "dir", "open", "partial"}
|
||||||
|
has_filters = any(key not in control_keys and value for key in request.GET for value in request.GET.getlist(key))
|
||||||
|
active_filter_count = sum(
|
||||||
|
1 for key in request.GET
|
||||||
|
if key not in control_keys and any(value for value in request.GET.getlist(key))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"page_obj": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"query_string": params.urlencode(),
|
||||||
|
"archived": archived,
|
||||||
|
"can_edit_devices": can_edit(request.user),
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
"has_filters": has_filters,
|
||||||
|
"active_filter_count": active_filter_count,
|
||||||
|
"preference": preference,
|
||||||
|
"navigation_query": navigation_params.urlencode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def device_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
context = _list_context(request)
|
||||||
|
if request.headers.get("HX-Request") == "true" or request.GET.get("partial") == "1":
|
||||||
|
return render(request, "inventory/_device_table.html", context)
|
||||||
|
return render(request, "inventory/device_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def bulk_update_devices(request: HttpRequest) -> HttpResponse:
|
||||||
|
try:
|
||||||
|
selected_ids = {int(value) for value in request.POST.getlist("device_ids") if value}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
selected_ids = set()
|
||||||
|
status_value = request.POST.get("status", "").strip()
|
||||||
|
recipient_value = request.POST.get("recipient", "").strip()
|
||||||
|
return_query = QueryDict(request.POST.get("return_query", "")[:8000]).urlencode()
|
||||||
|
return_url = reverse("inventory:device-list")
|
||||||
|
if return_query:
|
||||||
|
return_url = f"{return_url}?{return_query}"
|
||||||
|
|
||||||
|
if not selected_ids:
|
||||||
|
messages.error(request, "Bitte mindestens ein Gerät auswählen.")
|
||||||
|
return redirect(return_url)
|
||||||
|
if not status_value and not recipient_value:
|
||||||
|
messages.error(request, "Bitte einen Status oder einen Empfänger auswählen.")
|
||||||
|
return redirect(return_url)
|
||||||
|
|
||||||
|
devices = list(Device.objects.filter(pk__in=selected_ids, archived_at__isnull=True).order_by("pk"))
|
||||||
|
if len(devices) != len(selected_ids):
|
||||||
|
messages.error(request, "Mindestens ein ausgewähltes Gerät ist nicht mehr verfügbar.")
|
||||||
|
return redirect(return_url)
|
||||||
|
|
||||||
|
changed_devices = 0
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
for device in devices:
|
||||||
|
changed = False
|
||||||
|
if status_value and str(device.status_id) != status_value:
|
||||||
|
device = update_device_field(device, "status", status_value, request.user)
|
||||||
|
changed = True
|
||||||
|
if recipient_value:
|
||||||
|
raw_recipient = "" if recipient_value == "__clear__" else recipient_value
|
||||||
|
current_recipient = "" if device.recipient_id is None else str(device.recipient_id)
|
||||||
|
if current_recipient != raw_recipient:
|
||||||
|
update_device_field(device, "recipient", raw_recipient, request.user)
|
||||||
|
changed = True
|
||||||
|
changed_devices += int(changed)
|
||||||
|
except ValidationError as exc:
|
||||||
|
messages.error(request, exc.messages[0])
|
||||||
|
return redirect(return_url)
|
||||||
|
|
||||||
|
if changed_devices:
|
||||||
|
messages.success(request, f"{changed_devices} Geräte wurden gemeinsam aktualisiert.")
|
||||||
|
else:
|
||||||
|
messages.info(request, "Die ausgewählten Geräte hatten bereits diese Werte.")
|
||||||
|
return redirect(return_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_navigation(request: HttpRequest, device: Device) -> dict:
|
||||||
|
params = request.GET.copy()
|
||||||
|
for key in ("drawer", "partial", "exact", "open"):
|
||||||
|
params.pop(key, None)
|
||||||
|
ordered_ids = list(device_queryset(params).values_list("pk", flat=True))
|
||||||
|
try:
|
||||||
|
position = ordered_ids.index(device.pk)
|
||||||
|
except ValueError:
|
||||||
|
position = -1
|
||||||
|
previous_id = ordered_ids[position - 1] if position > 0 else None
|
||||||
|
next_id = ordered_ids[position + 1] if position >= 0 and position + 1 < len(ordered_ids) else None
|
||||||
|
query = params.urlencode()
|
||||||
|
list_url = reverse("inventory:device-list")
|
||||||
|
if query:
|
||||||
|
list_url = f"{list_url}?{query}"
|
||||||
|
return {
|
||||||
|
"previous_device_id": previous_id,
|
||||||
|
"next_device_id": next_id,
|
||||||
|
"navigation_query": query,
|
||||||
|
"list_url": list_url,
|
||||||
|
"navigation_position": position + 1 if position >= 0 else None,
|
||||||
|
"navigation_total": len(ordered_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hardware_type_map() -> dict[str, list[str]]:
|
||||||
|
choices = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE)
|
||||||
|
return {"": sorted(COMPUTER_FIELDS), **{
|
||||||
|
str(choice.pk): sorted(applicable_fields(choice)) for choice in choices
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def quick_add(request: HttpRequest) -> HttpResponse:
|
||||||
|
initial_status = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, label="Eingang", active=True).first()
|
||||||
|
form = QuickAddForm(
|
||||||
|
request.POST or None,
|
||||||
|
initial={"number": NumberSequence.suggested_device_number(), "quantity": 1, "status": initial_status, "date": timezone.localdate()},
|
||||||
|
)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
try:
|
||||||
|
devices = create_device_batch(
|
||||||
|
first_number=form.cleaned_data["number"], quantity=form.cleaned_data["quantity"],
|
||||||
|
shelf=form.cleaned_data["shelf"], status=form.cleaned_data["status"],
|
||||||
|
device_date=form.cleaned_data["date"], device_type=form.cleaned_data["device_type"],
|
||||||
|
employee=form.cleaned_data["employee"], donor=form.cleaned_data["donor"], user=request.user,
|
||||||
|
model=form.cleaned_data["model"], hardware_values=form.selected_hardware_values(),
|
||||||
|
)
|
||||||
|
except (IntegrityError, ValidationError) as exc:
|
||||||
|
form.add_error("number", "Diese Gerätenummer wurde inzwischen vergeben. Bitte wählen Sie eine andere Nummer.")
|
||||||
|
else:
|
||||||
|
if len(devices) == 1:
|
||||||
|
messages.success(request, f"Gerät #{devices[0].number} wurde angelegt. Sie können direkt das nächste Gerät erfassen.")
|
||||||
|
else:
|
||||||
|
messages.success(request, f"{len(devices)} Geräte wurden als #{devices[0].number} bis #{devices[-1].number} angelegt.")
|
||||||
|
return redirect("inventory:quick-add")
|
||||||
|
return render(request, "inventory/quick_add.html", {
|
||||||
|
"form": form, "type_field_map": _hardware_type_map(), "all_hardware_fields": HARDWARE_FIELDS,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@transaction.atomic
|
||||||
|
def device_detail(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
devices = Device.objects.select_related("status", "recipient", "location", "device_type", "employee", *SPECIFICATION_RELATIONS)
|
||||||
|
if request.method == "POST":
|
||||||
|
devices = devices.select_for_update(of=("self",))
|
||||||
|
device = get_object_or_404(devices, pk=pk)
|
||||||
|
navigation = _device_navigation(request, device)
|
||||||
|
definitions = FieldDefinition.objects.filter(active=True).prefetch_related("options")
|
||||||
|
if request.method == "POST":
|
||||||
|
if not can_edit(request.user):
|
||||||
|
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
|
||||||
|
expected = request.POST.get("expected_updated_at")
|
||||||
|
revision = request.POST.get("expected_revision")
|
||||||
|
try:
|
||||||
|
if not expected and not revision:
|
||||||
|
raise RuntimeError("conflict")
|
||||||
|
check_device_version(device, expected, revision)
|
||||||
|
except RuntimeError:
|
||||||
|
messages.error(request, "Das Gerät wurde zwischenzeitlich geändert. Bitte neu laden.")
|
||||||
|
target = reverse("inventory:device-detail", args=[device.pk])
|
||||||
|
if navigation["navigation_query"]:
|
||||||
|
target = f'{target}?{navigation["navigation_query"]}'
|
||||||
|
return redirect(target)
|
||||||
|
old_values = {name: getattr(device, name) for name in FIELD_LABELS}
|
||||||
|
old_custom = dict(device.custom_values)
|
||||||
|
form = DeviceForm(request.POST, instance=device)
|
||||||
|
if form.is_valid():
|
||||||
|
with transaction.atomic():
|
||||||
|
device = form.save(commit=False)
|
||||||
|
clear_inapplicable(device)
|
||||||
|
custom_values = dict(device.custom_values)
|
||||||
|
try:
|
||||||
|
for definition in definitions:
|
||||||
|
key = f"custom_{definition.key}"
|
||||||
|
raw = request.POST.getlist(key) if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT else request.POST.get(key)
|
||||||
|
custom_values[definition.key] = validate_custom_value(definition, raw)
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
device.custom_values = custom_values
|
||||||
|
device.full_clean()
|
||||||
|
device.save()
|
||||||
|
for name, label in FIELD_LABELS.items():
|
||||||
|
old = old_values[name]
|
||||||
|
new = getattr(device, name)
|
||||||
|
old_data = serialize_audit_value(name, old)
|
||||||
|
new_data = serialize_audit_value(name, new)
|
||||||
|
if old_data != new_data:
|
||||||
|
add_audit(
|
||||||
|
request.user, device, AuditLog.Action.UPDATE, label,
|
||||||
|
display_device_value(name, old), display_device_value(name, new), field_key=name,
|
||||||
|
old_data=old_data, new_data=new_data,
|
||||||
|
)
|
||||||
|
for definition in definitions:
|
||||||
|
old = old_custom.get(definition.key)
|
||||||
|
new = custom_values.get(definition.key)
|
||||||
|
if old != new:
|
||||||
|
add_audit(
|
||||||
|
request.user, device, AuditLog.Action.UPDATE, definition.name, old, new,
|
||||||
|
field_key=f"custom:{definition.key}", old_data=old, new_data=new,
|
||||||
|
)
|
||||||
|
messages.success(request, "Änderungen gespeichert.")
|
||||||
|
target = reverse("inventory:device-detail", args=[device.pk])
|
||||||
|
if navigation["navigation_query"]:
|
||||||
|
target = f'{target}?{navigation["navigation_query"]}'
|
||||||
|
return redirect(target)
|
||||||
|
else:
|
||||||
|
form = DeviceForm(instance=device)
|
||||||
|
if not can_edit(request.user):
|
||||||
|
for field in form.fields.values():
|
||||||
|
field.disabled = True
|
||||||
|
type_field_map = _hardware_type_map()
|
||||||
|
selected_type_id = str(form["device_type"].value() or "")
|
||||||
|
context = {
|
||||||
|
"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,
|
||||||
|
"type_field_map": type_field_map,
|
||||||
|
"next_stages": [(value, label) for value, label in Device.Stage.choices if value in TRANSITIONS[device.stage]],
|
||||||
|
"tasks": device.tasks.select_related("assigned_to", "completed_by"),
|
||||||
|
"task_assignees": get_user_model().objects.filter(is_active=True).filter(
|
||||||
|
Q(is_superuser=True) | Q(profile__role__in=[UserProfile.Role.ADMIN, UserProfile.Role.EDITOR]),
|
||||||
|
).order_by("username"),
|
||||||
|
"workflow_events": device.workflow_events.select_related("user")[:10],
|
||||||
|
"inspections": device.inspections.select_related("recorded_by")[:5],
|
||||||
|
"repairs": device.repairs.select_related("recorded_by")[:5],
|
||||||
|
"ram_suggestions": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.RAM, active=True),
|
||||||
|
"ssd_suggestions": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.SSD, active=True),
|
||||||
|
"hdd_suggestions": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.HDD, active=True),
|
||||||
|
"display_suggestions": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DISPLAY, active=True),
|
||||||
|
**navigation,
|
||||||
|
}
|
||||||
|
return render(request, "inventory/device_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def device_advance(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
try:
|
||||||
|
advance_device(pk, request.POST.get("stage", ""), user=request.user,
|
||||||
|
note=request.POST.get("note", "").strip(),
|
||||||
|
expected_revision=request.POST.get("expected_revision"))
|
||||||
|
messages.success(request, "Arbeitsschritt aktualisiert.")
|
||||||
|
except (ValidationError, Device.DoesNotExist) as exc:
|
||||||
|
messages.error(request, exc.messages[0] if isinstance(exc, ValidationError) else "Gerät nicht gefunden.")
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def device_task_add(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
|
|
||||||
|
raw_due = request.POST.get("due_date", "").strip()
|
||||||
|
due_date = parse_date(raw_due) if raw_due else None
|
||||||
|
if raw_due and due_date is None:
|
||||||
|
messages.error(request, "Ungültiges Fälligkeitsdatum.")
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
assigned_to = None
|
||||||
|
if raw_assignee := request.POST.get("assigned_to", "").strip():
|
||||||
|
if not raw_assignee.isascii() or not raw_assignee.isdecimal():
|
||||||
|
messages.error(request, "Ungültige zuständige Person.")
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
assigned_to = get_user_model().objects.filter(pk=raw_assignee, is_active=True).filter(
|
||||||
|
Q(is_superuser=True) | Q(profile__role__in=[UserProfile.Role.ADMIN, UserProfile.Role.EDITOR]),
|
||||||
|
).first()
|
||||||
|
if assigned_to is None:
|
||||||
|
messages.error(request, "Ungültige zuständige Person.")
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
try:
|
||||||
|
create_task(pk, title=request.POST.get("title", ""), description=request.POST.get("description", ""),
|
||||||
|
due_date=due_date, assigned_to=assigned_to, user=request.user)
|
||||||
|
messages.success(request, "Aufgabe angelegt.")
|
||||||
|
except (ValidationError, Device.DoesNotExist) as exc:
|
||||||
|
messages.error(request, exc.messages[0] if isinstance(exc, ValidationError) else "Gerät nicht gefunden.")
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def device_task_complete(request: HttpRequest, pk: int, task_id: int) -> HttpResponse:
|
||||||
|
try:
|
||||||
|
complete_task(pk, task_id, user=request.user)
|
||||||
|
messages.success(request, "Aufgabe erledigt.")
|
||||||
|
except (ValidationError, Device.DoesNotExist, DeviceTask.DoesNotExist) as exc:
|
||||||
|
messages.error(request, exc.messages[0] if isinstance(exc, ValidationError) else "Aufgabe nicht gefunden.")
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def device_inspection_save(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
device = get_object_or_404(Device, pk=pk, archived_at__isnull=True)
|
||||||
|
form = InspectionForm(request.POST, device=device)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
save_inspection(pk, form.cleaned_data, user=request.user,
|
||||||
|
expected_revision=request.POST.get("expected_revision"))
|
||||||
|
messages.success(request, "Inspektion gespeichert.")
|
||||||
|
except (ValidationError, Device.DoesNotExist) as exc:
|
||||||
|
messages.error(request, exc.messages[0] if isinstance(exc, ValidationError) else "Gerät nicht gefunden.")
|
||||||
|
else:
|
||||||
|
messages.error(request, form.errors.as_text())
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def device_repair_add(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
form = RepairForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
record_repair(pk, form.cleaned_data["work_done"], user=request.user,
|
||||||
|
expected_revision=request.POST.get("expected_revision"))
|
||||||
|
messages.success(request, "Reparatur dokumentiert.")
|
||||||
|
except (ValidationError, Device.DoesNotExist) as exc:
|
||||||
|
messages.error(request, exc.messages[0] if isinstance(exc, ValidationError) else "Gerät nicht gefunden.")
|
||||||
|
else:
|
||||||
|
messages.error(request, form.errors.as_text())
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def device_storage_assign(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
form = StorageRecipientForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
assign_storage_recipient(pk, form.cleaned_data["recipient"], user=request.user,
|
||||||
|
expected_revision=request.POST.get("expected_revision"))
|
||||||
|
messages.success(request, "Empfänger gespeichert.")
|
||||||
|
except (ValidationError, Device.DoesNotExist) as exc:
|
||||||
|
messages.error(request, exc.messages[0] if isinstance(exc, ValidationError) else "Gerät nicht gefunden.")
|
||||||
|
else:
|
||||||
|
messages.error(request, form.errors.as_text())
|
||||||
|
return workflow_action_redirect(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_action_redirect(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
if request.POST.get("return_to") == "workflow":
|
||||||
|
return redirect("inventory:workflow-board")
|
||||||
|
return redirect("inventory:device-detail", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def workflow_board(request: HttpRequest) -> HttpResponse:
|
||||||
|
open_tasks = Prefetch(
|
||||||
|
"tasks", queryset=DeviceTask.objects.filter(state=DeviceTask.State.OPEN).order_by("due_date", "created_at"),
|
||||||
|
to_attr="open_tasks",
|
||||||
|
)
|
||||||
|
columns = []
|
||||||
|
for stage, label in Device.Stage.choices:
|
||||||
|
queryset = Device.objects.filter(stage=stage, archived_at__isnull=True).select_related(
|
||||||
|
"device_type", "status", "employee", "recipient", *SPECIFICATION_RELATIONS,
|
||||||
|
)
|
||||||
|
devices = list(queryset.order_by("-updated_at", "-number").prefetch_related(
|
||||||
|
open_tasks,
|
||||||
|
Prefetch("inspections", queryset=InspectionRecord.objects.select_related("recorded_by").order_by("-created_at"), to_attr="inspection_history"),
|
||||||
|
Prefetch("repairs", queryset=RepairRecord.objects.select_related("recorded_by").order_by("-created_at"), to_attr="repair_history"),
|
||||||
|
)[:30])
|
||||||
|
for device in devices:
|
||||||
|
device.open_task_count = len(device.open_tasks)
|
||||||
|
device.next_stages = [(value, stage_label) for value, stage_label in Device.Stage.choices if value in TRANSITIONS[stage]]
|
||||||
|
if stage == Device.Stage.INSPECTION:
|
||||||
|
device.stage_form = InspectionForm(device=device)
|
||||||
|
elif stage == Device.Stage.REPAIR:
|
||||||
|
device.stage_form = RepairForm()
|
||||||
|
elif stage == Device.Stage.STORAGE:
|
||||||
|
device.stage_form = StorageRecipientForm(initial={"recipient": device.recipient.label if device.recipient else ""})
|
||||||
|
columns.append({
|
||||||
|
"stage": stage, "label": label, "total": queryset.count(),
|
||||||
|
"devices": devices,
|
||||||
|
})
|
||||||
|
return render(request, "inventory/workflow_board.html", {
|
||||||
|
"columns": columns,
|
||||||
|
"can_edit_devices": can_edit(request.user),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def task_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
state = request.GET.get("state", "OPEN")
|
||||||
|
if state not in DeviceTask.State.values:
|
||||||
|
state = DeviceTask.State.OPEN
|
||||||
|
tasks = DeviceTask.objects.select_related("device", "assigned_to", "created_by").filter(
|
||||||
|
state=state, device__archived_at__isnull=True,
|
||||||
|
).order_by("due_date", "-created_at")
|
||||||
|
return render(request, "inventory/task_list.html", {
|
||||||
|
"tasks": tasks, "state": state, "can_edit_devices": can_edit(request.user),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def inline_update(request: HttpRequest, pk: int) -> JsonResponse:
|
||||||
|
device = get_object_or_404(Device, pk=pk, archived_at__isnull=True)
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValidationError("Ein JSON-Objekt ist erforderlich.")
|
||||||
|
if not payload.get("expected_updated_at") and payload.get("expected_revision") is None:
|
||||||
|
raise ValidationError("Die Datensatzversion fehlt. Bitte neu laden.")
|
||||||
|
updated = update_device_field(device, payload.get("field", ""), payload.get("value"), request.user, payload.get("expected_updated_at"), expected_revision=payload.get("expected_revision"))
|
||||||
|
field = payload["field"]
|
||||||
|
value = getattr(updated, field)
|
||||||
|
display = display_device_value(field, value)
|
||||||
|
return JsonResponse({
|
||||||
|
"ok": True, "display": display, "value": serialize_audit_value(field, value),
|
||||||
|
"color": value.color if field == "status" and value else "",
|
||||||
|
"updated_at": updated.updated_at.isoformat(),
|
||||||
|
"revision": updated.revision,
|
||||||
|
})
|
||||||
|
except RuntimeError:
|
||||||
|
return JsonResponse({"error": "Das Gerät wurde inzwischen geändert. Bitte die Ansicht neu laden."}, status=409)
|
||||||
|
except (ValidationError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||||
|
message = exc.messages[0] if isinstance(exc, ValidationError) else str(exc)
|
||||||
|
return JsonResponse({"error": message}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
@transaction.atomic
|
||||||
|
def archive_device(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
device = get_object_or_404(Device.objects.select_for_update(), pk=pk, archived_at__isnull=True)
|
||||||
|
device.archived_at = timezone.now()
|
||||||
|
device.archived_by = request.user
|
||||||
|
device.save(update_fields=["archived_at", "archived_by", "updated_at"])
|
||||||
|
add_audit(request.user, device, AuditLog.Action.ARCHIVE)
|
||||||
|
messages.success(request, f"Gerät #{device.number} wurde archiviert.")
|
||||||
|
return redirect("inventory:device-list")
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def archive_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
return render(request, "inventory/archive_list.html", _list_context(request, archived=True))
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
@transaction.atomic
|
||||||
|
def restore_device(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
device = get_object_or_404(Device.objects.select_for_update(), pk=pk, archived_at__isnull=False)
|
||||||
|
device.archived_at = None
|
||||||
|
device.archived_by = None
|
||||||
|
device.save(update_fields=["archived_at", "archived_by", "updated_at"])
|
||||||
|
add_audit(request.user, device, AuditLog.Action.RESTORE)
|
||||||
|
messages.success(request, f"Gerät #{device.number} wurde wiederhergestellt.")
|
||||||
|
return redirect("inventory:archive-list")
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@login_required
|
||||||
|
def autocomplete(request: HttpRequest, field: str) -> JsonResponse:
|
||||||
|
term = request.GET.get("q", "").strip()
|
||||||
|
results: list[dict] = []
|
||||||
|
kind_map = {"status": ChoiceValue.Kind.STATUS, "recipient": ChoiceValue.Kind.RECIPIENT, "location": ChoiceValue.Kind.LOCATION, "device_type": ChoiceValue.Kind.DEVICE_TYPE}
|
||||||
|
if field in kind_map:
|
||||||
|
rows = ChoiceValue.objects.filter(kind=kind_map[field], active=True, label__icontains=term).order_by("sort_order", "label")
|
||||||
|
results = [{"id": row.pk, "label": row.label} for row in rows]
|
||||||
|
elif field == "employee":
|
||||||
|
rows = Employee.objects.filter(active=True).filter(models.Q(code__icontains=term) | models.Q(name__icontains=term)).order_by("code")
|
||||||
|
results = [{"id": row.pk, "label": f"{row.code} – {row.name}"} for row in rows]
|
||||||
|
elif field == "cpu":
|
||||||
|
existing = (cpu for model in (LaptopSpecification, DesktopSpecification, AllInOneSpecification, ComputerSpecification)
|
||||||
|
for cpu in model.objects.exclude(cpu="").values_list("cpu", flat=True).distinct())
|
||||||
|
results = [{"id": value, "label": value} for value in suggest_cpus(term, existing)]
|
||||||
|
return JsonResponse({"results": results})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def audit_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
logs = AuditLog.objects.select_related("user", "device", "reverted_by")
|
||||||
|
query = request.GET.get("q", "").strip()
|
||||||
|
if query:
|
||||||
|
logs = logs.filter(models.Q(field__icontains=query) | models.Q(old_value__icontains=query) | models.Q(new_value__icontains=query))
|
||||||
|
page = Paginator(logs, 50).get_page(request.GET.get("page"))
|
||||||
|
return render(request, "inventory/audit_list.html", {"page_obj": page, "can_revert_audits": can_edit(request.user)})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN, UserProfile.Role.EDITOR)
|
||||||
|
def audit_revert(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
log = get_object_or_404(AuditLog, pk=pk)
|
||||||
|
try:
|
||||||
|
revert_audit_change(log, request.user)
|
||||||
|
except ValidationError as exc:
|
||||||
|
messages.error(request, exc.messages[0])
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Die Änderung an Gerät #{log.device.number} wurde zurückgesetzt.")
|
||||||
|
return redirect("inventory:audit-list")
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def configuration(request: HttpRequest) -> HttpResponse:
|
||||||
|
choices = ChoiceValue.objects.all()
|
||||||
|
definitions = FieldDefinition.objects.prefetch_related("options")
|
||||||
|
employees = Employee.objects.all()
|
||||||
|
return render(request, "inventory/configuration.html", {
|
||||||
|
"choices": choices, "definitions": definitions, "employees": employees,
|
||||||
|
"choice_kinds": ChoiceValue.Kind.choices,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def employee_edit(request: HttpRequest, pk: int | None = None) -> HttpResponse:
|
||||||
|
instance = get_object_or_404(Employee, pk=pk) if pk else None
|
||||||
|
form = EmployeeForm(request.POST or None, instance=instance)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
employee = form.save()
|
||||||
|
messages.success(request, f"Bearbeiter {employee.name} wurde gespeichert.")
|
||||||
|
return redirect("inventory:configuration")
|
||||||
|
return render(request, "inventory/simple_form.html", {
|
||||||
|
"form": form, "title": "Bearbeiter bearbeiten" if instance else "Bearbeiter hinzufügen",
|
||||||
|
"cancel_url": reverse("inventory:configuration"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def employee_remove(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
employee = get_object_or_404(Employee, pk=pk)
|
||||||
|
name = employee.name
|
||||||
|
if employee.devices.exists():
|
||||||
|
employee.active = False
|
||||||
|
employee.save(update_fields=["active", "updated_at"])
|
||||||
|
messages.success(request, f"Bearbeiter {name} wurde aus der Auswahl entfernt. Bestehende Gerätezuordnungen bleiben erhalten.")
|
||||||
|
else:
|
||||||
|
employee.delete()
|
||||||
|
messages.success(request, f"Bearbeiter {name} wurde entfernt.")
|
||||||
|
return redirect("inventory:configuration")
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def user_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
users = User.objects.select_related("profile").order_by("username")
|
||||||
|
return render(request, "inventory/user_list.html", {"users": users})
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def user_edit(request: HttpRequest, pk: int | None = None) -> HttpResponse:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
instance = get_object_or_404(User, pk=pk) if pk else None
|
||||||
|
form = UserManagementForm(request.POST or None, instance=instance)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
messages.success(request, f"Konto {user.username} gespeichert.")
|
||||||
|
return redirect("inventory:user-list")
|
||||||
|
return render(request, "inventory/simple_form.html", {"form": form, "title": "Benutzer bearbeiten" if instance else "Benutzer anlegen", "cancel_url": reverse("inventory:user-list")})
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def choice_edit(request: HttpRequest, pk: int | None = None) -> HttpResponse:
|
||||||
|
instance = get_object_or_404(ChoiceValue, pk=pk) if pk else None
|
||||||
|
form = ChoiceValueForm(request.POST or None, instance=instance)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, "Auswahlwert gespeichert.")
|
||||||
|
return redirect("inventory:configuration")
|
||||||
|
return render(request, "inventory/simple_form.html", {"form": form, "title": "Auswahlwert bearbeiten" if instance else "Auswahlwert anlegen"})
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def field_edit(request: HttpRequest, pk: int | None = None) -> HttpResponse:
|
||||||
|
instance = get_object_or_404(FieldDefinition, pk=pk) if pk else None
|
||||||
|
form = FieldDefinitionForm(request.POST or None, instance=instance)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, "Benutzerdefiniertes Feld gespeichert.")
|
||||||
|
return redirect("inventory:configuration")
|
||||||
|
return render(request, "inventory/simple_form.html", {"form": form, "title": "Feld bearbeiten" if instance else "Feld anlegen"})
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def import_excel(request: HttpRequest) -> HttpResponse:
|
||||||
|
form = ExcelUploadForm(request.POST or None, request.FILES or None)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
upload = form.cleaned_data["file"]
|
||||||
|
try:
|
||||||
|
headers, preview = analyze_workbook(upload)
|
||||||
|
except Exception as exc:
|
||||||
|
form.add_error("file", f"Datei konnte nicht gelesen werden: {exc}")
|
||||||
|
else:
|
||||||
|
upload.seek(0)
|
||||||
|
job = ImportJob.objects.create(user=request.user, file=upload, original_name=upload.name, headers=headers, preview_rows=preview)
|
||||||
|
return redirect("inventory:import-map", pk=job.pk)
|
||||||
|
return render(request, "inventory/import_upload.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@role_required(UserProfile.Role.ADMIN)
|
||||||
|
def import_map(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
|
job = get_object_or_404(ImportJob, pk=pk, user=request.user, status=ImportJob.Status.UPLOADED)
|
||||||
|
suggestions = suggested_mapping(job.headers)
|
||||||
|
fields = [("", "Nicht importieren")] + [(key, label) for key, label in [
|
||||||
|
("number", "Nr."), ("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"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"),
|
||||||
|
("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]
|
||||||
|
if request.method == "POST":
|
||||||
|
mapping = {index: request.POST.get(f"column_{index}", "") for index in range(len(job.headers))}
|
||||||
|
try:
|
||||||
|
with job.file.open("rb") as source:
|
||||||
|
result = import_workbook(source, mapping, request.user)
|
||||||
|
except Exception as exc:
|
||||||
|
job.status = ImportJob.Status.FAILED
|
||||||
|
job.result = {"error": str(exc)}
|
||||||
|
job.save(update_fields=["status", "result", "updated_at"])
|
||||||
|
messages.error(request, f"Import abgebrochen: {exc}")
|
||||||
|
else:
|
||||||
|
job.status = ImportJob.Status.COMPLETED
|
||||||
|
job.result = result
|
||||||
|
job.save(update_fields=["status", "result", "updated_at"])
|
||||||
|
messages.success(request, f"Import abgeschlossen: {result['created']} Geräte angelegt.")
|
||||||
|
return redirect("inventory:device-list")
|
||||||
|
return render(request, "inventory/import_map.html", {"job": job, "suggestions": suggestions, "fields": fields})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def export_devices(request: HttpRequest, file_format: str) -> HttpResponse:
|
||||||
|
queryset = device_queryset(request.GET) if request.GET.get("scope") == "filtered" else device_queryset(type("Params", (), {"get": lambda self, key, default=None: default, "getlist": lambda self, key: []})())
|
||||||
|
if file_format == "csv":
|
||||||
|
return csv_response(queryset)
|
||||||
|
if file_format == "xlsx":
|
||||||
|
return xlsx_response(queryset)
|
||||||
|
if file_format == "ods":
|
||||||
|
return ods_response(queryset)
|
||||||
|
return JsonResponse({"error": "Unbekanntes Exportformat."}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@login_required
|
||||||
|
def save_preferences(request: HttpRequest) -> JsonResponse:
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError
|
||||||
|
allowed_columns = {"number", *FIELD_LABELS} | {f"custom_{key}" for key in FieldDefinition.objects.values_list("key", flat=True)}
|
||||||
|
if set(payload) - {"page_size", "visible_columns", "column_order", "column_widths"}:
|
||||||
|
raise ValueError
|
||||||
|
if "page_size" in payload and (type(payload["page_size"]) is not int or payload["page_size"] not in {25, 50, 100}):
|
||||||
|
raise ValueError
|
||||||
|
for key in ("visible_columns", "column_order"):
|
||||||
|
if key in payload:
|
||||||
|
value = payload[key]
|
||||||
|
if not isinstance(value, list) or len(value) > len(allowed_columns) or any(not isinstance(item, str) or item not in allowed_columns for item in value):
|
||||||
|
raise ValueError
|
||||||
|
if len(set(value)) != len(value):
|
||||||
|
raise ValueError
|
||||||
|
if "column_widths" in payload:
|
||||||
|
widths = payload["column_widths"]
|
||||||
|
if not isinstance(widths, dict) or any(key not in allowed_columns or type(value) is not int or not 40 <= value <= 2000 for key, value in widths.items()):
|
||||||
|
raise ValueError
|
||||||
|
preference, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||||
|
if payload.get("page_size") in {25, 50, 100}:
|
||||||
|
preference.page_size = payload["page_size"]
|
||||||
|
for key in ["visible_columns", "column_order", "column_widths"]:
|
||||||
|
if key in payload:
|
||||||
|
setattr(preference, key, payload[key])
|
||||||
|
preference.save()
|
||||||
|
return JsonResponse({"ok": True})
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return JsonResponse({"error": "Ungültige Einstellungen."}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def health(request: HttpRequest) -> JsonResponse:
|
||||||
|
try:
|
||||||
|
Device.objects.values("pk").first()
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"status": "unavailable"}, status=503)
|
||||||
146
inventory/workflow.py
Normal file
146
inventory/workflow.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Validated workflow and task operations. All writes use row locks."""
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import AuditLog, ChoiceValue, Device, DeviceTask, InspectionRecord, RepairRecord, WorkflowEvent
|
||||||
|
from .services import add_audit
|
||||||
|
from .device_types import applicable_fields
|
||||||
|
|
||||||
|
|
||||||
|
TRANSITIONS = {
|
||||||
|
Device.Stage.INTAKE: {Device.Stage.INSPECTION},
|
||||||
|
Device.Stage.INSPECTION: {Device.Stage.REPAIR, Device.Stage.STORAGE},
|
||||||
|
Device.Stage.REPAIR: {Device.Stage.INSPECTION, Device.Stage.STORAGE},
|
||||||
|
Device.Stage.STORAGE: {Device.Stage.REPAIR, Device.Stage.ISSUED},
|
||||||
|
Device.Stage.ISSUED: {Device.Stage.INTAKE},
|
||||||
|
}
|
||||||
|
STATUS_BY_STAGE = {
|
||||||
|
Device.Stage.INTAKE: "Eingang",
|
||||||
|
Device.Stage.REPAIR: "Defekt",
|
||||||
|
Device.Stage.STORAGE: "Fertig",
|
||||||
|
Device.Stage.ISSUED: "Ausgegeben",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_stage_device(device_id, stage, expected_revision):
|
||||||
|
device = Device.objects.select_for_update().get(pk=device_id, archived_at__isnull=True)
|
||||||
|
if expected_revision is None or str(expected_revision) != str(device.revision):
|
||||||
|
raise ValidationError("Das Gerät wurde inzwischen geändert. Bitte neu laden.")
|
||||||
|
if device.stage != stage:
|
||||||
|
raise ValidationError("Dieses Formular gehört nicht zum aktuellen Arbeitsschritt.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save_inspection(device_id, values, *, user, expected_revision):
|
||||||
|
device = _locked_stage_device(device_id, Device.Stage.INSPECTION, expected_revision)
|
||||||
|
defects = values["defects"].strip()
|
||||||
|
if not defects or len(defects) > 4000:
|
||||||
|
raise ValidationError("Bitte festgestellte Mängel eintragen (maximal 4000 Zeichen).")
|
||||||
|
changed = ["updated_at"]
|
||||||
|
for name in ("model", *applicable_fields(device.device_type)):
|
||||||
|
if name not in values:
|
||||||
|
continue
|
||||||
|
old, new = getattr(device, name), values[name]
|
||||||
|
if old != new:
|
||||||
|
setattr(device, name, new)
|
||||||
|
changed.append(name)
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, name, old, new, field_key=name)
|
||||||
|
device.save(update_fields=changed)
|
||||||
|
InspectionRecord.objects.create(device=device, defects=defects, recorded_by=user)
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Inspektion", "", defects)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def record_repair(device_id, work_done, *, user, expected_revision):
|
||||||
|
device = _locked_stage_device(device_id, Device.Stage.REPAIR, expected_revision)
|
||||||
|
work_done = work_done.strip()
|
||||||
|
if not work_done or len(work_done) > 4000:
|
||||||
|
raise ValidationError("Bitte die durchgeführten Arbeiten eintragen (maximal 4000 Zeichen).")
|
||||||
|
RepairRecord.objects.create(device=device, work_done=work_done, recorded_by=user)
|
||||||
|
device.save(update_fields=["updated_at"])
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Reparatur", "", work_done)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def assign_storage_recipient(device_id, recipient_name, *, user, expected_revision):
|
||||||
|
device = _locked_stage_device(device_id, Device.Stage.STORAGE, expected_revision)
|
||||||
|
recipient_name = recipient_name.strip()
|
||||||
|
if not recipient_name or len(recipient_name) > 120:
|
||||||
|
raise ValidationError("Bitte einen Empfänger mit höchstens 120 Zeichen eintragen.")
|
||||||
|
recipient = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.RECIPIENT, label__iexact=recipient_name).first()
|
||||||
|
if recipient is None:
|
||||||
|
recipient = ChoiceValue.objects.create(kind=ChoiceValue.Kind.RECIPIENT, label=recipient_name)
|
||||||
|
old = device.recipient
|
||||||
|
if old != recipient:
|
||||||
|
device.recipient = recipient
|
||||||
|
device.save(update_fields=["recipient"])
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Empfänger", old, recipient,
|
||||||
|
field_key="recipient", old_data=old.pk if old else None, new_data=recipient.pk)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def advance_device(device_id, target, *, user, note="", expected_revision=None):
|
||||||
|
device = Device.objects.select_for_update().get(pk=device_id, archived_at__isnull=True)
|
||||||
|
if expected_revision is None or str(expected_revision) != str(device.revision):
|
||||||
|
raise ValidationError("Das Gerät wurde inzwischen geändert. Bitte neu laden.")
|
||||||
|
if target not in TRANSITIONS[device.stage]:
|
||||||
|
raise ValidationError("Dieser Schritt ist vom aktuellen Zustand nicht erlaubt.")
|
||||||
|
if target in {Device.Stage.STORAGE, Device.Stage.ISSUED} and device.tasks.filter(state=DeviceTask.State.OPEN).exists():
|
||||||
|
raise ValidationError("Vor der Lagerung oder Ausgabe müssen alle Aufgaben erledigt sein.")
|
||||||
|
if target == Device.Stage.ISSUED and not device.recipient_id:
|
||||||
|
raise ValidationError("Vor der Ausgabe einen Empfänger eintragen.")
|
||||||
|
if len(note) > 500:
|
||||||
|
raise ValidationError("Die Notiz darf höchstens 500 Zeichen enthalten.")
|
||||||
|
previous = device.stage
|
||||||
|
device.stage = target
|
||||||
|
update_fields = ["stage"]
|
||||||
|
desired_label = STATUS_BY_STAGE.get(target)
|
||||||
|
old_status = device.status
|
||||||
|
desired_status = ChoiceValue.objects.filter(
|
||||||
|
kind=ChoiceValue.Kind.STATUS, label=desired_label, active=True,
|
||||||
|
).first() if desired_label else None
|
||||||
|
if desired_status and desired_status.pk != device.status_id:
|
||||||
|
device.status = desired_status
|
||||||
|
update_fields.append("status")
|
||||||
|
device.save(update_fields=update_fields)
|
||||||
|
WorkflowEvent.objects.create(device=device, from_stage=previous, to_stage=target, note=note, user=user)
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Arbeitsschritt", previous, target,
|
||||||
|
field_key="stage", old_data=previous, new_data=target)
|
||||||
|
if "status" in update_fields:
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Status", old_status, desired_status,
|
||||||
|
field_key="status", old_data=old_status.pk, new_data=desired_status.pk)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_task(device_id, *, title, description="", due_date=None, assigned_to=None, user):
|
||||||
|
device = Device.objects.select_for_update().get(pk=device_id, archived_at__isnull=True)
|
||||||
|
title = title.strip()
|
||||||
|
if not title or len(title) > 200:
|
||||||
|
raise ValidationError("Bitte einen Aufgabentitel mit höchstens 200 Zeichen eingeben.")
|
||||||
|
if device.stage == Device.Stage.ISSUED:
|
||||||
|
raise ValidationError("Für ausgegebene Geräte können keine Aufgaben angelegt werden.")
|
||||||
|
task = DeviceTask.objects.create(device=device, title=title, description=description.strip(),
|
||||||
|
due_date=due_date, assigned_to=assigned_to, created_by=user)
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Aufgabe angelegt", "", title)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def complete_task(device_id, task_id, *, user):
|
||||||
|
device = Device.objects.select_for_update().get(pk=device_id, archived_at__isnull=True)
|
||||||
|
task = DeviceTask.objects.select_for_update().get(pk=task_id, device=device)
|
||||||
|
if task.state != DeviceTask.State.OPEN:
|
||||||
|
raise ValidationError("Diese Aufgabe ist bereits erledigt.")
|
||||||
|
task.state = DeviceTask.State.DONE
|
||||||
|
task.completed_by = user
|
||||||
|
task.completed_at = timezone.now()
|
||||||
|
task.save(update_fields=["state", "completed_by", "completed_at", "updated_at"])
|
||||||
|
add_audit(user, device, AuditLog.Action.UPDATE, "Aufgabe erledigt", "", task.title)
|
||||||
|
return task
|
||||||
14
manage.py
Normal file
14
manage.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "csr_inventory.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
media/imports/.gitkeep
Normal file
1
media/imports/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Django>=5.2,<5.3
|
||||||
|
djangorestframework>=3.16,<3.17
|
||||||
|
djangorestframework-api-key>=3.1,<4
|
||||||
|
gunicorn>=23,<24
|
||||||
|
openpyxl>=3.1,<4
|
||||||
|
psycopg[binary]>=3.2,<4
|
||||||
|
whitenoise>=6.9,<7
|
||||||
20
scripts/backup.sh
Normal file
20
scripts/backup.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
BACKUP_DIR=${BACKUP_DIR:-./backups}
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
WORK=$(mktemp -d "$BACKUP_DIR/.backup.XXXXXX")
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
trap 'exit 1' HUP INT TERM
|
||||||
|
STAMP=$(date +%Y-%m-%d_%H%M%S)
|
||||||
|
FILE="$BACKUP_DIR/backup_${STAMP}_$$.sql.gz"
|
||||||
|
|
||||||
|
# Separate commands: POSIX sh has no portable pipefail. Credentials stay inside
|
||||||
|
# the container; a .env file must never be sourced as shell code.
|
||||||
|
docker compose exec -T db sh -c 'exec pg_dump --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$WORK/dump.sql"
|
||||||
|
test -s "$WORK/dump.sql"
|
||||||
|
gzip -c "$WORK/dump.sql" > "$WORK/dump.sql.gz"
|
||||||
|
gzip -t "$WORK/dump.sql.gz"
|
||||||
|
mv "$WORK/dump.sql.gz" "$FILE"
|
||||||
|
echo "Backup erstellt: $FILE"
|
||||||
7
scripts/entrypoint.sh
Normal file
7
scripts/entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py seed_initial_data
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
exec "$@"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user