Merge remote-tracking branch 'origin/master' into feature/usb-aio-workflow
Some checks failed
Tests / postgres (push) Has been cancelled
Tests / postgres (pull_request) Has been cancelled

# Conflicts:
#	README.md
#	inventory/test_specification_migration.py
This commit is contained in:
2026-10-14 20:04:37 +02:00
25 changed files with 723 additions and 52 deletions

View File

@@ -38,6 +38,14 @@ class EmployeeSummarySerializer(serializers.ModelSerializer):
class DeviceSerializer(serializers.ModelSerializer):
camera = serializers.CharField(read_only=True)
sound = serializers.CharField(read_only=True)
hdmi = serializers.CharField(read_only=True)
vga = serializers.CharField(read_only=True)
dvi = serializers.CharField(read_only=True)
displayport = serializers.CharField(read_only=True)
aspect_ratio = serializers.CharField(read_only=True)
battery = serializers.CharField(read_only=True)
wifi = serializers.CharField(read_only=True)
lan = serializers.CharField(read_only=True)
@@ -64,7 +72,7 @@ class DeviceSerializer(serializers.ModelSerializer):
"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",
"duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport", "model", "employee",
"donor", "comment", "archived_at", "created_at", "updated_at", "revision",
]
@@ -88,6 +96,13 @@ def active_choice_field(kind, *, allow_null=True):
class DeviceChangesSerializer(StrictSerializer):
camera = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
sound = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
hdmi = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
vga = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
dvi = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
displayport = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
aspect_ratio = serializers.CharField(max_length=20, required=False, allow_blank=True)
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)

View File

@@ -3,18 +3,19 @@
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"}
TRISTATE_FIELDS = frozenset({"battery", "wifi", "lan", "duplex", "color_printing", "camera", "sound", "hdmi", "vga", "dvi", "displayport"})
HARDWARE_FIELDS = COMPUTER_FIELDS | {"resolution", "print_technology", "duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport"}
TYPE_FIELDS = {
"laptop": COMPUTER_FIELDS,
"aio": COMPUTER_FIELDS - {"battery"},
"laptop": COMPUTER_FIELDS | {"camera", "sound"},
"aio": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"},
"desktop": COMPUTER_FIELDS - {"battery", "display_inches"},
"bildschirm": frozenset({"display_inches", "resolution", "ports"}),
"monitor": frozenset({"display_inches", "resolution", "ports"}),
"bildschirm": frozenset({"display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio"}),
"monitor": frozenset({"display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio"}),
"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"},
"all-in-one": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"},
"all in one": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"},
}
@@ -38,7 +39,7 @@ def clear_inapplicable(device):
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)
setattr(device, name, "UNKNOWN" if name in TRISTATE_FIELDS else "" if name in {"cpu", "hwid", "ports", "resolution", "print_technology", "aspect_ratio"} else None)
if old_values:
device.custom_values = {**device.custom_values, "legacy_hardware": legacy}
return old_values

View File

@@ -5,7 +5,7 @@ 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
from .device_types import HARDWARE_FIELDS, TRISTATE_FIELDS, applicable_fields
GERMAN_DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y-%m-%d"]
@@ -45,7 +45,7 @@ class QuickAddForm(forms.Form):
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"}:
if name in TRISTATE_FIELDS:
field.required = False
field.initial = Device.TriState.UNKNOWN
if self.is_bound and name not in applicable_fields(selected_type):
@@ -60,7 +60,7 @@ class QuickAddForm(forms.Form):
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:
if name in TRISTATE_FIELDS and not value:
value = Device.TriState.UNKNOWN
values[name] = value
return values
@@ -88,6 +88,13 @@ class QuickAddForm(forms.Form):
class DeviceForm(forms.ModelForm):
camera = forms.ChoiceField(label="Kamera", choices=Device.TriState.choices, required=False)
sound = forms.ChoiceField(label="Sound", choices=Device.TriState.choices, required=False)
hdmi = forms.ChoiceField(label="HDMI", choices=Device.TriState.choices, required=False)
vga = forms.ChoiceField(label="VGA", choices=Device.TriState.choices, required=False)
dvi = forms.ChoiceField(label="DVI", choices=Device.TriState.choices, required=False)
displayport = forms.ChoiceField(label="DP", choices=Device.TriState.choices, required=False)
aspect_ratio = forms.CharField(label="Seitenverhältnis", max_length=20, required=False, help_text="z. B. 16:9, 4:3 oder 21:9")
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)
@@ -162,7 +169,7 @@ class DeviceForm(forms.ModelForm):
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:
if name in TRISTATE_FIELDS and not value:
value = Device.TriState.UNKNOWN
setattr(device, name, value)
if commit:
@@ -179,11 +186,18 @@ class InspectionForm(forms.Form):
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"):
for name in ("cpu", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "hwid", "ports", "resolution", "print_technology", "duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport"):
if name in applicable_fields(device.device_type):
self.fields[name] = deepcopy(DeviceForm.base_fields[name])
self.initial.setdefault(name, getattr(device, name))
def clean(self):
cleaned = super().clean()
for name in TRISTATE_FIELDS & self.fields.keys():
if not cleaned.get(name):
cleaned[name] = Device.TriState.UNKNOWN
return cleaned
@property
def specification_fields(self):
return [self[name] for name in self.fields if name != "defects"]

