diff --git a/inventory/views.py b/inventory/views.py
index ddfcbf1..a061054 100644
--- a/inventory/views.py
+++ b/inventory/views.py
@@ -793,7 +793,10 @@ def import_map(request: HttpRequest, pk: int) -> HttpResponse:
("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"), ("recipient", "Empfänger"),
("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"),
("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"),
- ("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]
+ ("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"),
+ ("resolution", "Auflösung"), ("print_technology", "Druckverfahren"),
+ ("duplex", "Duplex"), ("color_printing", "Farbdruck"),
+ ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]
if request.method == "POST":
mapping = {index: request.POST.get(f"column_{index}", "") for index in range(len(job.headers))}
try:
diff --git a/scripts/test_browser.py b/scripts/test_browser.py
new file mode 100644
index 0000000..b205cc1
--- /dev/null
+++ b/scripts/test_browser.py
@@ -0,0 +1,355 @@
+"""Exercise the main UI flows with Playwright and a disposable local database.
+
+Install the optional tooling with ``pip install playwright`` and
+``python -m playwright install chromium --only-shell``, then run this script.
+"""
+
+import os
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+PASSWORD = "Browser-Test-2026!"
+
+
+def available_port():
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def start_server(base_url, port, log_file):
+ server = subprocess.Popen(
+ [sys.executable, str(ROOT / "manage.py"), "runserver", f"127.0.0.1:{port}", "--noreload"],
+ cwd=ROOT,
+ env=os.environ.copy(),
+ stdout=log_file,
+ stderr=subprocess.STDOUT,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ )
+ for _ in range(100):
+ if server.poll() is not None:
+ raise RuntimeError("Browser test server exited before startup")
+ try:
+ with urllib.request.urlopen(f"{base_url}/health/", timeout=1) as response:
+ if response.status == 200:
+ return server
+ except (urllib.error.URLError, TimeoutError):
+ time.sleep(0.1)
+ server.terminate()
+ raise RuntimeError("Browser test server did not become healthy")
+
+
+def login(page, base_url, username):
+ page.goto(f"{base_url}/accounts/login/")
+ page.locator('[name="username"]').fill(username)
+ page.locator('[name="password"]').fill(PASSWORD)
+ page.locator("form button").click()
+ assert page.url.rstrip("/") == base_url, f"Login failed for {username}: {page.url}"
+
+
+def run():
+ try:
+ from playwright.sync_api import sync_playwright, expect
+ except ImportError as exc:
+ raise SystemExit("Install the optional Playwright package to run browser tests") from exc
+
+ with tempfile.TemporaryDirectory(prefix="csr-browser-") as temporary:
+ os.environ.update({
+ "DJANGO_SETTINGS_MODULE": "csr_inventory.browser_test_settings",
+ "CSR_BROWSER_TEST_DB": str(Path(temporary) / "browser.sqlite3"),
+ "CSR_BROWSER_TEST_MEDIA": str(Path(temporary) / "media"),
+ "DEBUG": "true",
+ })
+ import django
+ from django.core.management import call_command
+ from django.db import connections
+
+ django.setup()
+ from django.contrib.auth.models import User
+ from openpyxl import Workbook
+ from inventory.models import ChoiceValue, Device, Employee, UserProfile
+
+ call_command("migrate", interactive=False, verbosity=0)
+ call_command("seed_initial_data", verbosity=0)
+ User.objects.create_superuser("browser_admin", "browser@example.invalid", PASSWORD)
+ editor = User.objects.create_user("browser_editor", password=PASSWORD)
+ editor.profile.role = UserProfile.Role.EDITOR
+ editor.profile.save()
+ User.objects.create_user("browser_viewer", password=PASSWORD)
+ status = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Eingang")
+ laptop = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Laptop")
+ printer = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Drucker")
+ monitor = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Bildschirm")
+ employee = Employee.objects.get(code="JH")
+ for device_type, model in ((laptop, "Fixture Laptop"), (printer, "Fixture Printer"), (monitor, "Fixture Monitor")):
+ Device.objects.create(status=status, device_type=device_type, model=model, employee=employee)
+
+ port = available_port()
+ base_url = f"http://127.0.0.1:{port}"
+ with (Path(temporary) / "server.log").open("w", encoding="utf-8") as log_file:
+ server = start_server(base_url, port, log_file)
+ try:
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True)
+ context = browser.new_context(viewport={"width": 1440, "height": 900}, accept_downloads=True)
+ page = context.new_page()
+ page.set_default_timeout(8000)
+ page_errors = []
+ page.on("pageerror", lambda error: page_errors.append(str(error)))
+
+ page.goto(f"{base_url}/")
+ expect(page).to_have_url(f"{base_url}/accounts/login/?next=/")
+ login(page, base_url, "browser_admin")
+ print("PASS login and protected routes", flush=True)
+
+ for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/devices/new/",
+ "/devices/1/", "/configuration/", "/users/", "/import/", "/archive/", "/history/"):
+ response = page.goto(f"{base_url}{route}")
+ assert response.status == 200, f"{route}: HTTP {response.status}"
+ assert page.locator("h1").count() > 0, route
+ print("PASS all main pages", flush=True)
+
+ page.goto(f"{base_url}/devices/new/")
+ page.locator('[name="device_type"]').select_option(label="Laptop")
+ assert page.locator('[data-hardware-field="ram_gb"]').is_visible()
+ assert not page.locator('[data-hardware-field="print_technology"]').is_visible()
+ page.locator('[name="device_type"]').select_option(label="Drucker")
+ assert page.locator('[data-hardware-field="print_technology"]').is_visible()
+ assert not page.locator('[data-hardware-field="ram_gb"]').is_visible()
+ page.locator('[name="device_type"]').select_option(label="Laptop")
+ page.locator('[name="quantity"]').fill("2")
+ page.locator('[name="model"]').fill("Playwright Laptop")
+ page.locator('[name="ram_gb"]').fill("16")
+ page.locator('[name="ssd_gb"]').fill("512")
+ page.locator('[name="employee"]').select_option(index=1)
+ page.locator('form[data-device-type-form] button').click()
+ expect(page.locator(".toast.success")).to_contain_text("2 Geräte")
+ page.goto(f"{base_url}/")
+ assert page.locator('tr[data-device-id] td[data-column="model"]').all_inner_texts().count("Playwright Laptop") == 2
+ print("PASS dynamic type fields and batch creation", flush=True)
+
+ page.locator('[data-search-input]').fill("1")
+ expect(page.locator('tr[data-device-id]')).to_have_count(1)
+ assert page.locator('tr[data-device-id] td[data-column="number"]').inner_text() == "1"
+ page.locator('[data-search-input]').fill("")
+ expect(page.locator('tr[data-device-id]')).to_have_count(5)
+ page.locator('[data-filter-toggle]').click()
+ page.locator('[name="model_contains"]').fill("Playwright Laptop")
+ page.get_by_role("button", name="Filter anwenden").click()
+ expect(page.locator('tr[data-device-id]')).to_have_count(2)
+ assert page.locator('[data-filter-panel]').is_hidden()
+ page.locator('[data-columns-toggle]').click()
+ shelf_column = page.locator('[data-columns-panel] label[data-column="shelf"] input')
+ shelf_column.uncheck()
+ assert not page.locator('th[data-column="shelf"]').is_visible()
+ shelf_column.check()
+ assert page.locator('th[data-column="shelf"]').is_visible()
+ page.locator('[data-columns-toggle]').click()
+ print("PASS live search, filters, and column picker", flush=True)
+
+ first_row = page.locator('tr[data-device-id]').first
+ first_row.locator('td[data-field="model"]').dblclick()
+ editor_input = first_row.locator('td[data-field="model"] input')
+ editor_input.fill("Playwright Laptop Edited")
+ editor_input.press("Enter")
+ expect(first_row.locator('td[data-field="model"]')).to_have_text("Playwright Laptop Edited")
+ page.locator('[data-select-all]').check()
+ expect(page.locator('[data-bulk-count]')).to_have_text("2")
+ page.locator('[data-bulk-form] select[name="status"]').select_option(label="Fertig")
+ page.locator('[data-bulk-submit]').click()
+ page.locator('.confirm-box button').last.click()
+ expect(page.locator('tr[data-device-id]')).to_have_count(2)
+ assert all("Fertig" in text for text in page.locator('tr[data-device-id] td[data-column="status"]').all_inner_texts())
+ page.locator('.export-menu summary').click()
+ with page.expect_download() as download_event:
+ page.get_by_role('link', name='Als CSV exportieren').click()
+ assert download_event.value.suggested_filename.endswith('.csv')
+ print("PASS inline edit, bulk update, and filtered CSV export", flush=True)
+
+ page.goto(f"{base_url}/devices/4/")
+ page.locator('.detail-form [name="device_type"]').select_option(label="Drucker")
+ assert page.locator('[data-hardware-field="print_technology"]').is_visible()
+ assert page.locator('[data-hardware-field="ram_gb"]').is_hidden()
+ page.locator('.detail-form [name="print_technology"]').fill("Laser")
+ page.locator('.detail-form button').click()
+ expect(page.locator('.detail-form [name="print_technology"]')).to_have_value("Laser")
+ print("PASS detail editing and device-type field changes", flush=True)
+
+ page.goto(f"{base_url}/devices/1/")
+ task_form = page.locator('form[action="/devices/1/tasks/"]')
+ task_form.locator('[name="title"]').fill("Akku prüfen")
+ task_form.locator('[name="assigned_to"]').select_option(label="browser_editor")
+ task_form.locator('button').click()
+ expect(page.get_by_text("Akku prüfen")).to_be_visible()
+ stage_form = page.locator('form[action="/devices/1/stage/"]')
+ stage_form.locator('[name="stage"]').select_option("INSPECTION")
+ stage_form.locator('button').click()
+ expect(page.locator('h2').first).to_contain_text("Inspektion")
+ page.goto(f"{base_url}/ablauf/")
+ card = page.locator('.workflow-card').filter(has=page.locator('a[href="/devices/1/"]'))
+ inspection = card.locator('form[action="/devices/1/inspection/"]')
+ inspection.locator('[name="defects"]').fill("Akku schwach")
+ inspection.locator('[name="ram_gb"]').fill("8")
+ inspection.locator('button').click()
+ expect(card).to_contain_text("Akku schwach")
+ stage_form = card.locator('form[action="/devices/1/stage/"]')
+ stage_form.locator('[name="stage"]').select_option("REPAIR")
+ stage_form.locator('button').click()
+ repair = card.locator('form[action="/devices/1/repairs/"]')
+ repair.locator('[name="work_done"]').fill("Akku ersetzt")
+ repair.locator('button').click()
+ expect(card).to_contain_text("Akku ersetzt")
+ stage_form = card.locator('form[action="/devices/1/stage/"]')
+ stage_form.locator('[name="stage"]').select_option("STORAGE")
+ stage_form.locator('button').click()
+ assert card.locator('form[action="/devices/1/repairs/"]').is_visible()
+ page.goto(f"{base_url}/aufgaben/")
+ page.locator('form[action^="/devices/1/tasks/"][action$="/complete/"] button').click()
+ page.goto(f"{base_url}/ablauf/")
+ stage_form = card.locator('form[action="/devices/1/stage/"]')
+ stage_form.locator('[name="stage"]').select_option("STORAGE")
+ stage_form.locator('button').click()
+ storage = card.locator('form[action="/devices/1/recipient/"]')
+ expect(storage).to_be_visible()
+ stage_form = card.locator('form[action="/devices/1/stage/"]')
+ stage_form.locator('[name="stage"]').select_option("ISSUED")
+ stage_form.locator('button').click()
+ expect(storage).to_be_visible()
+ storage.locator('[name="recipient"]').fill("Testverein")
+ storage.locator('button').click()
+ stage_form = card.locator('form[action="/devices/1/stage/"]')
+ stage_form.locator('[name="stage"]').select_option("ISSUED")
+ stage_form.locator('button').click()
+ expect(page.locator('#stage-ISSUED').locator('../..')).to_contain_text("Fixture Laptop")
+ print("PASS task gating, inspection, repair, storage, and issuance", flush=True)
+
+ page.goto(f"{base_url}/devices/3/")
+ page.get_by_role("button", name="Gerät archivieren").click()
+ page.locator('.confirm-box button').last.click()
+ assert page.url.rstrip("/") == base_url
+ page.goto(f"{base_url}/archive/")
+ expect(page.locator("tbody")).to_contain_text("Fixture Monitor")
+ page.locator('form[action="/archive/3/restore/"] button').click()
+ expect(page.locator("tbody")).not_to_contain_text("Fixture Monitor")
+ page.goto(f"{base_url}/devices/3/")
+ expect(page.locator('h1')).to_contain_text("#3")
+ print("PASS archive and restore", flush=True)
+
+ page.goto(f"{base_url}/")
+ page.locator('.sidebar-theme').click()
+ assert page.locator('html').get_attribute('data-theme') == 'dark'
+ page.reload()
+ assert page.locator('html').get_attribute('data-theme') == 'dark'
+ page.locator('.sidebar-theme').click()
+ assert page.locator('html').get_attribute('data-theme') == 'light'
+ mobile = browser.new_context(viewport={"width": 390, "height": 844})
+ mobile_page = mobile.new_page()
+ login(mobile_page, base_url, "browser_admin")
+ expect(mobile_page.locator('.mobile-header')).to_be_visible()
+ mobile_page.locator('[data-sidebar-toggle]').click()
+ expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open')
+ mobile.close()
+ print("PASS dark mode persistence and mobile navigation", flush=True)
+
+ page.goto(f"{base_url}/users/new/")
+ page.locator('[name="username"]').fill('playwright_user')
+ page.locator('[name="password"]').fill('SecureBrowserTest-2026!')
+ page.locator('[name="role"]').select_option('EDITOR')
+ page.locator('form.app-form button').click()
+ expect(page.locator('tbody')).to_contain_text('playwright_user')
+ page.goto(f"{base_url}/configuration/employee/new/")
+ page.locator('[name="code"]').fill('PW')
+ page.locator('[name="name"]').fill('Playwright Tester')
+ page.locator('form.app-form button').click()
+ expect(page.locator('.employee-list')).to_contain_text('Playwright Tester')
+ print("PASS user and employee administration", flush=True)
+
+ page.goto(f"{base_url}/import/")
+ for label, suffix in (("XLSX herunterladen", ".xlsx"), ("ODS für LibreOffice", ".ods")):
+ with page.expect_download() as download_event:
+ page.get_by_role('link', name=label).click()
+ assert download_event.value.suggested_filename.endswith(suffix)
+ workbook = Workbook()
+ workbook.active.append(["Modell", "Typ", "Status", "Druckverfahren", "Anschlüsse", "Duplex", "Farbdruck"])
+ workbook.active.append(["Imported Browser Device", "Drucker", "Eingang", "Laser", "USB-C", "JA", "NEIN"])
+ import_path = Path(temporary) / "import.xlsx"
+ workbook.save(import_path)
+ page.locator('input[type="file"]').set_input_files(import_path)
+ page.get_by_role('button', name='Datei analysieren').click()
+ expect(page.locator('h1')).to_contain_text('Spalten zuordnen')
+ assert [page.locator(f'[name="column_{index}"]').input_value() for index in range(7)] == [
+ 'model', 'device_type', 'status', 'print_technology', 'ports', 'duplex', 'color_printing',
+ ]
+ page.get_by_role('button', name='Import bestätigen').click()
+ page.goto(f"{base_url}/")
+ expect(page.locator('td[data-column="model"]').filter(has_text='Imported Browser Device')).to_have_count(1)
+ imported_row = page.locator('tr[data-device-id]').filter(has_text='Imported Browser Device')
+ imported_id = imported_row.get_attribute('data-device-id')
+ page.goto(f"{base_url}/devices/{imported_id}/")
+ expect(page.locator('.detail-form [name="print_technology"]')).to_have_value('Laser')
+ expect(page.locator('.detail-form [name="ports"]')).to_have_value('USB-C')
+ print("PASS XLSX and ODS exports, XLSX mapping and import", flush=True)
+
+ page.goto(f"{base_url}/configuration/field/new/")
+ page.locator('[name="name"]').fill('Prüfcode')
+ page.locator('[name="key"]').fill('pruefcode')
+ page.locator('[name="field_type"]').select_option('text')
+ page.locator('form.app-form button').click()
+ expect(page.locator('.config-list').last).to_contain_text('Prüfcode')
+ page.goto(f"{base_url}/devices/2/")
+ page.locator('[name="custom_pruefcode"]').fill('PW-123')
+ page.locator('.detail-form button').click()
+ expect(page.locator('[name="custom_pruefcode"]')).to_have_value('PW-123')
+ print("PASS custom field creation and device entry", flush=True)
+
+ page.goto(f"{base_url}/history/?q=Playwright%20Laptop%20Edited")
+ changed_model = page.locator('.history-list article').filter(has_text='Playwright Laptop Edited').first
+ changed_model.locator('form[action$="/revert/"] button').click()
+ page.locator('.confirm-box button').last.click()
+ expect(page.locator('.history-list')).to_contain_text('Zurückgesetzt')
+ page.goto(f"{base_url}/devices/4/")
+ expect(page.locator('.detail-form [name="model"]')).to_have_value('Playwright Laptop')
+ print("PASS audit change rollback", flush=True)
+
+ editor_page = context.new_page()
+ login(editor_page, base_url, "browser_editor")
+ assert editor_page.goto(f"{base_url}/users/").status == 403
+ assert editor_page.goto(f"{base_url}/configuration/").status == 403
+ assert editor_page.goto(f"{base_url}/devices/new/").status == 200
+ viewer_page = context.new_page()
+ login(viewer_page, base_url, "browser_viewer")
+ assert viewer_page.goto(f"{base_url}/devices/new/").status == 403
+ assert viewer_page.goto(f"{base_url}/archive/").status == 403
+ assert viewer_page.goto(f"{base_url}/").status == 200
+ assert viewer_page.locator('[data-device-select]').count() == 0
+ print("PASS editor and viewer permissions", flush=True)
+
+ assert not page_errors, f"Browser JavaScript errors: {page_errors}"
+ context.close()
+ browser.close()
+ finally:
+ if os.name == "nt":
+ # The Windows venv launcher leaves its child Python process alive otherwise.
+ subprocess.run(["taskkill", "/PID", str(server.pid), "/T", "/F"], capture_output=True)
+ else:
+ server.terminate()
+ try:
+ server.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ server.kill()
+ server.wait(timeout=5)
+ connections.close_all()
+ time.sleep(1) # Give Windows time to release SQLite handles after process exit.
+
+
+if __name__ == "__main__":
+ run()
diff --git a/scripts/test_server_browser.py b/scripts/test_server_browser.py
new file mode 100644
index 0000000..2ccc567
--- /dev/null
+++ b/scripts/test_server_browser.py
@@ -0,0 +1,155 @@
+"""Run representative write flows against a disposable deployed test server.
+
+Requires CSR_TEST_BASE_URL, CSR_TEST_USER, and CSR_TEST_PASSWORD. Prints the
+unique record prefix so created test data can be removed after inspection.
+"""
+
+import os
+import time
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from openpyxl import Workbook
+from playwright.sync_api import expect, sync_playwright
+
+
+def run():
+ base = os.environ["CSR_TEST_BASE_URL"].rstrip("/")
+ username = os.environ["CSR_TEST_USER"]
+ password = os.environ["CSR_TEST_PASSWORD"]
+ marker = f"PW{int(time.time())}"
+ print(f"TEST_MARKER={marker}", flush=True)
+ errors = []
+ with sync_playwright() as playwright, TemporaryDirectory(prefix="csr-server-browser-") as temporary:
+ browser = playwright.chromium.launch(headless=True)
+ context = browser.new_context(ignore_https_errors=True, accept_downloads=True)
+ page = context.new_page()
+ page.set_default_timeout(10000)
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ page.goto(f"{base}/accounts/login/")
+ page.locator('[name="username"]').fill(username)
+ page.locator('[name="password"]').fill(password)
+ page.locator('form button').click()
+ assert page.url.rstrip("/") == base, f"Login failed: {page.url}"
+ for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/configuration/", "/users/", "/archive/", "/history/", "/import/"):
+ response = page.goto(f"{base}{route}")
+ assert response.status == 200, f"{route}: {response.status}"
+ print("PASS login and main routes", flush=True)
+
+ page.goto(f"{base}/devices/new/")
+ page.locator('[name="device_type"]').select_option(label="Laptop")
+ assert page.locator('[data-hardware-field="ram_gb"]').is_visible()
+ page.locator('[name="model"]').fill(f"{marker}-Laptop")
+ page.locator('[name="employee"]').select_option(index=1)
+ page.locator('[name="ram_gb"]').fill("16")
+ page.locator('[name="ssd_gb"]').fill("512")
+ page.locator('form[data-device-type-form] button').click()
+ page.goto(f"{base}/?model_contains={marker}")
+ expect(page.locator('tr[data-device-id]')).to_have_count(1)
+ row = page.locator('tr[data-device-id]').first
+ device_id = row.get_attribute('data-device-id')
+ assert page.locator('[data-filter-panel]').is_hidden()
+ row.locator('td[data-field="model"]').dblclick()
+ field = row.locator('td[data-field="model"] input')
+ field.fill(f"{marker}-Laptop-Edited")
+ field.press('Enter')
+ expect(row.locator('td[data-field="model"]')).to_have_text(f"{marker}-Laptop-Edited")
+ print(f"PASS create, filter, inline edit: device {device_id}", flush=True)
+
+ page.goto(f"{base}/devices/{device_id}/")
+ task = page.locator(f'form[action="/devices/{device_id}/tasks/"]')
+ task.locator('[name="title"]').fill(f"{marker}-Akku prüfen")
+ task.locator('button').click()
+ stage = page.locator(f'form[action="/devices/{device_id}/stage/"]')
+ stage.locator('[name="stage"]').select_option('INSPECTION')
+ stage.locator('button').click()
+ page.goto(f"{base}/ablauf/")
+ card = page.locator('.workflow-card').filter(has=page.locator(f'a[href="/devices/{device_id}/"]'))
+ inspection = card.locator(f'form[action="/devices/{device_id}/inspection/"]')
+ inspection.locator('[name="defects"]').fill(f"{marker}-Akku schwach")
+ inspection.locator('[name="ram_gb"]').fill('16')
+ inspection.locator('button').click()
+ expect(card).to_contain_text(f"{marker}-Akku schwach")
+ stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
+ stage.locator('[name="stage"]').select_option('REPAIR')
+ stage.locator('button').click()
+ repair = card.locator(f'form[action="/devices/{device_id}/repairs/"]')
+ repair.locator('[name="work_done"]').fill(f"{marker}-Akku ersetzt")
+ repair.locator('button').click()
+ stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
+ stage.locator('[name="stage"]').select_option('STORAGE')
+ stage.locator('button').click()
+ expect(repair).to_be_visible() # An open task blocks storage.
+ page.goto(f"{base}/aufgaben/")
+ page.locator(f'form[action^="/devices/{device_id}/tasks/"][action$="/complete/"] button').click()
+ page.goto(f"{base}/ablauf/")
+ stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
+ stage.locator('[name="stage"]').select_option('STORAGE')
+ stage.locator('button').click()
+ storage = card.locator(f'form[action="/devices/{device_id}/recipient/"]')
+ expect(storage).to_be_visible()
+ storage.locator('[name="recipient"]').fill(f"{marker}-Recipient")
+ storage.locator('button').click()
+ stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
+ stage.locator('[name="stage"]').select_option('ISSUED')
+ stage.locator('button').click()
+ expect(page.locator('#stage-ISSUED').locator('../..')).to_contain_text(f"{marker}-Laptop-Edited")
+ print("PASS PostgreSQL workflow, records, task gating, recipient, issuance", flush=True)
+
+ page.goto(f"{base}/devices/{device_id}/")
+ page.get_by_role('button', name='Gerät archivieren').click()
+ page.locator('.confirm-box button').last.click()
+ page.goto(f"{base}/archive/")
+ expect(page.locator('tbody')).to_contain_text(f"{marker}-Laptop-Edited")
+ page.locator(f'form[action="/archive/{device_id}/restore/"] button').click()
+ page.goto(f"{base}/devices/{device_id}/")
+ assert page.url.rstrip('/').endswith(f'/devices/{device_id}')
+ expect(page.locator('h1')).to_contain_text('Gerät #')
+ print("PASS archive and restore", flush=True)
+
+ page.goto(f"{base}/import/")
+ for label, suffix in (("XLSX herunterladen", '.xlsx'), ("ODS für LibreOffice", '.ods')):
+ with page.expect_download() as event:
+ page.get_by_role('link', name=label).click()
+ assert event.value.suggested_filename.endswith(suffix)
+ workbook = Workbook()
+ workbook.active.append(['Modell', 'Typ', 'Status', 'Druckverfahren', 'Anschlüsse'])
+ workbook.active.append([f"{marker}-Printer", 'Drucker', 'Eingang', 'Laser', 'USB-C'])
+ import_path = Path(temporary) / f"{marker}.xlsx"
+ workbook.save(import_path)
+ page.locator('input[type="file"]').set_input_files(import_path)
+ page.get_by_role('button', name='Datei analysieren').click()
+ assert [page.locator(f'[name="column_{i}"]').input_value() for i in range(5)] == [
+ 'model', 'device_type', 'status', 'print_technology', 'ports',
+ ]
+ page.get_by_role('button', name='Import bestätigen').click()
+ page.goto(f"{base}/?model_contains={marker}-Printer")
+ expect(page.locator('tr[data-device-id]')).to_have_count(1)
+ imported_id = page.locator('tr[data-device-id]').get_attribute('data-device-id')
+ page.goto(f"{base}/devices/{imported_id}/")
+ expect(page.locator('[name="print_technology"]')).to_have_value('Laser')
+ expect(page.locator('[name="ports"]')).to_have_value('USB-C')
+ print(f"PASS PostgreSQL import/export with printer fields: device {imported_id}", flush=True)
+
+ page.goto(f"{base}/")
+ page.locator('.sidebar-theme').click()
+ assert page.locator('html').get_attribute('data-theme') == 'dark'
+ page.reload()
+ assert page.locator('html').get_attribute('data-theme') == 'dark'
+ mobile = browser.new_context(ignore_https_errors=True, viewport={'width': 390, 'height': 844})
+ mobile_page = mobile.new_page()
+ mobile_page.goto(f"{base}/accounts/login/")
+ mobile_page.locator('[name="username"]').fill(username)
+ mobile_page.locator('[name="password"]').fill(password)
+ mobile_page.locator('form button').click()
+ mobile_page.locator('[data-sidebar-toggle]').click()
+ expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open')
+ mobile.close()
+ print("PASS dark mode and mobile navigation", flush=True)
+ assert not errors, f"JavaScript errors: {errors}"
+ context.close()
+ browser.close()
+
+
+if __name__ == '__main__':
+ run()