588 lines
26 KiB
Python
588 lines
26 KiB
Python
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 DeviceRegistration(TimeStampedModel):
|
||
"""Durable receipt so retrying a USB report cannot allocate another device."""
|
||
|
||
request_id = models.UUIDField(unique=True)
|
||
payload_hash = models.CharField(max_length=64)
|
||
device = models.OneToOneField("Device", on_delete=models.PROTECT, related_name="registration")
|
||
|
||
|
||
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)
|
||
|
||
|
||
class DeviceIdentity(models.Model):
|
||
"""Stable intake identity, independent of mutable specifications and USBs."""
|
||
hwid = models.CharField(max_length=100, unique=True)
|
||
device = models.ForeignKey(Device, on_delete=models.PROTECT, related_name='hardware_identities')
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class InspectionSubmission(models.Model):
|
||
request_id = models.UUIDField(unique=True)
|
||
device = models.ForeignKey(Device, on_delete=models.PROTECT, related_name='usb_inspections')
|
||
payload_hash = models.CharField(max_length=64)
|
||
payload = models.JSONField()
|
||
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|