Initialize clean CSR inventory repository
This commit is contained in:
662
inventory/tests.py
Normal file
662
inventory/tests.py
Normal 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)
|
||||
Reference in New Issue
Block a user