Compare commits
4 Commits
72138203be
...
48556d2f5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 48556d2f5e | |||
| dee25b5cbb | |||
|
|
57764eec03 | ||
|
|
c560c0450d |
@@ -338,6 +338,14 @@ Vor dem Update einer bestehenden Installation eine Datenbanksicherung
|
|||||||
erstellen, dann `python manage.py migrate` ausführen. Ein Löschen der
|
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.
|
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)
|
## Korrekturen für die Geräteansicht (Migration 0014)
|
||||||
|
|
||||||
Die Geräteliste bleibt auch bei kleinen oder niedrigen Fenstern scrollbar. Neue
|
Die Geräteliste bleibt auch bei kleinen oder niedrigen Fenstern scrollbar. Neue
|
||||||
|
|||||||
88
inventory/api_identity.py
Normal file
88
inventory/api_identity.py
Normal 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
98
inventory/api_station.py
Normal 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)
|
||||||
@@ -3,9 +3,15 @@ from django.urls import path
|
|||||||
from .api import BulkUpdateView, ChoicesView, DeviceDetailView, DeviceListView
|
from .api import BulkUpdateView, ChoicesView, DeviceDetailView, DeviceListView
|
||||||
|
|
||||||
|
|
||||||
|
from .api_identity import DeviceResolveView
|
||||||
|
from .api_station import IntakeSyncView, InspectionSyncView
|
||||||
|
|
||||||
app_name = "inventory-api"
|
app_name = "inventory-api"
|
||||||
|
|
||||||
urlpatterns = [
|
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/", DeviceListView.as_view(), name="device-list"),
|
||||||
path("devices/bulk-update/", BulkUpdateView.as_view(), name="device-bulk-update"),
|
path("devices/bulk-update/", BulkUpdateView.as_view(), name="device-bulk-update"),
|
||||||
path("devices/<int:number>/", DeviceDetailView.as_view(), name="device-detail"),
|
path("devices/<int:number>/", DeviceDetailView.as_view(), name="device-detail"),
|
||||||
|
|||||||
11
inventory/migrations/0014_deviceidentity.py
Normal file
11
inventory/migrations/0014_deviceidentity.py
Normal 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')),
|
||||||
|
])]
|
||||||
16
inventory/migrations/0015_inspectionsubmission.py
Normal file
16
inventory/migrations/0015_inspectionsubmission.py
Normal 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)]
|
||||||
10
inventory/migrations/0016_merge_usb_station_and_equipment.py
Normal file
10
inventory/migrations/0016_merge_usb_station_and_equipment.py
Normal 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 = []
|
||||||
@@ -593,3 +593,18 @@ class ImportJob(TimeStampedModel):
|
|||||||
headers = models.JSONField(default=list)
|
headers = models.JSONField(default=list)
|
||||||
preview_rows = models.JSONField(default=list)
|
preview_rows = models.JSONField(default=list)
|
||||||
result = models.JSONField(default=dict)
|
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)
|
||||||
|
|||||||
84
inventory/test_identity.py
Normal file
84
inventory/test_identity.py
Normal 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
51
inventory/test_station.py
Normal 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)
|
||||||
@@ -17,6 +17,7 @@ TRANSITIONS = {
|
|||||||
Device.Stage.ISSUED: {Device.Stage.INTAKE},
|
Device.Stage.ISSUED: {Device.Stage.INTAKE},
|
||||||
}
|
}
|
||||||
STATUS_BY_STAGE = {
|
STATUS_BY_STAGE = {
|
||||||
|
Device.Stage.INSPECTION: "Inspektion",
|
||||||
Device.Stage.INTAKE: "Eingang",
|
Device.Stage.INTAKE: "Eingang",
|
||||||
Device.Stage.REPAIR: "Defekt",
|
Device.Stage.REPAIR: "Defekt",
|
||||||
Device.Stage.STORAGE: "Fertig",
|
Device.Stage.STORAGE: "Fertig",
|
||||||
|
|||||||
Reference in New Issue
Block a user