View File

@@ -14,11 +14,18 @@ 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 .device_types import TRISTATE_FIELDS, clear_inapplicable
from .services import add_audit
COLUMN_MAP = {
"kamera": "camera",
"sound": "sound",
"hdmi": "hdmi",
"vga": "vga",
"dvi": "dvi",
"dp": "displayport",
"displayport": "displayport", "seitenverhältnis": "aspect_ratio", "seitenverhaeltnis": "aspect_ratio",
"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",
@@ -37,6 +44,13 @@ EXPORT_COLUMNS = [
("print_technology", "Druckverfahren"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"),
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"),
("comment", "Kommentar"),
("camera", "Kamera"),
("sound", "Sound"),
("hdmi", "HDMI"),
("vga", "VGA"),
("dvi", "DVI"),
("displayport", "DP"),
("aspect_ratio", "Seitenverhältnis"),
]
@@ -155,6 +169,13 @@ def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]:
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")),
camera=tri_state(data.get("camera")),
sound=tri_state(data.get("sound")),
hdmi=tri_state(data.get("hdmi")),
vga=tri_state(data.get("vga")),
dvi=tri_state(data.get("dvi")),
displayport=tri_state(data.get("displayport")),
aspect_ratio=str(data.get("aspect_ratio") or "").strip(),
model=str(data.get("model") or "").strip(),
employee=employee, donor=str(data.get("donor") or "").strip(), comment=str(data.get("comment") or "").strip(),
)
@@ -174,7 +195,7 @@ def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]:
def export_value(device: Device, field: str) -> Any:
value = getattr(device, field)
if field in {"battery", "wifi", "lan", "duplex", "color_printing"}:
if field in TRISTATE_FIELDS:
return dict(Device.TriState.choices).get(value, value)
if hasattr(value, "label"):
return value.label

View File

@@ -0,0 +1,58 @@
# Generated by Django 5.2.18 on 2026-10-13 08:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0013_deviceregistration'),
]
operations = [
migrations.AddField(
model_name='allinonespecification',
name='camera',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='allinonespecification',
name='sound',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='laptopspecification',
name='camera',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='laptopspecification',
name='sound',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='aspect_ratio',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='monitorspecification',
name='displayport',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='dvi',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='hdmi',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='vga',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
]

View File

@@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("inventory", "0014_allinonespecification_camera_and_more"),
("inventory", "0015_inspectionsubmission"),
]
operations = []

View File

