Compare commits

...

3 Commits

Author SHA1 Message Date
dee25b5cbb Merge remote-tracking branch 'origin/master' into feature/usb-aio-workflow
Some checks failed
Tests / postgres (push) Has been cancelled
Tests / postgres (pull_request) Has been cancelled
# Conflicts:
#	README.md
#	inventory/test_specification_migration.py
2026-10-14 20:04:37 +02:00
Codex
57764eec03 Restore current schema after historical migration tests
Some checks failed
Tests / postgres (push) Has been cancelled
Tests / postgres (pull_request) Has been cancelled
2026-10-14 19:42:49 +02:00
Codex
c560c0450d Add hardware-linked USB intake and inspection workflow 2026-10-14 19:36:36 +02:00
11 changed files with 388 additions and 0 deletions

View File

@@ -338,6 +338,14 @@ 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/<number>/intake/` accepts verified CPU/model/RAM/storage measurements plus `hwid` and `expected_revision`. Later workflow stages reject intake writes.
`POST /api/v1/devices/<number>/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.
## Korrekturen für die Geräteansicht (Migration 0014)
Die Geräteliste bleibt auch bei kleinen oder niedrigen Fenstern scrollbar. Neue

88
inventory/api_identity.py Normal file
View File

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

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)

View File

@@ -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/<int:number>/intake/", IntakeSyncView.as_view(), name="device-intake"),
path("devices/<int:number>/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/<int:number>/", DeviceDetailView.as_view(), name="device-detail"),

View File

@@ -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')),
])]

View File

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

View File

@@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("inventory", "0014_allinonespecification_camera_and_more"),
("inventory", "0015_inspectionsubmission"),
]
operations = []

View File

@@ -593,3 +593,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)

View File

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

51
inventory/test_station.py Normal file
View File

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

View File

@@ -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",