Initialize clean CSR inventory repository
This commit is contained in:
389
inventory/services.py
Normal file
389
inventory/services.py
Normal file
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, NumberSequence
|
||||
from .device_types import HARDWARE_FIELDS, applicable_fields, bulk_create_specifications, clear_inapplicable
|
||||
|
||||
|
||||
FIELD_LABELS = {
|
||||
"shelf": "Regal", "status": "Status", "date": "Datum", "recipient": "Empfänger",
|
||||
"location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis",
|
||||
"battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll",
|
||||
"ram_gb": "RAM", "ssd_gb": "SSD", "hdd_gb": "HDD", "cpu": "CPU",
|
||||
"hwid": "HWID", "model": "Modell", "employee": "Bearbeiter", "donor": "Spender", "comment": "Kommentar",
|
||||
"ports": "Anschlüsse", "resolution": "Auflösung", "print_technology": "Druckverfahren",
|
||||
"duplex": "Duplex", "color_printing": "Farbdruck",
|
||||
}
|
||||
INLINE_FIELDS = set(FIELD_LABELS)
|
||||
RELATION_FIELDS = {
|
||||
"status": ChoiceValue.Kind.STATUS, "recipient": ChoiceValue.Kind.RECIPIENT,
|
||||
"location": ChoiceValue.Kind.LOCATION, "device_type": ChoiceValue.Kind.DEVICE_TYPE,
|
||||
}
|
||||
INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"}
|
||||
DECIMAL_FIELDS = {"price", "display_inches"}
|
||||
TRISTATE_FIELDS = {"battery", "wifi", "lan", "duplex", "color_printing"}
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def display_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "label"):
|
||||
return value.label
|
||||
if hasattr(value, "code"):
|
||||
return value.code
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def display_device_value(field: str, value: Any) -> str:
|
||||
if field in TRISTATE_FIELDS:
|
||||
return dict(Device.TriState.choices).get(value, value or "")
|
||||
if field == "date" and isinstance(value, date):
|
||||
return value.strftime("%d.%m.%Y")
|
||||
return display_value(value)
|
||||
|
||||
|
||||
def serialize_audit_value(field: str, value: Any) -> Any:
|
||||
if field.startswith("custom:"):
|
||||
return value
|
||||
if field in RELATION_FIELDS or field == "employee":
|
||||
return value.pk if value is not None else None
|
||||
if isinstance(value, (date, Decimal)):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
def add_audit(
|
||||
user, device: Device, action: str, field: str = "", old: Any = "", new: Any = "", *,
|
||||
field_key: str = "", old_data: Any = _UNSET, new_data: Any = _UNSET, api_client: str = "",
|
||||
) -> AuditLog:
|
||||
values = {
|
||||
"user": user if getattr(user, "is_authenticated", False) else None,
|
||||
"api_client": api_client,
|
||||
"device": device, "action": action, "field": field, "field_key": field_key,
|
||||
"old_value": display_value(old), "new_value": display_value(new),
|
||||
}
|
||||
if old_data is not _UNSET:
|
||||
values["old_data"] = old_data
|
||||
if new_data is not _UNSET:
|
||||
values["new_data"] = new_data
|
||||
return AuditLog.objects.create(**values)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_device_batch(*, first_number: int, quantity: int, shelf: str, status, device_date, device_type, employee, donor: str, user,
|
||||
model: str = "", hardware_values: dict | None = None) -> list[Device]:
|
||||
sequence, _ = NumberSequence.objects.select_for_update().get_or_create(name="device")
|
||||
last_number = first_number + quantity - 1
|
||||
conflict = Device.objects.filter(number__range=(first_number, last_number)).order_by("number").values_list("number", flat=True).first()
|
||||
if conflict is not None:
|
||||
raise ValidationError(f"Die Gerätenummer {conflict} ist bereits vergeben.")
|
||||
|
||||
created: list[Device] = []
|
||||
for batch_start in range(first_number, last_number + 1, 1000):
|
||||
batch_end = min(batch_start + 999, last_number)
|
||||
batch = [
|
||||
Device(number=number, shelf=shelf, status=status, date=device_date, device_type=device_type,
|
||||
employee=employee, donor=donor, model=model, **(hardware_values or {}))
|
||||
for number in range(batch_start, batch_end + 1)
|
||||
]
|
||||
saved = Device.objects.bulk_create(batch, batch_size=1000)
|
||||
bulk_create_specifications(saved)
|
||||
AuditLog.objects.bulk_create(
|
||||
[AuditLog(user=user, device=device, action=AuditLog.Action.CREATE, new_value=f"Gerät #{device.number}") for device in saved],
|
||||
batch_size=1000,
|
||||
)
|
||||
created.extend(saved)
|
||||
|
||||
current_maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0
|
||||
sequence.last_value = max(sequence.last_value, current_maximum, last_number)
|
||||
sequence.save(update_fields=["last_value"])
|
||||
return created
|
||||
|
||||
|
||||
def coerce_inline_value(field: str, raw: Any) -> Any:
|
||||
if isinstance(raw, (dict, list)):
|
||||
raise ValidationError("Bitte einen einzelnen Feldwert eingeben.")
|
||||
if raw in (None, ""):
|
||||
if field == "status":
|
||||
raise ValidationError("Dieses Feld darf nicht leer sein.")
|
||||
return None if field in INTEGER_FIELDS | DECIMAL_FIELDS | set(RELATION_FIELDS) | {"employee"} else ""
|
||||
if field in INTEGER_FIELDS:
|
||||
try:
|
||||
numeric = Decimal(str(raw))
|
||||
if not numeric.is_finite() or numeric != numeric.to_integral_value():
|
||||
raise ValueError
|
||||
value = int(numeric)
|
||||
except (TypeError, ValueError, InvalidOperation, OverflowError):
|
||||
raise ValidationError("Bitte eine gültige ganze Zahl eingeben.")
|
||||
if value < 0:
|
||||
raise ValidationError("Der Wert darf nicht negativ sein.")
|
||||
return value
|
||||
if field in DECIMAL_FIELDS:
|
||||
try:
|
||||
value = Decimal(str(raw).replace(",", "."))
|
||||
except InvalidOperation:
|
||||
raise ValidationError("Bitte eine gültige Dezimalzahl eingeben.")
|
||||
if not value.is_finite() or value < 0:
|
||||
raise ValidationError("Der Wert darf nicht negativ sein.")
|
||||
return value
|
||||
if field == "date":
|
||||
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(str(raw), date_format).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValidationError("Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.")
|
||||
if field in TRISTATE_FIELDS:
|
||||
if raw not in dict(Device.TriState.choices):
|
||||
raise ValidationError("Ungültiger Auswahlwert.")
|
||||
return raw
|
||||
if field in RELATION_FIELDS:
|
||||
try:
|
||||
return ChoiceValue.objects.get(pk=int(raw), kind=RELATION_FIELDS[field], active=True)
|
||||
except (ChoiceValue.DoesNotExist, TypeError, ValueError):
|
||||
raise ValidationError("Ungültiger Auswahlwert.")
|
||||
if field == "employee":
|
||||
try:
|
||||
return Employee.objects.get(pk=int(raw), active=True)
|
||||
except (Employee.DoesNotExist, TypeError, ValueError):
|
||||
raise ValidationError("Ungültiger Mitarbeiter.")
|
||||
return str(raw).strip()
|
||||
|
||||
|
||||
def check_device_version(device, expected_updated_at=None, expected_revision=None):
|
||||
if expected_revision is not None:
|
||||
if isinstance(expected_revision, bool) or str(expected_revision) != str(device.revision):
|
||||
raise RuntimeError("conflict")
|
||||
if expected_updated_at:
|
||||
try:
|
||||
expected = expected_updated_at if isinstance(expected_updated_at, datetime) else datetime.fromisoformat(str(expected_updated_at).replace("Z", "+00:00"))
|
||||
if timezone.is_naive(expected):
|
||||
expected = timezone.make_aware(expected, timezone.get_current_timezone())
|
||||
except (TypeError, ValueError):
|
||||
raise RuntimeError("conflict")
|
||||
if device.updated_at != expected:
|
||||
raise RuntimeError("conflict")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_device_field(
|
||||
device: Device, field: str, raw_value: Any, user, expected_updated_at: str | None = None, *,
|
||||
api_client: str = "", expected_revision=None,
|
||||
) -> Device:
|
||||
if not isinstance(field, str) or field not in INLINE_FIELDS:
|
||||
raise ValidationError("Dieses Feld kann nicht direkt bearbeitet werden.")
|
||||
locked = Device.objects.select_for_update().get(pk=device.pk)
|
||||
check_device_version(locked, expected_updated_at, expected_revision)
|
||||
old = getattr(locked, field)
|
||||
new = coerce_inline_value(field, raw_value)
|
||||
if field in HARDWARE_FIELDS and field not in applicable_fields(locked.device_type):
|
||||
raise ValidationError("Dieses Hardwarefeld gehört nicht zu diesem Gerätetyp.")
|
||||
if serialize_audit_value(field, old) != serialize_audit_value(field, new):
|
||||
setattr(locked, field, new)
|
||||
changed_fields = [field, "updated_at"]
|
||||
cleared_hardware = {}
|
||||
if field == "device_type":
|
||||
cleared_hardware = clear_inapplicable(locked)
|
||||
changed_fields.extend(HARDWARE_FIELDS - applicable_fields(new))
|
||||
if cleared_hardware:
|
||||
changed_fields.append("custom_values")
|
||||
locked.full_clean(exclude=["custom_values"])
|
||||
locked.save(update_fields=changed_fields)
|
||||
add_audit(
|
||||
user, locked, AuditLog.Action.UPDATE, FIELD_LABELS[field],
|
||||
display_device_value(field, old), display_device_value(field, new), field_key=field,
|
||||
old_data=serialize_audit_value(field, old), new_data=serialize_audit_value(field, new),
|
||||
api_client=api_client,
|
||||
)
|
||||
for hardware_field, previous_value in cleared_hardware.items():
|
||||
add_audit(user, locked, AuditLog.Action.UPDATE, FIELD_LABELS[hardware_field],
|
||||
previous_value, "", field_key=hardware_field,
|
||||
old_data=serialize_audit_value(hardware_field, previous_value),
|
||||
new_data=serialize_audit_value(hardware_field, getattr(locked, hardware_field)),
|
||||
api_client=api_client)
|
||||
return locked
|
||||
|
||||
|
||||
def _deserialize_device_value(field: str, raw: Any) -> Any:
|
||||
if raw in (None, ""):
|
||||
if field == "status":
|
||||
raise ValidationError("Ein Status kann nicht leer sein.")
|
||||
return None if field in INTEGER_FIELDS | DECIMAL_FIELDS | set(RELATION_FIELDS) | {"employee"} else ""
|
||||
if field in RELATION_FIELDS:
|
||||
try:
|
||||
if str(raw).isdigit():
|
||||
return ChoiceValue.objects.get(pk=int(raw), kind=RELATION_FIELDS[field])
|
||||
return ChoiceValue.objects.get(label__iexact=str(raw), kind=RELATION_FIELDS[field])
|
||||
except (ChoiceValue.DoesNotExist, ChoiceValue.MultipleObjectsReturned, TypeError, ValueError) as exc:
|
||||
raise ValidationError("Der frühere Auswahlwert ist nicht mehr vorhanden.") from exc
|
||||
if field == "employee":
|
||||
try:
|
||||
if str(raw).isdigit():
|
||||
return Employee.objects.get(pk=int(raw))
|
||||
return Employee.objects.get(code__iexact=str(raw))
|
||||
except (Employee.DoesNotExist, Employee.MultipleObjectsReturned, TypeError, ValueError) as exc:
|
||||
raise ValidationError("Der frühere Bearbeiter ist nicht mehr vorhanden.") from exc
|
||||
if field == "date":
|
||||
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(str(raw), date_format).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValidationError("Das frühere Datum ist ungültig.")
|
||||
if field in INTEGER_FIELDS:
|
||||
return int(raw)
|
||||
if field in DECIMAL_FIELDS:
|
||||
return Decimal(str(raw).replace(",", "."))
|
||||
if field in TRISTATE_FIELDS:
|
||||
reverse_labels = {label: key for key, label in Device.TriState.choices}
|
||||
value = reverse_labels.get(str(raw), raw)
|
||||
if value not in Device.TriState.values:
|
||||
raise ValidationError("Der frühere Auswahlwert ist ungültig.")
|
||||
return value
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _legacy_field_key(log: AuditLog) -> str:
|
||||
key = next((name for name, label in FIELD_LABELS.items() if label == log.field), "")
|
||||
if key:
|
||||
return key
|
||||
definition = FieldDefinition.objects.filter(name=log.field).first()
|
||||
return f"custom:{definition.key}" if definition else ""
|
||||
|
||||
|
||||
def _legacy_custom_value(definition: FieldDefinition, value: str) -> Any:
|
||||
if value == "":
|
||||
return None
|
||||
if definition.field_type == FieldDefinition.FieldType.INTEGER:
|
||||
return int(value)
|
||||
if definition.field_type == FieldDefinition.FieldType.BOOLEAN:
|
||||
return value.lower() in {"1", "true", "yes", "ja"}
|
||||
if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT:
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
except (SyntaxError, ValueError):
|
||||
parsed = [part.strip() for part in value.split(",") if part.strip()]
|
||||
return parsed if isinstance(parsed, list) else [str(parsed)]
|
||||
return value
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def revert_audit_change(log: AuditLog, user) -> AuditLog:
|
||||
locked_log = AuditLog.objects.select_for_update().select_related("device").get(pk=log.pk)
|
||||
if locked_log.action != AuditLog.Action.UPDATE or locked_log.reverted_at:
|
||||
raise ValidationError("Diese Änderung kann nicht zurückgesetzt werden.")
|
||||
field_key = locked_log.field_key or _legacy_field_key(locked_log)
|
||||
if not field_key:
|
||||
raise ValidationError("Das geänderte Feld ist nicht mehr eindeutig zuzuordnen.")
|
||||
|
||||
device = Device.objects.select_for_update().get(pk=locked_log.device_id)
|
||||
if field_key.startswith("custom:"):
|
||||
custom_key = field_key.split(":", 1)[1]
|
||||
definition = FieldDefinition.objects.filter(key=custom_key).first()
|
||||
if not definition:
|
||||
raise ValidationError("Das Zusatzfeld ist nicht mehr vorhanden.")
|
||||
current = device.custom_values.get(custom_key)
|
||||
if locked_log.field_key:
|
||||
if current != locked_log.new_data:
|
||||
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||
restored = locked_log.old_data
|
||||
else:
|
||||
if display_value(current) != locked_log.new_value:
|
||||
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||
restored = _legacy_custom_value(definition, locked_log.old_value)
|
||||
custom_values = dict(device.custom_values)
|
||||
custom_values[custom_key] = restored
|
||||
device.custom_values = custom_values
|
||||
device.save(update_fields=["custom_values", "updated_at"])
|
||||
reversal = add_audit(
|
||||
user, device, AuditLog.Action.UPDATE, locked_log.field,
|
||||
display_value(current), display_value(restored), field_key=field_key,
|
||||
old_data=current, new_data=restored,
|
||||
)
|
||||
else:
|
||||
if field_key not in FIELD_LABELS:
|
||||
raise ValidationError("Dieses Feld kann nicht zurückgesetzt werden.")
|
||||
current = getattr(device, field_key)
|
||||
current_data = serialize_audit_value(field_key, current)
|
||||
if locked_log.field_key:
|
||||
if current_data != locked_log.new_data:
|
||||
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||
raw_old = locked_log.old_data
|
||||
else:
|
||||
current_displays = {display_device_value(field_key, current), display_value(current)}
|
||||
if locked_log.new_value not in current_displays:
|
||||
raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.")
|
||||
raw_old = locked_log.old_value
|
||||
restored = _deserialize_device_value(field_key, raw_old)
|
||||
if field_key in HARDWARE_FIELDS and field_key not in applicable_fields(device.device_type):
|
||||
raise ValidationError("Dieses Hardwarefeld gehört nicht zu diesem Gerätetyp.")
|
||||
setattr(device, field_key, restored)
|
||||
update_fields = [field_key, "updated_at"]
|
||||
if field_key == "device_type":
|
||||
cleared = clear_inapplicable(device)
|
||||
update_fields.extend(HARDWARE_FIELDS - applicable_fields(restored))
|
||||
if cleared:
|
||||
update_fields.append("custom_values")
|
||||
device.full_clean(exclude=["custom_values"])
|
||||
device.save(update_fields=update_fields)
|
||||
reversal = add_audit(
|
||||
user, device, AuditLog.Action.UPDATE, FIELD_LABELS[field_key],
|
||||
display_device_value(field_key, current), display_device_value(field_key, restored), field_key=field_key,
|
||||
old_data=current_data, new_data=serialize_audit_value(field_key, restored),
|
||||
)
|
||||
|
||||
locked_log.reverted_at = timezone.now()
|
||||
locked_log.reverted_by = user
|
||||
locked_log.save(update_fields=["reverted_at", "reverted_by"])
|
||||
return reversal
|
||||
|
||||
|
||||
def validate_custom_value(definition: FieldDefinition, raw: Any) -> Any:
|
||||
if raw in (None, "", []):
|
||||
if definition.required:
|
||||
raise ValidationError(f"{definition.name} ist erforderlich.")
|
||||
return None
|
||||
if definition.field_type == FieldDefinition.FieldType.INTEGER:
|
||||
try:
|
||||
value = Decimal(str(raw))
|
||||
if not value.is_finite() or value != value.to_integral_value():
|
||||
raise ValueError
|
||||
return int(value)
|
||||
except (TypeError, ValueError, InvalidOperation, OverflowError) as exc:
|
||||
raise ValidationError(f"{definition.name}: Bitte eine ganze Zahl eingeben.") from exc
|
||||
if definition.field_type == FieldDefinition.FieldType.DECIMAL:
|
||||
try:
|
||||
value = Decimal(str(raw).replace(",", "."))
|
||||
if not value.is_finite():
|
||||
raise InvalidOperation
|
||||
return str(value)
|
||||
except InvalidOperation as exc:
|
||||
raise ValidationError(f"{definition.name}: Bitte eine Dezimalzahl eingeben.") from exc
|
||||
if definition.field_type == FieldDefinition.FieldType.BOOLEAN:
|
||||
return str(raw).lower() in {"1", "true", "yes", "ja", "on"}
|
||||
if definition.field_type == FieldDefinition.FieldType.DATE:
|
||||
for date_format in ("%d.%m.%Y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(str(raw), date_format).date().isoformat()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValidationError(f"{definition.name}: Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.")
|
||||
if definition.field_type in {FieldDefinition.FieldType.SELECT, FieldDefinition.FieldType.MULTI_SELECT}:
|
||||
values = raw if isinstance(raw, list) else [str(raw)]
|
||||
allowed = set(definition.options.filter(active=True).values_list("label", flat=True))
|
||||
if not definition.allow_custom_values and not set(values).issubset(allowed):
|
||||
raise ValidationError(f"Ungültige Auswahl für {definition.name}.")
|
||||
return values if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT else values[0]
|
||||
return str(raw)
|
||||
Reference in New Issue
Block a user