@@ -153,6 +153,13 @@ class NumberSequence(models.Model):
HARDWARE_DEFAULTS = {
"camera": "UNKNOWN",
"sound": "UNKNOWN",
"hdmi": "UNKNOWN",
"vga": "UNKNOWN",
"dvi": "UNKNOWN",
"displayport": "UNKNOWN",
"aspect_ratio": "",
"battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN",
"display_inches": None, "ram_gb": None, "ssd_gb": None, "hdd_gb": None,
"cpu": "", "hwid": "", "ports": "", "resolution": "",
@@ -216,6 +223,13 @@ class Device(TimeStampedModel):
cpu = HardwareValue("cpu")
hwid = HardwareValue("hwid")
ports = HardwareValue("ports")
camera = HardwareValue("camera")
sound = HardwareValue("sound")
hdmi = HardwareValue("hdmi")
vga = HardwareValue("vga")
dvi = HardwareValue("dvi")
displayport = HardwareValue("displayport")
aspect_ratio = HardwareValue("aspect_ratio")
resolution = HardwareValue("resolution")
print_technology = HardwareValue("print_technology")
duplex = HardwareValue("duplex")
@@ -358,10 +372,12 @@ class ComputerCore(models.Model):
class LaptopSpecification(ComputerCore):
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports")
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports", "camera", "sound")
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)
camera = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
sound = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
class DesktopSpecification(ComputerCore):
@@ -370,17 +386,24 @@ class DesktopSpecification(ComputerCore):
class AllInOneSpecification(ComputerCore):
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "wifi", "lan", "ports")
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "wifi", "lan", "ports", "camera", "sound")
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)
camera = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
sound = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
class MonitorSpecification(models.Model):
hardware_field_names = ("display_inches", "resolution", "ports")
hardware_field_names = ("display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio")
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)
hdmi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
vga = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
dvi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
displayport = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
aspect_ratio = models.CharField(max_length=20, blank=True)
class PrinterSpecification(models.Model):

View File

@@ -121,6 +121,18 @@ def device_queryset(params, archived: bool = False) -> QuerySet[Device]:
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}))
for name in ("camera", "sound", "hdmi", "vga", "dvi", "displayport"):
value = params.get(name)
if value:
if value not in Device.TriState.values:
return qs.none()
relations = ("laptop_specification", "aio_specification") if name in {"camera", "sound"} else ("monitor_specification",)
query = Q()
for relation in relations:
query |= Q(**{f"{relation}__{name}": value})
qs = qs.filter(query)
if ratio := params.get("aspect_ratio", "").strip():
qs = qs.filter(monitor_specification__aspect_ratio=ratio)
vendor_query = Q()
for value in params.getlist("cpu_vendor"):
if value in CPU_VENDOR_QUERIES:

View File

