Initialize clean CSR inventory repository

This commit is contained in:
DenisToxic
2026-09-29 08:43:05 +02:00
commit 059f386c60
107 changed files with 9639 additions and 0 deletions

1
inventory/__init__.py Normal file
View File

@@ -0,0 +1 @@

120
inventory/admin.py Normal file
View 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
View 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
View 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"

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

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

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

View 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."))

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

View 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)."))

View 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'),
),
]

View 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)]

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

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

View File

@@ -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,
},
),
]

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

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

View 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)]

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

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

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

View File

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

View File

564
inventory/models.py Normal file
View 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
View 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
View 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
View 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
View 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)

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View 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&&params.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;}
})();

View 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());
});
})();

View 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 %}

View 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 %}

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

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

View 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 %}

View 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 %}

View 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 &amp; 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>

View File

@@ -0,0 +1,21 @@
{% extends 'inventory/base.html' %}
{% block title %}Werte &amp; Felder{% endblock %}
{% block content %}
<section class="page-head"><div><h1>Werte &amp; 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 %}

View 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 %}

View 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 %}&amp;{{ request.GET.urlencode }}{% endif %}">Als CSV exportieren</a>
<a href="{% url 'inventory:export-devices' 'xlsx' %}?scope=filtered{% if request.GET.urlencode %}&amp;{{ request.GET.urlencode }}{% endif %}">Als XLSX exportieren</a>
<a href="{% url 'inventory:export-devices' 'ods' %}?scope=filtered{% if request.GET.urlencode %}&amp;{{ 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

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

View File

@@ -0,0 +1 @@

View 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

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

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