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)