diff --git a/README.md b/README.md index 24023fa..0cc384e 100644 --- a/README.md +++ b/README.md @@ -331,3 +331,11 @@ eigene Datensätze erhalten (Migration 0011). Vor dem Update einer bestehenden Installation eine Datenbanksicherung erstellen, dann `python manage.py migrate` ausführen. Ein Löschen der Datenbank ist nicht nötig und würde Geräte und Benutzerkonten entfernen. + +## USB AIO station API + +The USB station resolves hardware identity with `POST /api/v1/devices/resolve/`, then reads the returned inventory number, workflow `stage`, and status. For `INTAKE` devices only, `POST /api/v1/devices//intake/` accepts verified CPU/model/RAM/storage measurements plus `hwid` and `expected_revision`. Later workflow stages reject intake writes. + +`POST /api/v1/devices//inspection/` accepts a UUID `request_id`, `hwid`, `expected_revision`, phase (`STARTED`, `IN_PROGRESS`, `COMPLETE`), technician, notes and a dictionary of test results. Each result preserves outcome, notes, evidence and attempt history. The API moves allowed devices to `INSPECTION`, sets status to `Inspektion`, adds a readable inspection record and retains the structured snapshot. A unique request ID prevents duplicate records after lost responses; reused IDs with different content are rejected. Inventory workflow checks, hardware matching and API-key permissions remain enforced. Completed inspections require a named technician and no unfinished results; unavailable tests remain distinct from hardware failures. + +Apply migrations through `0015_inspectionsubmission` before deploying the new USB application. Wi-Fi credentials and inventory API keys stay on USB persistent storage, not in this repository. diff --git a/inventory/api_identity.py b/inventory/api_identity.py new file mode 100644 index 0000000..215b319 --- /dev/null +++ b/inventory/api_identity.py @@ -0,0 +1,88 @@ +"""Atomic hardware identity registration for intake clients.""" +import re +import uuid +from django.db import transaction +from django.db.models import Q +from rest_framework import serializers +from rest_framework.response import Response +from rest_framework.views import APIView +from .api import SecureAPIResponseMixin, StrictSerializer, DeviceSerializer +from .models import Device, DeviceIdentity, NumberSequence, ChoiceValue, AuditLog +from .query import COMPUTER_RELATIONS +from .services import add_audit + + +def canonical_hwid(value): + value = value.strip().lower() + if value.startswith('dmi-uuid:'): + try: + token = uuid.UUID(value[9:]) + except ValueError: + raise serializers.ValidationError('Ungültige Hardware-UUID.') + if token.int in (0, (1 << 128) - 1): + raise serializers.ValidationError('Platzhalter-UUID ist nicht erlaubt.') + return 'dmi-uuid:' + str(token) + if re.fullmatch(r'dmi-serial-sha256:[0-9a-f]{64}', value): + return value + raise serializers.ValidationError('Verifizierbare Hardware-Kennung erforderlich.') + + +class IdentitySerializer(StrictSerializer): + hwid = serializers.CharField(max_length=100) + aliases = serializers.ListField(child=serializers.CharField(max_length=100), max_length=2, required=False, default=list) + device_kind = serializers.ChoiceField(choices=['Laptop', 'Desktop', 'AiO']) + model = serializers.CharField(max_length=200, required=False, default='') + + def validate_aliases(self, values): + return list(dict.fromkeys(canonical_hwid(v) for v in values)) + + def validate_hwid(self, value): + return canonical_hwid(value) + + +class DeviceResolveView(SecureAPIResponseMixin, APIView): + @transaction.atomic + def post(self, request): + data = IdentitySerializer(data=request.data) + data.is_valid(raise_exception=True) + values = data.validated_data + hwid = values['hwid'] + # Serialize allocation and identity lookup across clients and processes. + NumberSequence.objects.select_for_update().get_or_create(name='device') + keys = list(dict.fromkeys([hwid, *values['aliases']])) + # At most one value per source. Differing UUIDs/serials need review. + if len({k.split(':')[0] for k in keys}) != len(keys): + return Response({'detail': 'Widersprüchliche Hardware-Kennungen.'}, status=409) + ids = set(DeviceIdentity.objects.filter(hwid__in=keys).values_list('device_id', flat=True)) + query = Q() + for relation in COMPUTER_RELATIONS: + for key in keys: + query |= Q(**{f'{relation}__hwid__iexact': key}) + if key.startswith('dmi-uuid:'): + query |= Q(**{f'{relation}__hwid__iexact': key[9:]}) + ids.update(Device.objects.filter(query).values_list('pk', flat=True)) + if len(ids) > 1: + return Response({'detail': 'Hardware-Kennungen sind mehreren Geräten zugeordnet.'}, status=409) + device = Device.objects.get(pk=next(iter(ids))) if ids else None + if device is not None: + if device.archived_at: + return Response({'detail': 'Gerät ist archiviert; zuerst im Inventory prüfen.'}, status=409) + if values['model'] and device.model and values['model'].casefold() != device.model.strip().casefold(): + return Response({'detail': 'Hardware-Kennung vorhanden, aber Modell stimmt nicht überein.'}, status=409) + stored = list(device.hardware_identities.values_list('hwid', flat=True)) + for key in keys: + if any(old.split(':')[0] == key.split(':')[0] and old != key for old in stored): + return Response({'detail': 'UUID oder Seriennummer hat sich geändert; Gerät manuell prüfen.'}, status=409) + for key in keys: + DeviceIdentity.objects.get_or_create(hwid=key, defaults={'device': device}) + return Response(DeviceSerializer(device).data) + status = ChoiceValue.objects.filter(kind='status', active=True, label__iexact='Eingang').first() + kind = ChoiceValue.objects.filter(kind='device_type', active=True, label__iexact=values['device_kind']).first() + if not status or not kind: + raise serializers.ValidationError('Aktiver Status Eingang und passender Gerätetyp müssen konfiguriert sein.') + device = Device.objects.create(status=status, device_type=kind, hwid=hwid, model=values['model']) + for key in keys: + DeviceIdentity.objects.create(hwid=key, device=device) + add_audit(request.user, device, AuditLog.Action.CREATE, + new=f'Gerät #{device.number} per Hardware-Kennung', api_client=request.auth.name) + return Response(DeviceSerializer(device).data, status=201) diff --git a/inventory/api_station.py b/inventory/api_station.py new file mode 100644 index 0000000..03dbbd5 --- /dev/null +++ b/inventory/api_station.py @@ -0,0 +1,98 @@ +"""USB station: guarded intake updates and idempotent inspection snapshots.""" +import hashlib +import json +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import serializers +from rest_framework.response import Response +from rest_framework.views import APIView +from .api import SecureAPIResponseMixin, StrictSerializer, DeviceSerializer, apply_changes +from .api_identity import canonical_hwid +from .models import Device, DeviceIdentity, ChoiceValue, InspectionSubmission, InspectionRecord, AuditLog +from .services import add_audit +from .workflow import advance_device + +class IdentityRequest(StrictSerializer): + hwid = serializers.CharField(max_length=100) + expected_revision = serializers.IntegerField(min_value=1) + def validate_hwid(self, value): return canonical_hwid(value) + +class SpecsSerializer(StrictSerializer): + model = serializers.CharField(max_length=200, required=False) + cpu = serializers.CharField(max_length=200, required=False) + ram_gb = serializers.IntegerField(min_value=1, max_value=2048, required=False) + ssd_gb = serializers.IntegerField(min_value=1, required=False) + hdd_gb = serializers.IntegerField(min_value=1, required=False) + +class IntakeRequest(IdentityRequest): + specifications = SpecsSerializer() + +class ResultSerializer(StrictSerializer): + outcome = serializers.ChoiceField(choices=['NOT_TESTED','NOT_APPLICABLE','PASS','HARDWARE_FAILURE','UNAVAILABLE','SETUP_PROBLEM','NEEDS_REVIEW']) + notes = serializers.CharField(max_length=10000, allow_blank=True, required=False, default='') + evidence = serializers.CharField(max_length=100000, allow_blank=True, required=False, default='') + history = serializers.ListField(child=serializers.JSONField(), max_length=500, required=False, default=list) + +class InspectionRequest(IdentityRequest): + request_id = serializers.UUIDField() + phase = serializers.ChoiceField(choices=['STARTED','IN_PROGRESS','COMPLETE']) + technician = serializers.CharField(max_length=200, allow_blank=True) + notes = serializers.CharField(max_length=10000, allow_blank=True) + results = serializers.DictField(child=ResultSerializer()) + def validate_results(self, value): + if len(value)>50 or len(json.dumps(value))>500000: + raise serializers.ValidationError('Too many inspection results.') + if any(len(k)>80 for k in value): raise serializers.ValidationError('Invalid test name.') + return value + def validate(self, attrs): + if attrs['phase']=='COMPLETE' and (not attrs['technician'].strip() or not attrs['results'] or any(r['outcome'] in ['NOT_TESTED','NEEDS_REVIEW'] for r in attrs['results'].values())): + raise serializers.ValidationError('Complete all tests and enter the technician name first.') + return attrs + +def locked_device(number, hwid): + d=get_object_or_404(Device.objects.select_for_update(), number=number, archived_at__isnull=True) + if not DeviceIdentity.objects.filter(device=d,hwid=hwid).exists(): + raise serializers.ValidationError('Hardware identity does not match this device.') + return d + +class IntakeSyncView(SecureAPIResponseMixin, APIView): + @transaction.atomic + def post(self, request, number): + s=IntakeRequest(data=request.data);s.is_valid(raise_exception=True);v=s.validated_data + d=locked_device(number,v['hwid']) + if d.stage != Device.Stage.INTAKE or d.revision != v['expected_revision']: + return Response({'detail':'Device state changed. Refresh before intake sync.'},status=409) + changes={k:x for k,x in v['specifications'].items() if getattr(d,k)!=x} + if changes: + d=apply_changes(d,changes,user=request.user,api_client=request.auth.name,expected_revision=d.revision) + return Response(DeviceSerializer(d).data) + +class InspectionSyncView(SecureAPIResponseMixin, APIView): + @transaction.atomic + def post(self, request, number): + s=InspectionRequest(data=request.data);s.is_valid(raise_exception=True);v=s.validated_data + d=locked_device(number,v['hwid']) + payload={k:x for k,x in request.data.items() if k not in ['expected_revision','request_id']} + digest=hashlib.sha256(json.dumps(payload,sort_keys=True,separators=(',',':')).encode()).hexdigest() + previous=InspectionSubmission.objects.filter(request_id=v['request_id']).first() + if previous: + if previous.device_id != d.pk or previous.payload_hash != digest: + return Response({'detail':'Request ID already used for different results.'},status=409) + return Response(DeviceSerializer(d).data) + if d.revision != v['expected_revision'] or d.stage not in [Device.Stage.INTAKE,Device.Stage.INSPECTION,Device.Stage.REPAIR]: + return Response({'detail':'Device state changed. Refresh before inspection sync.'},status=409) + if d.stage != Device.Stage.INSPECTION: + d=advance_device(d.pk,Device.Stage.INSPECTION,user=request.user,note='USB inspection',expected_revision=d.revision) + state=ChoiceValue.objects.get(kind='status',label='Inspektion',active=True) + if d.status_id != state.pk: + old=d.status;d.status=state;d.save(update_fields=['status']) + add_audit(request.user,d,AuditLog.Action.UPDATE,'Status',old,state,api_client=request.auth.name) + else: + d.save(update_fields=['updated_at']) + InspectionSubmission.objects.create(request_id=v['request_id'],device=d,payload_hash=digest,payload=payload,recorded_by=request.user) + lines=[f"USB {v['phase']} · {v['technician']}",v['notes']] + lines += [f"{key}: {result['outcome']} — {result['notes']}" for key,result in v['results'].items()] + summary='\n'.join(lines) + InspectionRecord.objects.create(device=d,defects=summary,recorded_by=request.user) + add_audit(request.user,d,AuditLog.Action.UPDATE,'USB inspection','',summary,api_client=request.auth.name) + return Response(DeviceSerializer(d).data,status=201) diff --git a/inventory/api_urls.py b/inventory/api_urls.py index 45f557d..063fdc2 100644 --- a/inventory/api_urls.py +++ b/inventory/api_urls.py @@ -3,9 +3,15 @@ from django.urls import path from .api import BulkUpdateView, ChoicesView, DeviceDetailView, DeviceListView +from .api_identity import DeviceResolveView +from .api_station import IntakeSyncView, InspectionSyncView + app_name = "inventory-api" urlpatterns = [ + path("devices//intake/", IntakeSyncView.as_view(), name="device-intake"), + path("devices//inspection/", InspectionSyncView.as_view(), name="device-inspection"), + path("devices/resolve/", DeviceResolveView.as_view(), name="device-resolve"), path("devices/", DeviceListView.as_view(), name="device-list"), path("devices/bulk-update/", BulkUpdateView.as_view(), name="device-bulk-update"), path("devices//", DeviceDetailView.as_view(), name="device-detail"), diff --git a/inventory/migrations/0014_deviceidentity.py b/inventory/migrations/0014_deviceidentity.py new file mode 100644 index 0000000..dc6940d --- /dev/null +++ b/inventory/migrations/0014_deviceidentity.py @@ -0,0 +1,11 @@ +from django.db import migrations, models +import django.db.models.deletion + +class Migration(migrations.Migration): + dependencies = [('inventory', '0013_deviceregistration')] + operations = [migrations.CreateModel(name='DeviceIdentity', fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hwid', models.CharField(max_length=100, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='hardware_identities', to='inventory.device')), + ])] diff --git a/inventory/migrations/0015_inspectionsubmission.py b/inventory/migrations/0015_inspectionsubmission.py new file mode 100644 index 0000000..25b3638 --- /dev/null +++ b/inventory/migrations/0015_inspectionsubmission.py @@ -0,0 +1,16 @@ +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + +def add_status(apps, schema_editor): + apps.get_model('inventory','ChoiceValue').objects.get_or_create(kind='status',label='Inspektion',defaults={'active':True}) + +class Migration(migrations.Migration): + dependencies=[('inventory','0014_deviceidentity'),migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations=[migrations.CreateModel(name='InspectionSubmission',fields=[ + ('id',models.BigAutoField(auto_created=True,primary_key=True,serialize=False,verbose_name='ID')), + ('request_id',models.UUIDField(unique=True)),('payload_hash',models.CharField(max_length=64)), + ('payload',models.JSONField()),('created_at',models.DateTimeField(auto_now_add=True)), + ('device',models.ForeignKey(on_delete=django.db.models.deletion.PROTECT,related_name='usb_inspections',to='inventory.device')), + ('recorded_by',models.ForeignKey(null=True,on_delete=django.db.models.deletion.SET_NULL,to=settings.AUTH_USER_MODEL)), + ]),migrations.RunPython(add_status,migrations.RunPython.noop)] diff --git a/inventory/models.py b/inventory/models.py index 3cb466c..15b064f 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -570,3 +570,18 @@ class ImportJob(TimeStampedModel): headers = models.JSONField(default=list) preview_rows = models.JSONField(default=list) result = models.JSONField(default=dict) + + +class DeviceIdentity(models.Model): + """Stable intake identity, independent of mutable specifications and USBs.""" + hwid = models.CharField(max_length=100, unique=True) + device = models.ForeignKey(Device, on_delete=models.PROTECT, related_name='hardware_identities') + created_at = models.DateTimeField(auto_now_add=True) + +class InspectionSubmission(models.Model): + request_id = models.UUIDField(unique=True) + device = models.ForeignKey(Device, on_delete=models.PROTECT, related_name='usb_inspections') + payload_hash = models.CharField(max_length=64) + payload = models.JSONField() + recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/inventory/test_identity.py b/inventory/test_identity.py new file mode 100644 index 0000000..bea3f03 --- /dev/null +++ b/inventory/test_identity.py @@ -0,0 +1,84 @@ +import json +import uuid +from datetime import timedelta +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from django.contrib.auth.models import User +from django.db import close_old_connections, IntegrityError, transaction +from django.test import TestCase, TransactionTestCase, Client, skipUnlessDBFeature +from django.utils import timezone +from .models import Device, DeviceIdentity, ChoiceValue, InventoryAPIKey, NumberSequence + +class IdentityTests(TestCase): + def setUp(self): + self.user = User.objects.create_user('identity-operator', is_superuser=True) + _, self.key = InventoryAPIKey.objects.create_key(name='identity', service_user=self.user, + can_read=True, can_write=True, expiry_date=timezone.now()+timedelta(days=1)) + self.status = ChoiceValue.objects.create(kind='status', label='Eingang') + self.kind = ChoiceValue.objects.create(kind='device_type', label='Laptop') + self.payload = {'hwid': 'dmi-uuid:'+str(uuid.uuid4()), 'device_kind': 'Laptop', 'model': 'Test Laptop'} + def post(self, data=None, key=None): + return self.client.post('/api/v1/devices/resolve/', json.dumps(data or self.payload), content_type='application/json', HTTP_AUTHORIZATION='Api-Key '+(key or self.key)) + def test_repeat_uses_same_number_and_preserves_repairs(self): + a=self.post(); self.assertEqual(a.status_code,201,a.content) + device=Device.objects.get(); device.comment='Repaired'; device.save() + b=self.post(); self.assertEqual(b.status_code,200,b.content) + self.assertEqual(a.json()['number'],b.json()['number']) + self.assertEqual(Device.objects.get().comment,'Repaired') + self.assertEqual(DeviceIdentity.objects.count(),1) + def test_existing_raw_uuid_is_reused(self): + d=Device.objects.create(status=self.status,device_type=self.kind,hwid=self.payload['hwid'][9:],model='Test Laptop') + r=self.post(); self.assertEqual(r.status_code,200,r.content); self.assertEqual(r.json()['number'],d.number) + def test_invalid_identifier_and_model_conflict(self): + for hwid in ['','unknown','dmi-uuid:00000000-0000-0000-0000-000000000000','dmi-uuid:ffffffff-ffff-ffff-ffff-ffffffffffff']: + self.assertEqual(self.post({**self.payload,'hwid':hwid}).status_code,400) + self.post(); self.assertEqual(self.post({**self.payload,'model':'Different'}).status_code,409) + self.assertEqual(Device.objects.count(),1) + def test_existing_ambiguous_or_archived_is_not_duplicated(self): + d=Device.objects.create(status=self.status,device_type=self.kind,hwid=self.payload['hwid'],archived_at=timezone.now()) + self.assertEqual(self.post().status_code,409) + Device.objects.create(status=self.status,device_type=self.kind,hwid=self.payload['hwid']) + self.assertEqual(self.post().status_code,409) + self.assertEqual(Device.objects.count(),2) + def test_readonly_cannot_register(self): + _,key=InventoryAPIKey.objects.create_key(name='read',service_user=self.user,can_read=True,can_write=False,expiry_date=timezone.now()+timedelta(days=1)) + self.assertEqual(self.post(key=key).status_code,403) + self.assertFalse(Device.objects.exists()) + def test_serial_fallback_then_uuid_does_not_duplicate(self): + serial='dmi-serial-sha256:'+'a'*64 + first=self.post({**self.payload,'hwid':serial}) + second=self.post({**self.payload,'aliases':[serial]}) + self.assertEqual(first.status_code,201,first.content) + self.assertEqual(second.status_code,200,second.content) + self.assertEqual(first.json()['number'],second.json()['number']) + self.assertEqual(DeviceIdentity.objects.count(),2) + conflict=self.post({**self.payload,'aliases':['dmi-serial-sha256:'+'b'*64]}) + self.assertEqual(conflict.status_code,409,conflict.content) + self.assertEqual(Device.objects.count(),1) + + def test_database_enforces_unique_identity(self): + self.post() + with self.assertRaises(IntegrityError), transaction.atomic(): + DeviceIdentity.objects.create(hwid=self.payload['hwid'],device=Device.objects.get()) + +class ConcurrentIdentityTests(TransactionTestCase): + @skipUnlessDBFeature('has_select_for_update') + def test_two_clients_allocate_once(self): + user=User.objects.create_user('concurrent-identity',is_superuser=True) + _,key=InventoryAPIKey.objects.create_key(name='identity',service_user=user,can_read=True,can_write=True,expiry_date=timezone.now()+timedelta(days=1)) + ChoiceValue.objects.create(kind='status',label='Eingang') + ChoiceValue.objects.create(kind='device_type',label='Laptop') + NumberSequence.objects.update_or_create(name='device',defaults={'last_value':0}) + payload={'hwid':'dmi-uuid:'+str(uuid.uuid4()),'device_kind':'Laptop'} + barrier=Barrier(2) + def submit(_): + close_old_connections() + try: + barrier.wait(timeout=10) + r=Client().post('/api/v1/devices/resolve/',json.dumps(payload),content_type='application/json',HTTP_AUTHORIZATION='Api-Key '+key) + return r.status_code,r.json()['number'] + finally: close_old_connections() + with ThreadPoolExecutor(max_workers=2) as pool: results=list(pool.map(submit,range(2))) + self.assertEqual(sorted(x[0] for x in results),[200,201]) + self.assertEqual(len({x[1] for x in results}),1) + self.assertEqual(Device.objects.count(),1) diff --git a/inventory/test_station.py b/inventory/test_station.py new file mode 100644 index 0000000..51842ec --- /dev/null +++ b/inventory/test_station.py @@ -0,0 +1,51 @@ +import json,uuid +from datetime import timedelta +from django.test import TestCase +from django.contrib.auth.models import User +from django.utils import timezone +from .models import Device,DeviceIdentity,ChoiceValue,InventoryAPIKey,InspectionSubmission,InspectionRecord + +class StationTests(TestCase): + def setUp(self): + self.user=User.objects.create_user('station',is_superuser=True) + _,self.key=InventoryAPIKey.objects.create_key(name='station',service_user=self.user,can_read=True,can_write=True,expiry_date=timezone.now()+timedelta(days=1)) + state=ChoiceValue.objects.create(kind='status',label='Eingang') + ChoiceValue.objects.get_or_create(kind='status',label='Inspektion',defaults={'active':True}) + kind=ChoiceValue.objects.create(kind='device_type',label='Laptop') + self.d=Device.objects.create(status=state,device_type=kind,model='Test',ram_gb=8) + self.hwid='dmi-uuid:'+str(uuid.uuid4()) + DeviceIdentity.objects.create(hwid=self.hwid,device=self.d) + def post(self,route,body,key=None): + return self.client.post(f'/api/v1/devices/{self.d.number}/{route}/',json.dumps(body),content_type='application/json',HTTP_AUTHORIZATION='Api-Key '+(key or self.key)) + def inspection(self,phase='STARTED'): + self.d.refresh_from_db() + return {'hwid':self.hwid,'expected_revision':self.d.revision,'request_id':str(uuid.uuid4()),'phase':phase,'technician':'Tech','notes':'','results':{'wifi':{'outcome':'PASS','notes':'Connected','history':[]}}} + def test_intake_specs_then_start_changes_stage_and_status(self): + r=self.post('intake',{'hwid':self.hwid,'expected_revision':self.d.revision,'specifications':{'ram_gb':16,'ssd_gb':512}}) + self.assertEqual(r.status_code,200,r.content);self.assertEqual(r.json()['stage'],'INTAKE') + r=self.post('inspection',self.inspection());self.assertEqual(r.status_code,201,r.content) + self.assertEqual(r.json()['stage'],'INSPECTION');self.assertEqual(r.json()['status']['label'],'Inspektion') + self.assertEqual(InspectionSubmission.objects.count(),1) + def test_repeat_and_completion_preserve_structured_results(self): + body=self.inspection('COMPLETE');body['results']['wifi']['outcome']='UNAVAILABLE' + r=self.post('inspection',body);self.assertEqual(r.status_code,201,r.content) + self.assertEqual(self.post('inspection',body).status_code,200) + self.assertEqual(InspectionSubmission.objects.count(),1);self.assertEqual(InspectionRecord.objects.count(),1) + self.assertEqual(InspectionSubmission.objects.get().payload['results']['wifi']['outcome'],'UNAVAILABLE') + body['notes']='different';self.assertEqual(self.post('inspection',body).status_code,409) + def test_incomplete_cannot_be_completed(self): + body=self.inspection('COMPLETE');body['results']['wifi']['outcome']='NOT_TESTED' + self.assertEqual(self.post('inspection',body).status_code,400) + self.assertFalse(InspectionSubmission.objects.exists()) + def test_stale_revision_wrong_device_and_later_stages_blocked(self): + body=self.inspection();self.d.comment='Changed';self.d.save() + self.assertEqual(self.post('inspection',body).status_code,409) + body=self.inspection();body['hwid']='dmi-uuid:'+str(uuid.uuid4()) + self.assertEqual(self.post('inspection',body).status_code,400) + self.d.stage='STORAGE';self.d.save() + self.assertEqual(self.post('inspection',self.inspection()).status_code,409) + self.assertEqual(self.post('intake',{'hwid':self.hwid,'expected_revision':self.d.revision,'specifications':{'ram_gb':32}}).status_code,409) + self.d.refresh_from_db();self.assertEqual(self.d.ram_gb,8) + def test_readonly_cannot_change_inspection(self): + _,key=InventoryAPIKey.objects.create_key(name='read',service_user=self.user,can_read=True,can_write=False,expiry_date=timezone.now()+timedelta(days=1)) + self.assertEqual(self.post('inspection',self.inspection(),key).status_code,403) diff --git a/inventory/workflow.py b/inventory/workflow.py index 637f2ea..bdee5fa 100644 --- a/inventory/workflow.py +++ b/inventory/workflow.py @@ -17,6 +17,7 @@ TRANSITIONS = { Device.Stage.ISSUED: {Device.Stage.INTAKE}, } STATUS_BY_STAGE = { + Device.Stage.INSPECTION: "Inspektion", Device.Stage.INTAKE: "Eingang", Device.Stage.REPAIR: "Defekt", Device.Stage.STORAGE: "Fertig",