Add hardware-linked USB intake and inspection workflow

This commit is contained in:
Codex
2026-10-14 19:36:36 +02:00
parent e2870c2fda
commit c560c0450d
10 changed files with 378 additions and 0 deletions

98
inventory/api_station.py Normal file
View File

@@ -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)