Initialize clean CSR inventory repository

This commit is contained in:
DenisToxic
2026-09-29 08:43:05 +02:00
commit 059f386c60
107 changed files with 9639 additions and 0 deletions

662
inventory/tests.py Normal file
View File

@@ -0,0 +1,662 @@
import json
import tempfile
import zipfile
from datetime import date, timedelta
from io import BytesIO
from django.contrib.auth.models import User
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from openpyxl import Workbook, load_workbook
from .forms import DeviceForm
from .import_export import import_workbook
from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, InventoryAPIKey, NumberSequence, UserPreference, UserProfile
class InventoryTestCase(TestCase):
@classmethod
def setUpTestData(cls):
call_command("seed_initial_data", verbosity=0)
cls.status = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Eingang")
cls.done = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Fertig")
cls.device_type = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Laptop")
cls.employee = Employee.objects.get(code="JH")
cls.admin = User.objects.create_user("admin", password="strong-test-password", is_superuser=True, is_staff=True)
cls.editor = User.objects.create_user("editor", password="strong-test-password")
cls.editor.profile.role = UserProfile.Role.EDITOR
cls.editor.profile.save()
cls.viewer = User.objects.create_user("viewer", password="strong-test-password")
def make_device(self, **kwargs):
defaults = {"status": self.status, "model": "Lenovo ThinkPad P50", "cpu": "Intel Core i5-8250U"}
defaults.update(kwargs)
return Device.objects.create(**defaults)
def test_device_number_is_automatic_unique_and_not_reused_after_archive(self):
first = self.make_device()
second = self.make_device()
self.assertEqual(second.number, first.number + 1)
first.archived_at = first.updated_at
first.save()
third = self.make_device()
self.assertEqual(third.number, second.number + 1)
self.assertEqual(Device.objects.values("number").distinct().count(), 3)
def test_imported_high_number_advances_sequence(self):
imported = self.make_device(number=3068)
following = self.make_device()
self.assertEqual(following.number, 3069)
def test_quick_add_creates_device_and_audit(self):
self.client.force_login(self.editor)
response = self.client.post(reverse("inventory:quick-add"), {"number": 25, "quantity": 1, "shelf": "A1", "status": self.status.pk, "date": "19.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "donor": "Testspender"})
self.assertEqual(response.status_code, 302)
device = Device.objects.get(shelf="A1")
self.assertEqual(device.number, 25)
self.assertEqual(device.date, date(2026, 9, 19))
self.assertEqual(device.device_type, self.device_type)
self.assertEqual(device.employee, self.employee)
self.assertEqual(device.donor, "Testspender")
self.assertTrue(AuditLog.objects.filter(device=device, action=AuditLog.Action.CREATE).exists())
self.assertEqual(response["Location"], reverse("inventory:quick-add"))
duplicate = self.client.post(reverse("inventory:quick-add"), {"number": 25, "quantity": 1, "shelf": "A2", "status": self.status.pk, "date": "19.09.2026", "device_type": "", "employee": self.employee.pk, "donor": ""})
self.assertEqual(duplicate.status_code, 200)
self.assertContains(duplicate, "bereits vergeben")
self.assertEqual(Device.objects.filter(number=25).count(), 1)
def test_quick_add_prefills_next_number_and_today(self):
self.make_device(number=40)
self.client.force_login(self.editor)
response = self.client.get(reverse("inventory:quick-add"))
self.assertContains(response, 'name="number"')
self.assertContains(response, 'value="41"')
self.assertContains(response, timezone.localdate().strftime("%d.%m.%Y"))
self.assertContains(response, "TT.MM.JJJJ")
def test_quick_add_shows_type_fields_and_saves_hardware(self):
self.client.force_login(self.editor)
page = self.client.get(reverse("inventory:quick-add"))
self.assertContains(page, 'data-device-type-form')
self.assertContains(page, 'data-hardware-field="ram_gb"')
self.assertContains(page, 'id="device-type-fields"')
response = self.client.post(reverse("inventory:quick-add"), {
"number": 45, "quantity": 2, "status": self.status.pk,
"date": "28.09.2026", "device_type": self.device_type.pk,
"employee": self.employee.pk, "model": "ThinkPad T14",
"cpu": "Ryzen 5", "ram_gb": "16", "ssd_gb": "512",
"battery": "YES", "wifi": "YES", "lan": "NO", "ports": "USB-C, HDMI",
})
self.assertEqual(response.status_code, 302)
devices = list(Device.objects.filter(number__in=(45, 46)).order_by("number"))
self.assertEqual(len(devices), 2)
self.assertTrue(all(device.ram_gb == 16 and device.ssd_gb == 512 for device in devices))
self.assertTrue(all(device.ports == "USB-C, HDMI" and device.model == "ThinkPad T14" for device in devices))
printer = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Drucker")
response = self.client.post(reverse("inventory:quick-add"), {
"number": 47, "quantity": 1, "status": self.status.pk,
"date": "28.09.2026", "device_type": printer.pk,
"employee": self.employee.pk, "print_technology": "Laser",
"duplex": "YES", "color_printing": "NO", "ram_gb": "999",
})
self.assertEqual(response.status_code, 302)
device = Device.objects.get(number=47)
self.assertEqual(device.print_technology, "Laser")
self.assertIsNone(device.ram_gb)
def test_quick_add_creates_consecutive_device_batch(self):
self.client.force_login(self.editor)
response = self.client.post(reverse("inventory:quick-add"), {
"number": 50, "quantity": 4, "shelf": "B2", "status": self.status.pk,
"date": "19.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "donor": "Serienspender",
})
self.assertEqual(response.status_code, 302)
devices = list(Device.objects.filter(number__range=(50, 53)).order_by("number"))
self.assertEqual([device.number for device in devices], [50, 51, 52, 53])
self.assertTrue(all(device.device_type == self.device_type for device in devices))
self.assertTrue(all(device.employee == self.employee for device in devices))
self.assertTrue(all(device.donor == "Serienspender" for device in devices))
self.assertEqual(AuditLog.objects.filter(device__in=devices, action=AuditLog.Action.CREATE).count(), 4)
self.assertEqual(NumberSequence.suggested_device_number(), 54)
def test_quick_add_batch_rejects_number_range_conflict(self):
self.make_device(number=62)
self.client.force_login(self.editor)
response = self.client.post(reverse("inventory:quick-add"), {
"number": 60, "quantity": 5, "shelf": "", "status": self.status.pk,
"date": "19.09.2026", "device_type": "", "employee": self.employee.pk, "donor": "",
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Gerätenummer 62 ist bereits vergeben")
self.assertFalse(Device.objects.filter(number__in=[60, 61, 63, 64]).exists())
def test_viewer_cannot_create_or_inline_edit(self):
device = self.make_device(ram_gb=4)
self.client.force_login(self.viewer)
self.assertEqual(self.client.get(reverse("inventory:quick-add")).status_code, 403)
response = self.client.post(reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": 8}), content_type="application/json")
self.assertEqual(response.status_code, 403)
device.refresh_from_db()
self.assertEqual(device.ram_gb, 4)
def test_quick_add_requires_employee(self):
self.client.force_login(self.editor)
response = self.client.post(reverse("inventory:quick-add"), {
"number": 70, "quantity": 1, "shelf": "", "status": self.status.pk,
"date": "19.09.2026", "device_type": self.device_type.pk, "employee": "", "donor": "",
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Bitte einen Bearbeiter auswählen")
self.assertFalse(Device.objects.filter(number=70).exists())
def test_inline_edit_validates_updates_and_audits(self):
device = self.make_device(ram_gb=4)
self.client.force_login(self.editor)
response = self.client.post(
reverse("inventory:inline-update", args=[device.pk]),
data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
device.refresh_from_db()
self.assertEqual(device.ram_gb, 8)
self.assertTrue(AuditLog.objects.filter(device=device, field="RAM", old_value="4", new_value="8").exists())
def test_inline_date_accepts_and_displays_german_format(self):
device = self.make_device(date=date(2026, 9, 1))
self.client.force_login(self.editor)
response = self.client.post(
reverse("inventory:inline-update", args=[device.pk]),
data=json.dumps({"field": "date", "value": "19.09.2026", "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["display"], "19.09.2026")
self.assertEqual(response.json()["value"], "2026-09-19")
device.refresh_from_db()
self.assertEqual(device.date, date(2026, 9, 19))
def test_editor_can_bulk_update_status_and_recipient(self):
recipient = ChoiceValue.objects.get(kind=ChoiceValue.Kind.RECIPIENT, label="NGO")
first = self.make_device(number=7201)
second = self.make_device(number=7202)
untouched = self.make_device(number=7203)
self.client.force_login(self.editor)
response = self.client.post(reverse("inventory:bulk-update-devices"), {
"device_ids": [first.pk, second.pk],
"status": self.done.pk,
"recipient": recipient.pk,
"return_query": f"status={self.status.pk}&sort=number&dir=asc",
})
self.assertEqual(response.status_code, 302)
self.assertIn(f"status={self.status.pk}", response["Location"])
first.refresh_from_db(); second.refresh_from_db(); untouched.refresh_from_db()
self.assertEqual((first.status, second.status), (self.done, self.done))
self.assertEqual((first.recipient, second.recipient), (recipient, recipient))
self.assertEqual(untouched.status, self.status)
self.assertIsNone(untouched.recipient)
self.assertEqual(AuditLog.objects.filter(device__in=[first, second], field="Status").count(), 2)
self.assertEqual(AuditLog.objects.filter(device__in=[first, second], field="Empfänger").count(), 2)
def test_viewer_cannot_bulk_update_devices(self):
device = self.make_device(number=7210)
self.client.force_login(self.viewer)
response = self.client.post(reverse("inventory:bulk-update-devices"), {
"device_ids": [device.pk], "status": self.done.pk,
})
self.assertEqual(response.status_code, 403)
device.refresh_from_db()
self.assertEqual(device.status, self.status)
def test_detail_navigation_respects_current_filters_and_sorting(self):
first = self.make_device(number=7301, status=self.done)
second = self.make_device(number=7302, status=self.done)
self.make_device(number=7303, status=self.status)
self.client.force_login(self.editor)
response = self.client.get(reverse("inventory:device-detail", args=[first.pk]), {
"status": self.done.pk, "sort": "number", "dir": "asc",
})
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.context["previous_device_id"])
self.assertEqual(response.context["next_device_id"], second.pk)
self.assertEqual(response.context["navigation_position"], 1)
self.assertEqual(response.context["navigation_total"], 2)
self.assertContains(response, f"/devices/{second.pk}/?status={self.done.pk}")
def test_editor_can_revert_a_single_change_from_history(self):
device = self.make_device(ram_gb=4)
self.client.force_login(self.editor)
self.client.post(
reverse("inventory:inline-update", args=[device.pk]),
data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
)
change = AuditLog.objects.get(device=device, field="RAM", old_value="4", new_value="8")
response = self.client.post(reverse("inventory:audit-revert", args=[change.pk]))
self.assertEqual(response.status_code, 302)
device.refresh_from_db()
change.refresh_from_db()
self.assertEqual(device.ram_gb, 4)
self.assertIsNotNone(change.reverted_at)
self.assertEqual(change.reverted_by, self.editor)
self.assertTrue(AuditLog.objects.filter(device=device, field_key="ram_gb", old_value="8", new_value="4").exists())
def test_old_change_is_not_reverted_after_same_field_changed_again(self):
device = self.make_device(ram_gb=4)
self.client.force_login(self.editor)
first = self.client.post(
reverse("inventory:inline-update", args=[device.pk]),
data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
)
device.refresh_from_db()
old_change = AuditLog.objects.get(device=device, field="RAM", old_value="4", new_value="8")
self.client.post(
reverse("inventory:inline-update", args=[device.pk]),
data=json.dumps({"field": "ram_gb", "value": "16", "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
)
self.client.post(reverse("inventory:audit-revert", args=[old_change.pk]))
device.refresh_from_db()
old_change.refresh_from_db()
self.assertEqual(first.status_code, 200)
self.assertEqual(device.ram_gb, 16)
self.assertIsNone(old_change.reverted_at)
def test_optimistic_concurrency_returns_conflict(self):
device = self.make_device(ram_gb=4)
stale = device.updated_at.isoformat()
device.ram_gb = 8
device.save()
self.client.force_login(self.editor)
response = self.client.post(reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": 16, "expected_updated_at": stale}), content_type="application/json")
self.assertEqual(response.status_code, 409)
def test_search_matches_only_device_number_prefix(self):
self.make_device(number=1234, model="Lenovo ThinkPad P50", ram_gb=16, status=self.done, device_type=self.device_type)
self.make_device(number=1299, model="Dell OptiPlex", ram_gb=4)
self.make_device(number=9800, model="Another ThinkPad", ram_gb=16)
self.client.force_login(self.viewer)
response = self.client.get(reverse("inventory:device-list"), {"q": "12", "ram_min": "8", "sort": "ram_gb", "dir": "asc"})
self.assertContains(response, "ThinkPad P50")
self.assertNotContains(response, "Dell OptiPlex")
response = self.client.get(reverse("inventory:device-list"), {"q": "ThinkPad"})
self.assertNotContains(response, "ThinkPad P50")
self.assertNotContains(response, "Another ThinkPad")
exact = self.client.get(reverse("inventory:device-list"), {"q": "12", "exact": "1"})
self.assertNotContains(exact, "ThinkPad P50")
exact = self.client.get(reverse("inventory:device-list"), {"q": "1234", "exact": "1"})
self.assertContains(exact, "ThinkPad P50")
self.assertNotContains(exact, "Dell OptiPlex")
def test_cpu_autocomplete_uses_masterlist_and_ranks_likely_match_first(self):
self.client.force_login(self.viewer)
intel = self.client.get(reverse("inventory:autocomplete", args=["cpu"]), {"q": "i5 8500"})
amd = self.client.get(reverse("inventory:autocomplete", args=["cpu"]), {"q": "ryzen 5 5600x"})
self.assertEqual(intel.status_code, 200)
self.assertEqual(intel.json()["results"][0]["label"], "Intel Core i5-8500")
self.assertEqual(amd.json()["results"][0]["label"], "AMD Ryzen 5 5600X")
self.assertGreaterEqual(len(intel.json()["results"]), 2)
def test_cpu_vendor_family_and_free_text_filters_can_be_combined(self):
self.make_device(number=7101, cpu="Intel Core i5-8500", model="INTEL-I5-TREFFER")
self.make_device(number=7102, cpu="Intel Core i7-8700", model="INTEL-I7")
self.make_device(number=7103, cpu="AMD Ryzen 5 5600X", model="AMD-R5-TREFFER")
self.make_device(number=7104, cpu="AMD Ryzen 9 5950X", model="AMD-R9")
self.client.force_login(self.viewer)
amd = self.client.get(reverse("inventory:device-list"), {"cpu_vendor": "amd", "cpu_family": "amd_ryzen_5"})
self.assertContains(amd, "AMD-R5-TREFFER")
self.assertNotContains(amd, "AMD-R9")
self.assertNotContains(amd, "INTEL-I5-TREFFER")
intel = self.client.get(reverse("inventory:device-list"), {"cpu_vendor": "intel", "cpu_family": ["intel_i5", "intel_i7"], "cpu_contains": "i5 8500"})
self.assertContains(intel, "INTEL-I5-TREFFER")
self.assertNotContains(intel, "INTEL-I7")
self.assertContains(intel, "data-cpu-autocomplete")
def test_overview_aggregates_recent_devices_and_is_available_to_viewers(self):
today = timezone.localdate()
month_index = today.year * 12 + today.month - 1 - 3
previous_period_date = date(month_index // 12, month_index % 12 + 1, 1)
desktop = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Desktop")
self.make_device(date=today, device_type=self.device_type)
self.make_device(date=today, device_type=self.device_type, status=self.done)
self.make_device(date=today, device_type=desktop)
self.make_device(date=previous_period_date, device_type=desktop)
self.client.force_login(self.viewer)
response = self.client.get(reverse("inventory:overview"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["period_months"], 3)
self.assertEqual(response.context["period_count"], 3)
self.assertEqual(response.context["period_laptops"], 2)
self.assertEqual(response.context["period_finished"], 1)
self.assertEqual(len(response.context["month_series"]), 12)
self.assertContains(response, "Geräteeingänge")
self.assertContains(response, "Datenvollständigkeit")
def test_device_form_uses_german_labels(self):
form = DeviceForm()
self.assertEqual(form.fields["device_type"].label, "Typ")
self.assertEqual(form.fields["employee"].label, "Bearbeiter")
self.assertEqual(form.fields["comment"].label, "Kommentar")
def test_archive_and_restore(self):
device = self.make_device()
self.client.force_login(self.editor)
self.client.post(reverse("inventory:archive-device", args=[device.pk]))
device.refresh_from_db()
self.assertIsNotNone(device.archived_at)
self.client.force_login(self.admin)
self.client.post(reverse("inventory:restore-device", args=[device.pk]))
device.refresh_from_db()
self.assertIsNone(device.archived_at)
self.assertEqual(list(device.audit_entries.values_list("action", flat=True))[:2], [AuditLog.Action.RESTORE, AuditLog.Action.ARCHIVE])
def test_custom_field_saved_without_migration(self):
definition = FieldDefinition.objects.create(name="Windows-Version", key="windows-version", field_type=FieldDefinition.FieldType.TEXT, required=True)
device = self.make_device()
self.client.force_login(self.editor)
form_data = {
"shelf": "", "status": self.status.pk, "date": "2026-09-19", "recipient": "", "location": "",
"ticket": "", "device_type": "", "price": "", "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN",
"display_inches": "", "ram_gb": "", "ssd_gb": "", "hdd_gb": "", "cpu": "", "model": "",
"employee": "", "donor": "", "comment": "", "custom_windows-version": "Windows 11",
"expected_updated_at": device.updated_at.isoformat(),
}
response = self.client.post(reverse("inventory:device-detail", args=[device.pk]), form_data)
self.assertEqual(response.status_code, 302)
device.refresh_from_db()
self.assertEqual(device.custom_values[definition.key], "Windows 11")
def test_excel_import_transaction_and_number_preservation(self):
workbook = Workbook()
sheet = workbook.active
sheet.append(["Nr.", "Status", "Modell", "Bearbeiter"])
sheet.append([4200, "Eingang", "Lenovo ThinkPad", "RG"])
buffer = BytesIO()
workbook.save(buffer)
buffer.seek(0)
result = import_workbook(buffer, {0: "number", 1: "status", 2: "model", 3: "employee"}, self.admin)
self.assertEqual(result["created"], 1)
imported = Device.objects.get(number=4200)
self.assertEqual(imported.employee.code, "RG")
self.assertEqual(self.make_device().number, 4201)
def test_excel_import_rolls_back_on_invalid_row(self):
workbook = Workbook()
sheet = workbook.active
sheet.append(["Nr.", "Status", "Bearbeiter"])
sheet.append([5001, "Eingang", "RG"])
sheet.append([5002, "Eingang", "UNBEKANNT"])
buffer = BytesIO(); workbook.save(buffer); buffer.seek(0)
with self.assertRaises(ValueError):
import_workbook(buffer, {0: "number", 1: "status", 2: "employee"}, self.admin)
self.assertFalse(Device.objects.filter(number__in=[5001, 5002]).exists())
def test_excel_upload_mapping_workflow(self):
workbook = Workbook(); sheet = workbook.active
sheet.append(["Nr.", "Status", "Modell"]); sheet.append([7001, "Eingang", "Importgerät"])
buffer = BytesIO(); workbook.save(buffer)
upload = SimpleUploadedFile("geraete.xlsx", buffer.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.client.force_login(self.admin)
with tempfile.TemporaryDirectory() as media_root, self.settings(MEDIA_ROOT=media_root):
response = self.client.post(reverse("inventory:import-excel"), {"file": upload})
self.assertEqual(response.status_code, 302)
mapping_url = response["Location"]
self.assertEqual(self.client.get(mapping_url).status_code, 200)
response = self.client.post(mapping_url, {"column_0": "number", "column_1": "status", "column_2": "model"})
self.assertEqual(response.status_code, 302)
self.assertTrue(Device.objects.filter(number=7001, model="Importgerät").exists())
def test_csv_xlsx_and_ods_export(self):
self.make_device(model="Exportmodell")
self.client.force_login(self.editor)
csv_response = self.client.get(reverse("inventory:export-devices", args=["csv"]))
self.assertEqual(csv_response.status_code, 200)
self.assertIn("Exportmodell", csv_response.content.decode("utf-8-sig"))
xlsx = self.client.get(reverse("inventory:export-devices", args=["xlsx"]))
workbook = load_workbook(BytesIO(xlsx.content), read_only=True)
self.assertEqual(workbook.active["A1"].value, "Nr.")
ods = self.client.get(reverse("inventory:export-devices", args=["ods"]))
self.assertEqual(ods.status_code, 200)
self.assertEqual(ods["Content-Type"], "application/vnd.oasis.opendocument.spreadsheet")
with zipfile.ZipFile(BytesIO(ods.content)) as archive:
self.assertEqual(archive.namelist()[0], "mimetype")
self.assertEqual(archive.read("mimetype"), b"application/vnd.oasis.opendocument.spreadsheet")
content = archive.read("content.xml").decode("utf-8")
self.assertIn("Exportmodell", content)
self.assertIn('table:name="Geräte"', content)
def test_admin_can_change_status_color_and_list_uses_it(self):
self.make_device(status=self.status)
self.client.force_login(self.admin)
response = self.client.post(reverse("inventory:choice-edit", args=[self.status.pk]), {
"kind": ChoiceValue.Kind.STATUS,
"label": self.status.label,
"color": "#be185d",
"numeric_value": "",
"active": "on",
"sort_order": self.status.sort_order,
})
self.assertEqual(response.status_code, 302)
self.status.refresh_from_db()
self.assertEqual(self.status.color, "#be185d")
device_list = self.client.get(reverse("inventory:device-list"))
self.assertContains(device_list, "device-list-page")
self.assertContains(device_list, "--status-color:#be185d")
self.assertContains(device_list, "Als ODS für LibreOffice")
def test_column_order_is_saved_per_user_and_rendered_for_the_list(self):
self.client.force_login(self.editor)
order = ["status", "number", "model", "cpu"]
response = self.client.post(
reverse("inventory:save-preferences"),
data=json.dumps({"column_order": order}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(UserPreference.objects.get(user=self.editor).column_order, order)
device_list = self.client.get(reverse("inventory:device-list"))
self.assertContains(device_list, 'id="server-column-order"')
self.assertContains(device_list, "Bearbeiter")
for column in [
"number", "shelf", "status", "date", "recipient", "location", "ticket", "device_type",
"price", "battery", "wifi", "lan", "display_inches", "ram_gb", "ssd_gb", "hdd_gb",
"cpu", "model", "employee", "donor", "comment",
]:
self.assertContains(device_list, f'data-column="{column}"')
def test_filtered_export_contains_only_matching_date_status_and_type(self):
matching = self.make_device(number=8101, date=date(2026, 9, 19), status=self.done, device_type=self.device_type, model="TREFFER")
self.make_device(number=8102, date=date(2025, 1, 1), status=self.done, device_type=self.device_type, model="ZU_ALT")
self.make_device(number=8103, date=date(2026, 9, 19), status=self.status, device_type=self.device_type, model="FALSCHER_STATUS")
self.client.force_login(self.editor)
response = self.client.get(reverse("inventory:export-devices", args=["csv"]), {
"scope": "filtered", "date_from": "01.09.2026", "date_to": "30.09.2026",
"status": self.done.pk, "type": self.device_type.pk,
})
content = response.content.decode("utf-8-sig")
self.assertEqual(response.status_code, 200)
self.assertIn(str(matching.number), content)
self.assertIn("TREFFER", content)
self.assertNotIn("ZU_ALT", content)
self.assertNotIn("FALSCHER_STATUS", content)
def test_seed_command_is_idempotent(self):
counts = (ChoiceValue.objects.count(), Employee.objects.count())
call_command("seed_initial_data", verbosity=0)
self.assertEqual(counts, (ChoiceValue.objects.count(), Employee.objects.count()))
def test_screen_and_printer_device_types_are_seeded(self):
labels = set(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE).values_list("label", flat=True))
self.assertTrue({"Bildschirm", "Drucker"}.issubset(labels))
def test_admin_can_create_user_with_role(self):
self.client.force_login(self.admin)
response = self.client.post(reverse("inventory:user-new"), {
"username": "new-editor", "first_name": "Neue", "last_name": "Person", "email": "",
"is_active": "on", "role": UserProfile.Role.EDITOR, "password": "A-strong-unique-test-password-2026!",
})
self.assertEqual(response.status_code, 302)
created = User.objects.get(username="new-editor")
self.assertEqual(created.profile.role, UserProfile.Role.EDITOR)
self.assertTrue(created.check_password("A-strong-unique-test-password-2026!"))
def test_admin_can_add_edit_and_remove_employee(self):
self.client.force_login(self.admin)
response = self.client.post(reverse("inventory:employee-new"), {"code": "NP", "name": "Neue Person", "active": "on"})
self.assertEqual(response.status_code, 302)
employee = Employee.objects.get(code="NP")
response = self.client.post(reverse("inventory:employee-edit", args=[employee.pk]), {"code": "NP", "name": "Neuer Name", "active": "on"})
self.assertEqual(response.status_code, 302)
employee.refresh_from_db()
self.assertEqual(employee.name, "Neuer Name")
self.make_device(employee=employee)
response = self.client.post(reverse("inventory:employee-remove", args=[employee.pk]))
self.assertEqual(response.status_code, 302)
employee.refresh_from_db()
self.assertFalse(employee.active)
def api_header(self, key):
return {"HTTP_AUTHORIZATION": f"Api-Key {key}"}
def test_api_rejects_anonymous_and_browser_session_access(self):
device = self.make_device(number=9101)
anonymous = self.client.get(reverse("inventory-api:device-detail", args=[device.number]))
self.assertEqual(anonymous.status_code, 401)
self.client.force_login(self.admin)
browser_session_only = self.client.get(reverse("inventory-api:device-detail", args=[device.number]))
self.assertEqual(browser_session_only.status_code, 401)
def test_read_only_api_key_can_read_but_not_write(self):
device = self.make_device(number=9102, ram_gb=8)
_, key = InventoryAPIKey.objects.create_key(
name="report-generator", service_user=self.viewer, can_read=True, can_write=False,
expiry_date=timezone.now() + timedelta(days=30),
)
detail_url = reverse("inventory-api:device-detail", args=[device.number])
response = self.client.get(detail_url, **self.api_header(key))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["number"], device.number)
response = self.client.patch(
detail_url,
data=json.dumps({"ram_gb": 16, "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
**self.api_header(key),
)
self.assertEqual(response.status_code, 403)
device.refresh_from_db()
self.assertEqual(device.ram_gb, 8)
def test_write_api_key_updates_device_and_records_client(self):
device = self.make_device(number=9103, ram_gb=8, hwid="old")
_, key = InventoryAPIKey.objects.create_key(
name="hardware-test-team2", service_user=self.editor, can_read=True, can_write=True,
expiry_date=timezone.now() + timedelta(days=30),
)
response = self.client.patch(
reverse("inventory-api:device-detail", args=[device.number]),
data=json.dumps({
"ram_gb": 32, "hwid": "abc-123",
"expected_updated_at": device.updated_at.isoformat(),
}),
content_type="application/json",
**self.api_header(key),
)
self.assertEqual(response.status_code, 200)
device.refresh_from_db()
self.assertEqual(device.ram_gb, 32)
self.assertEqual(device.hwid, "abc-123")
logs = AuditLog.objects.filter(device=device, api_client="hardware-test-team2")
self.assertEqual(logs.count(), 2)
self.assertTrue(all(log.user == self.editor for log in logs))
def test_api_rejects_expired_key_password_fields_and_stale_updates(self):
device = self.make_device(number=9104)
_, expired_key = InventoryAPIKey.objects.create_key(
name="expired", service_user=self.editor, can_read=True,
expiry_date=timezone.now() - timedelta(seconds=1),
)
detail_url = reverse("inventory-api:device-detail", args=[device.number])
self.assertEqual(self.client.get(detail_url, **self.api_header(expired_key)).status_code, 401)
_, key = InventoryAPIKey.objects.create_key(
name="secure-writer", service_user=self.editor, can_read=True, can_write=True,
expiry_date=timezone.now() + timedelta(days=30),
)
rejected = self.client.patch(
detail_url,
data=json.dumps({"password": "secret", "expected_updated_at": device.updated_at.isoformat()}),
content_type="application/json",
**self.api_header(key),
)
self.assertEqual(rejected.status_code, 400)
self.assertIn("unbekannte_felder", rejected.json())
stale_timestamp = device.updated_at.isoformat()
device.comment = "andere Änderung"
device.save()
conflict = self.client.patch(
detail_url,
data=json.dumps({"ram_gb": 16, "expected_updated_at": stale_timestamp}),
content_type="application/json",
**self.api_header(key),
)
self.assertEqual(conflict.status_code, 409)
def test_api_bulk_update_is_atomic(self):
first = self.make_device(number=9110)
second = self.make_device(number=9111)
recipient = ChoiceValue.objects.get(kind=ChoiceValue.Kind.RECIPIENT, label="NGO")
_, key = InventoryAPIKey.objects.create_key(
name="bulk-team2", service_user=self.editor, can_read=True, can_write=True,
expiry_date=timezone.now() + timedelta(days=30),
)
response = self.client.post(
reverse("inventory-api:device-bulk-update"),
data=json.dumps({
"device_numbers": [first.number, second.number],
"changes": {"status": self.done.pk, "recipient": recipient.pk},
}),
content_type="application/json",
**self.api_header(key),
)
self.assertEqual(response.status_code, 200)
first.refresh_from_db(); second.refresh_from_db()
self.assertEqual(first.status, self.done)
self.assertEqual(second.recipient, recipient)
failed = self.client.post(
reverse("inventory-api:device-bulk-update"),
data=json.dumps({"device_numbers": [first.number, 999999], "changes": {"ram_gb": 64}}),
content_type="application/json",
**self.api_header(key),
)
self.assertEqual(failed.status_code, 400)
first.refresh_from_db()
self.assertNotEqual(first.ram_gb, 64)