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

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)