@@ -11,10 +11,17 @@ 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
from .device_types import TRISTATE_FIELDS, HARDWARE_FIELDS, applicable_fields, bulk_create_specifications, clear_inapplicable
FIELD_LABELS = {
"camera": "Kamera",
"sound": "Sound",
"hdmi": "HDMI",
"vga": "VGA",
"dvi": "DVI",
"displayport": "DP",
"aspect_ratio": "Seitenverhältnis",
"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",
@@ -30,7 +37,6 @@ RELATION_FIELDS = {
}
INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"}
DECIMAL_FIELDS = {"price", "display_inches"}
TRISTATE_FIELDS = {"battery", "wifi", "lan", "duplex", "color_printing"}
_UNSET = object()

View File

@@ -49,7 +49,7 @@
@media(max-width:760px){.config-grid-three{grid-template-columns:1fr}.config-grid-three .panel:first-child{grid-column:auto}.config-row{align-items:flex-start}.config-row form{padding-top:6px}}
/* Fixierter Listenarbeitsbereich */
.device-list-page{height:100vh;overflow:hidden}.device-list-page .main{height:100vh;min-height:0;display:flex;flex-direction:column;padding-bottom:12px}.device-list-page .page-head,.device-list-page .toolbar{flex:0 0 auto}.device-list-page #device-table-container{display:flex;flex:1 1 auto;min-height:0;flex-direction:column}.device-list-page #device-table-container .table-shell{flex:1 1 auto;min-height:0;overflow:auto}.device-list-page #device-table-container .pagination{flex:0 0 auto;margin:0;padding:10px 2px 0}.device-list-page .device-table th{top:0;z-index:6}.device-list-page .table-shell{scrollbar-gutter:stable}
.device-list-page{min-height:100vh}.device-list-page .main{height:100vh;min-height:600px;display:flex;flex-direction:column;padding-bottom:12px}.device-list-page .page-head,.device-list-page .toolbar,.device-list-page .main>nav{flex:0 0 auto}.device-list-page #device-table-container{display:flex;flex:1 0 240px;min-height:240px;flex-direction:column}.device-list-page #device-table-container .table-shell{flex:1 1 auto;min-height:0;overflow:auto}.device-list-page #device-table-container .pagination{flex:0 0 auto;margin:0;padding:10px 2px 0}.device-list-page .device-table th{top:0;z-index:6}.device-list-page .table-shell{scrollbar-gutter:stable}
/* Bearbeitbare Statusfarben */
.status-colored{color:var(--status-color);background:color-mix(in srgb,var(--status-color) 12%,white);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--status-color) 24%,white)}.status-color-dot{display:inline-block;width:11px;height:11px;flex:0 0 auto;border-radius:50%;background:var(--status-color);box-shadow:0 0 0 3px color-mix(in srgb,var(--status-color) 13%,white)}.config-value-label{flex-direction:row!important;align-items:center;gap:9px}.color-field{border:0;margin:0;padding:0}.color-field legend{font-size:13px;font-weight:600;margin-bottom:7px}.color-field>small{display:block;color:var(--muted);margin-top:7px}.color-options{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.color-option{position:relative;min-height:48px;display:flex!important;flex-direction:row!important;align-items:center;gap:8px;padding:8px 9px;border:1px solid #d0d5dd;border-radius:7px;cursor:pointer;font-weight:600}.color-option:hover{background:#f8fafc}.color-option:has(input:checked){border-color:var(--choice-color);box-shadow:0 0 0 3px color-mix(in srgb,var(--choice-color) 14%,white)}.color-option input{position:absolute;opacity:0;width:1px!important;min-height:1px!important}.color-option i{width:18px;height:18px;flex:0 0 auto;border-radius:50%;background:var(--choice-color);box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}.color-option span{font-size:12px}

View File

@@ -221,7 +221,7 @@
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)));
const visible=new Set(storedVisible||availableKeys);
if(!localStorage.getItem(visibilityUpgradeKey)){
requiredPhotoColumns.forEach(key=>visible.add(key));
localStorage.setItem(visibilityUpgradeKey,"1");
@@ -229,6 +229,12 @@
localStorage.setItem(visibleStorageKey,JSON.stringify(migratedVisible));
savePreferences({visible_columns:migratedVisible});
}
const knownColumnsKey=`csr-known-columns-${userKey}`;
const knownColumns=readJSON(localStorage.getItem(knownColumnsKey),null);
const previouslyKnown=Array.isArray(knownColumns)?knownColumns:(storedOrder||[]).filter(key=>!key.startsWith("custom_"));
availableKeys.filter(key=>!previouslyKnown.includes(key)).forEach(key=>visible.add(key));
localStorage.setItem(knownColumnsKey,JSON.stringify([...new Set([...previouslyKnown,...availableKeys])]));
localStorage.setItem(visibleStorageKey,JSON.stringify([...visible]));
const applyVisibility=()=>columns.forEach(column=>container.querySelectorAll(`[data-column="${column.key}"]`).forEach(element=>element.style.display=visible.has(column.key)?"":"none"));
panel.replaceChildren();
@@ -243,7 +249,7 @@
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));
const values=[...visible];
localStorage.setItem(visibleStorageKey,JSON.stringify(values));
savePreferences({visible_columns:values});
applyVisibility();

View File

@@ -21,6 +21,7 @@
{% 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 %}
{% for name, label in extra_hardware_columns %}<th data-column="{{ name }}">{{ label }}</th>{% endfor %}
<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>
@@ -49,11 +50,12 @@
{% 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 %}
{% for name, label in extra_hardware_columns %}<td data-column="{{ name }}">{% if name in device.hardware_fields %}{% hardware_display device name %}{% else %}—{% endif %}</td>{% endfor %}
<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 %}
{% for field in custom_columns %}<td data-column="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key|display_custom|default_if_none:'—' }}</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>

View File

@@ -1,4 +1,4 @@
{% extends 'inventory/base.html' %}{% load inventory_tags %}{% block title %}Gerät #{{ device.number }}{% endblock %}{% block content %}
{% extends 'inventory/base.html' %}{% load inventory_tags l10n %}{% 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>
@@ -30,7 +30,7 @@
<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 %}
{% 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 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 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 }}">{{ custom_values|get_item:field.key|default_if_none:""|unlocalize }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true" {% if custom_values|get_item:field.key == True or custom_values|get_item:field.key == "true" %}selected{% endif %}>JA</option><option value="false" {% if custom_values|get_item:field.key == False or custom_values|get_item:field.key == "false" %}selected{% endif %}>NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ 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="{{ custom_values|get_item:field.key|default_if_none:""|unlocalize }}" step="any" 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>
{% if can_edit_devices and not device.archived_at %}<form method="post" action="{% url 'inventory:archive-device' device.pk %}" data-confirm="Gerät #{{ device.number }} wirklich archivieren?" data-confirm-action="Archivieren" class="archive-action">{% csrf_token %}<button class="btn danger" type="submit">Gerät archivieren</button></form>{% endif %}

View File

@@ -74,6 +74,13 @@
<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>Kamera<select name="camera"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'camera' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>Sound<select name="sound"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'sound' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>HDMI<select name="hdmi"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'hdmi' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>VGA<select name="vga"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'vga' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>DVI<select name="dvi"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'dvi' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>DP<select name="displayport"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'displayport' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>Seitenverhältnis<input name="aspect_ratio" value="{{ request.GET.aspect_ratio }}" placeholder="z. B. 16:9"></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>

View File

@@ -46,6 +46,8 @@ def contains_value(values, value):
@register.filter
def display_custom(value):
if value is None or value == "":
return "—"
if isinstance(value, list):
return ", ".join(str(item) for item in value)
if value is True:
@@ -64,3 +66,9 @@ def german_date(value):
return datetime.strptime(text, "%Y-%m-%d").strftime("%d.%m.%Y")
except ValueError:
return text
@register.simple_tag
def hardware_display(device, name):
from inventory.services import display_device_value
return display_device_value(name, getattr(device, name)) or "—"

View File

@@ -0,0 +1,123 @@
"""Regression coverage for Gitea issues #1–#3."""
from io import BytesIO
from django.contrib.auth.models import User
from django.core.management import call_command
from django.http import QueryDict
from django.test import TestCase
from django.urls import reverse
from openpyxl import load_workbook
from .api import DeviceChangesSerializer, DeviceSerializer
from .forms import DeviceForm, InspectionForm, QuickAddForm
from .import_export import import_workbook, suggested_mapping, xlsx_response
from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition
from .query import device_queryset
from .services import update_device_field
class IssueRegressionTests(TestCase):
@classmethod
def setUpTestData(cls):
call_command("seed_initial_data", verbosity=0)
cls.user = User.objects.create_superuser("issue-admin", password="test-password")
cls.status = ChoiceValue.objects.get(kind="status", label="Eingang")
cls.laptop = ChoiceValue.objects.get(kind="device_type", label="Laptop")
cls.aio = ChoiceValue.objects.get(kind="device_type", label="AiO")
cls.monitor = ChoiceValue.objects.get(kind="device_type", label="Bildschirm")
def setUp(self):
self.client.force_login(self.user)
def device(self, **values):
return Device.objects.create(status=self.status, device_type=self.laptop, **values)
def test_custom_values_render_and_survive_an_unrelated_edit(self):
for key, kind in (("enabled", "boolean"), ("disabled", "boolean"), ("count", "integer"), ("amount", "decimal"), ("empty", "text")):
FieldDefinition.objects.create(name=key, key=key, field_type=kind, show_in_list=True)
values = {"enabled": True, "disabled": False, "count": 0, "amount": "1.25", "empty": None}
device = self.device(custom_values=values)
url = reverse("inventory:device-detail", args=[device.pk])
response = self.client.get(url)
self.assertContains(response, 'value="true" selected')
self.assertContains(response, 'value="false" selected')
self.assertContains(response, 'name="custom_count" value="0"')
self.assertContains(response, 'name="custom_amount" value="1.25"')
self.assertContains(response, 'name="custom_empty" value=""')
listing = self.client.get(reverse("inventory:device-list"))
self.assertContains(listing, '<td data-column="custom_count">0</td>', html=True)
self.assertContains(listing, '<td data-column="custom_disabled">NEIN</td>', html=True)
payload = {"status": self.status.pk, "date": "2026-10-13", "device_type": self.laptop.pk,
"expected_revision": device.revision, "custom_enabled": "true", "custom_disabled": "false",
"custom_count": "0", "custom_amount": "1.25", "custom_empty": "", "model": "Edited"}
response = self.client.post(url, payload)
self.assertEqual(response.status_code, 302)
device.refresh_from_db()
self.assertEqual(device.custom_values, values)
payload.update(expected_revision=device.revision, custom_count="invalid", custom_enabled="false")
response = self.client.post(url, payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["custom_values"]["count"], "invalid")
self.assertEqual(response.context["custom_values"]["enabled"], "false")
device.refresh_from_db()
self.assertEqual(device.custom_values, values)
def test_hardware_persistence_api_and_type_changes(self):
for device_type in (self.laptop, self.aio):
device = Device.objects.create(status=self.status, device_type=device_type, camera="YES", sound="NO")
device.refresh_from_db()
self.assertEqual((device.camera, device.sound), ("YES", "NO"))
self.assertEqual(DeviceSerializer(device).data["camera"], "YES")
device = update_device_field(device, "sound", "YES", self.user, expected_revision=device.revision)
self.assertEqual(device.sound, "YES")
self.assertTrue(AuditLog.objects.filter(device=device, field_key="sound").exists())
device.device_type = self.monitor
device.save()
device.refresh_from_db()
self.assertEqual(device.camera, "UNKNOWN")
self.assertEqual(device.custom_values["legacy_hardware"]["camera"], "YES")
serializer = DeviceChangesSerializer(data={"hdmi": "sometimes"})
self.assertFalse(serializer.is_valid())
def test_monitor_filters_distinguish_no_and_unknown(self):
yes = Device.objects.create(status=self.status, device_type=self.monitor, hdmi="YES", vga="NO", dvi="NO", displayport="YES", aspect_ratio="16:9", ports="USB-C")
no = Device.objects.create(status=self.status, device_type=self.monitor, hdmi="NO")
unknown = Device.objects.create(status=self.status, device_type=self.monitor)
laptop = self.device(camera="YES", sound="NO")
for query, expected in (("hdmi=YES&displayport=YES&aspect_ratio=16:9", [yes]), ("hdmi=NO", [no]), ("hdmi=UNKNOWN", [unknown]), ("camera=YES&sound=NO", [laptop]), ("hdmi=invalid", [])):
self.assertEqual(list(device_queryset(QueryDict(query))), expected)
yes.refresh_from_db()
self.assertEqual(yes.ports, "USB-C")
listing = self.client.get(reverse("inventory:device-list"), {"type": self.monitor.pk, "hdmi": "YES"})
self.assertContains(listing, '<td data-column="hdmi">JA</td>', html=True)
self.assertNotContains(listing, '<th data-column="camera">')
def test_quick_add_and_inspection_include_new_fields(self):
form = QuickAddForm({"number": "1", "quantity": "1", "status": self.status.pk,
"date": "2026-10-13", "device_type": self.laptop.pk,
"employee": Employee.objects.first().pk, "camera": "YES", "sound": "NO"})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.selected_hardware_values()["camera"], "YES")
device = self.device(camera="YES")
self.assertEqual(DeviceForm(instance=device)["camera"].value(), "YES")
inspection = InspectionForm({"defects": "No camera test yet"}, device=device)
self.assertTrue(inspection.is_valid(), inspection.errors)
self.assertEqual(inspection.cleaned_data["camera"], "UNKNOWN")
monitor = Device.objects.create(status=self.status, device_type=self.monitor)
self.assertIn("displayport", InspectionForm(device=monitor).fields)
self.assertNotIn("camera", InspectionForm(device=monitor).fields)
def test_new_hardware_fields_round_trip_through_xlsx(self):
laptop = self.device(camera="YES", sound="NO")
monitor = Device.objects.create(status=self.status, device_type=self.monitor, hdmi="YES", vga="NO", dvi="UNKNOWN", displayport="YES", aspect_ratio="21:9", ports="USB-C")
payload = xlsx_response(Device.objects.all()).content
book = load_workbook(BytesIO(payload))
headers = [cell.value for cell in book.active[1]]
mapping = suggested_mapping(headers)
expected = {device.number: DeviceSerializer(device).data for device in (laptop, monitor)}
Device.objects.all().delete()
result = import_workbook(BytesIO(payload), mapping, self.user)
self.assertEqual(result["created"], 2)
for device in Device.objects.all():
for name in ("camera", "sound", "hdmi", "vga", "dvi", "displayport", "aspect_ratio", "ports"):
self.assertEqual(getattr(device, name), expected[device.number][name])

View File

@@ -7,7 +7,11 @@ 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")]
def restore_latest_schema():
executor = MigrationExecutor(connection)
executor.migrate(executor.loader.graph.leaf_nodes())
class SpecificationMigrationTests(TransactionTestCase):
@@ -40,8 +44,7 @@ class SpecificationMigrationTests(TransactionTestCase):
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:
executor = MigrationExecutor(connection)
executor.migrate(executor.loader.graph.leaf_nodes())
restore_latest_schema()
class DeviceTypeTableMigrationTests(TransactionTestCase):
@@ -61,8 +64,8 @@ class DeviceTypeTableMigrationTests(TransactionTestCase):
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
restore_latest_schema()
apps = MigrationExecutor(connection).loader.project_state().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")
@@ -70,5 +73,30 @@ class DeviceTypeTableMigrationTests(TransactionTestCase):
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:
executor = MigrationExecutor(connection)
executor.migrate(executor.loader.graph.leaf_nodes())
restore_latest_schema()
class EquipmentFieldMigrationTests(TransactionTestCase):
def test_existing_connection_notes_and_hardware_are_preserved(self):
old = [("inventory", "0013_deviceregistration")]
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")
Monitor = apps.get_model("inventory", "MonitorSpecification")
status, _ = Choice.objects.get_or_create(kind="status", label="Eingang")
kind, _ = Choice.objects.get_or_create(kind="device_type", label="Bildschirm")
device = Device.objects.create(number=93001, status=status, device_type=kind)
Monitor.objects.create(device=device, ports="HDMI, VGA; DP ungetestet", resolution="1920x1080")
restore_latest_schema()
apps = MigrationExecutor(connection).loader.project_state().apps
specification = apps.get_model("inventory", "MonitorSpecification").objects.get(device_id=device.pk)
self.assertEqual(specification.ports, "HDMI, VGA; DP ungetestet")
self.assertEqual(specification.resolution, "1920x1080")
self.assertEqual(specification.hdmi, "UNKNOWN")
self.assertEqual(specification.displayport, "UNKNOWN")
self.assertEqual(specification.aspect_ratio, "")
finally:
restore_latest_schema()

View File

@@ -199,11 +199,13 @@ def _list_context(request: HttpRequest, archived: bool = False) -> dict:
"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,
"visible_hardware_fields": applicable_fields(selected_type) if selected_type else HARDWARE_FIELDS,
"extra_hardware_columns": [(name, FIELD_LABELS[name]) for name in ("camera", "sound", "hdmi", "vga", "dvi", "displayport", "aspect_ratio") if not selected_type or name in applicable_fields(selected_type)],
"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,
"tristate_choices": Device.TriState.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,
@@ -410,6 +412,10 @@ def device_detail(request: HttpRequest, pk: int) -> HttpResponse:
type_field_map = _hardware_type_map()
selected_type_id = str(form["device_type"].value() or "")
context = {
"custom_values": {
definition.key: (request.POST.getlist(f"custom_{definition.key}") if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT else request.POST.get(f"custom_{definition.key}", ""))
for definition in definitions
} if request.method == "POST" else device.custom_values,
"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,
@@ -794,6 +800,13 @@ def import_map(request: HttpRequest, pk: int) -> HttpResponse:
("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"),
("camera", "Kamera"),
("sound", "Sound"),
("hdmi", "HDMI"),
("vga", "VGA"),
("dvi", "DVI"),
("displayport", "DP"),
("aspect_ratio", "Seitenverhältnis"),
("resolution", "Auflösung"), ("print_technology", "Druckverfahren"),
("duplex", "Duplex"), ("color_printing", "Farbdruck"),
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]