Add extra-data combinations, single measure flow, protocols and licensing
Four related backend features. They are committed together because they interleave in api/views.py and api/serializers.py - one hunk holds the combinations ViewSet, the licensing helpers and LicenseView - and splitting by hunk would produce commits that do not import. ExtraCombination records previously-used combinations of lookup nomenclature values so an operator can refill the Additional Data fields in one click. Deduplicated by a canonical order-independent hash, since JSONField equality compares serialized text on SQLite and so depends on key order. Weighing replaces set_tare/set_gross with a single measure action that assigns by weight - lighter becomes tare, heavier gross - because trucks arrive both loaded and empty. A second, lighter reading moves the first to gross carrying its own timestamp, user and manual fields, so provenance follows the value rather than the slot. Manual entries record who, when and why, gated on the previously unenforced can_measure and can_manually_measure flags. ScaleProtocol stores how to read each indicator, so a new scale type is a configuration change rather than a new binary on every station PC. Licensing is offline and node-locked: the server verifies an Ed25519 signature against a fingerprint of four hardware components, tolerating one change, and counts concurrently connected stations. Sites are air-gapped, so there is no activation server. A 14-day grace period keeps an unlicensed or newly-invalid install working, because a licence check must never stop a weighbridge without warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+274
-35
@@ -1,17 +1,21 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from .models import ComPortReading, User, Report, generate_doc_number
|
||||
from .models import (ComPortReading, User, Report, ScaleProtocol, License,
|
||||
StationSlot, generate_doc_number)
|
||||
from .serializers import (
|
||||
ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
|
||||
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
|
||||
NomenclatureEntrySerializer, ReportSerializer,
|
||||
NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
|
||||
ScaleProtocolSerializer,
|
||||
)
|
||||
from django.utils import timezone
|
||||
from vehicles.models import Vehicle
|
||||
from vehicles.models import Vehicle, ExtraCombination, combination_hash
|
||||
from nomenclatures.models import Nomenclature, NomenclatureEntry
|
||||
from scalesapp.sse import sse_broadcast_update
|
||||
from . import licensing
|
||||
|
||||
|
||||
class IsAdminUser(IsAuthenticated):
|
||||
@@ -137,6 +141,77 @@ def _maybe_finalize(vehicle, user):
|
||||
vehicle.save()
|
||||
|
||||
|
||||
def _assign_weighing(vehicle, value, user, manual=False, note=None):
|
||||
"""Record a weighing. Assigns by weight: lighter becomes tare, heavier becomes gross.
|
||||
|
||||
The first weighing is parked in tare; the second may push it to gross if the new
|
||||
reading is lighter, since trucks may arrive either empty (inbound) or loaded (outbound).
|
||||
"""
|
||||
now = timezone.now()
|
||||
|
||||
def apply(prefix):
|
||||
setattr(vehicle, prefix, value)
|
||||
setattr(vehicle, f'{prefix}_date', now)
|
||||
setattr(vehicle, f'{prefix}_user', user)
|
||||
setattr(vehicle, f'{prefix}_manual', manual)
|
||||
setattr(vehicle, f'{prefix}_manual_user', user if manual else None)
|
||||
setattr(vehicle, f'{prefix}_manual_date', now if manual else None)
|
||||
setattr(vehicle, f'{prefix}_manual_note', note if manual else None)
|
||||
|
||||
def move(src_prefix, dst_prefix):
|
||||
"""Move a recorded weighing to the other slot, carrying all its provenance."""
|
||||
for suffix in ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note'):
|
||||
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
|
||||
|
||||
if vehicle.tare is not None and vehicle.gross is not None:
|
||||
raise ValueError('Vehicle already has both weights.')
|
||||
|
||||
if vehicle.tare is None and vehicle.gross is None:
|
||||
apply('tare')
|
||||
elif vehicle.gross is None:
|
||||
# tare holds the first weighing - keep the lighter one as tare
|
||||
if value >= vehicle.tare:
|
||||
apply('gross')
|
||||
else:
|
||||
move('tare', 'gross')
|
||||
apply('tare')
|
||||
else:
|
||||
# gross set but tare empty (only reachable via direct API use)
|
||||
if value <= vehicle.gross:
|
||||
apply('tare')
|
||||
else:
|
||||
move('gross', 'tare')
|
||||
apply('gross')
|
||||
|
||||
vehicle.save()
|
||||
_maybe_finalize(vehicle, user)
|
||||
|
||||
|
||||
def _record_extra_combination(extra_data):
|
||||
"""Record the lookup-only projection of a vehicle's extra data as a reusable combination."""
|
||||
if not extra_data:
|
||||
return
|
||||
|
||||
lookup_codes = set(
|
||||
Nomenclature.objects.filter(applies_to='vehicle', kind=Nomenclature.LOOKUP)
|
||||
.values_list('code', flat=True)
|
||||
)
|
||||
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
|
||||
if not combo:
|
||||
return
|
||||
|
||||
existing, created = ExtraCombination.objects.get_or_create(
|
||||
data_hash=combination_hash(combo), defaults={'data': combo}
|
||||
)
|
||||
if not created:
|
||||
existing.use_count += 1
|
||||
existing.last_used = timezone.now()
|
||||
existing.save()
|
||||
|
||||
action = 'created' if created else 'updated'
|
||||
sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data)
|
||||
|
||||
|
||||
class VehicleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for vehicle management.
|
||||
@@ -148,8 +223,7 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
partial_update: Partial update of vehicle
|
||||
destroy: Delete a vehicle (cascades to VehicleExtra)
|
||||
by_number: Get vehicle by vehicle_number
|
||||
set_tare: Set tare weight from scale reading
|
||||
set_gross: Set gross weight from scale reading
|
||||
measure: Record a weighing (app assigns tare vs gross by weight)
|
||||
"""
|
||||
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
|
||||
serializer_class = VehicleSerializer
|
||||
@@ -162,11 +236,13 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
instance = serializer.save()
|
||||
data = VehicleSerializer(instance).data
|
||||
sse_broadcast_update('vehicle', 'created', data)
|
||||
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
data = VehicleSerializer(instance).data
|
||||
sse_broadcast_update('vehicle', 'updated', data)
|
||||
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
data = VehicleSerializer(instance).data
|
||||
@@ -193,10 +269,33 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='set-tare')
|
||||
def set_tare(self, request, pk=None):
|
||||
"""Set tare weight from scale reading"""
|
||||
vehicle = self.get_object()
|
||||
@action(detail=True, methods=['post'], url_path='measure')
|
||||
def measure(self, request, pk=None):
|
||||
"""Record a weighing. The app assigns tare vs gross by weight."""
|
||||
if not request.user.can_measure:
|
||||
return Response({'detail': 'You do not have permission to measure.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Licence enforcement. The UI also disables the button, but this is the
|
||||
# actual rule - the button state is only a convenience.
|
||||
station_key = (request.data.get('station_key') or '').strip()
|
||||
if not _station_allowed(station_key):
|
||||
limit = licensing.effective_station_limit()
|
||||
return Response(
|
||||
{'detail': f'This station is not licensed to measure '
|
||||
f'(limit: {limit} stations).'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
manual = bool(request.data.get('manual', False))
|
||||
if manual and not request.user.can_manually_measure:
|
||||
return Response({'detail': 'You do not have permission to measure manually.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
note = (request.data.get('note') or '').strip()
|
||||
if manual and not note:
|
||||
return Response({'detail': 'A note is required for a manual measurement.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
value = request.data.get('value')
|
||||
if value is None:
|
||||
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -205,39 +304,179 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
except (ValueError, TypeError):
|
||||
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
vehicle.tare = value
|
||||
vehicle.tare_date = timezone.now()
|
||||
vehicle.tare_user = request.user
|
||||
vehicle.save()
|
||||
_maybe_finalize(vehicle, request.user)
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='set-gross')
|
||||
def set_gross(self, request, pk=None):
|
||||
"""Set gross weight from scale reading"""
|
||||
vehicle = self.get_object()
|
||||
value = request.data.get('value')
|
||||
if value is None:
|
||||
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
vehicle.gross = value
|
||||
vehicle.gross_date = timezone.now()
|
||||
vehicle.gross_user = request.user
|
||||
vehicle.save()
|
||||
_maybe_finalize(vehicle, request.user)
|
||||
_assign_weighing(vehicle, value, request.user, manual=manual, note=note or None)
|
||||
except ValueError as exc:
|
||||
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ExtraCombinationViewSet(mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
"""
|
||||
API endpoint for previously-used combinations of vehicle extra (lookup) data.
|
||||
|
||||
list: Get all combinations, most recently used first
|
||||
retrieve: Get a specific combination
|
||||
destroy: Delete a combination the user no longer needs
|
||||
|
||||
Rows are created/bumped automatically when a vehicle's extra data is saved.
|
||||
"""
|
||||
queryset = ExtraCombination.objects.all()
|
||||
serializer_class = ExtraCombinationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
ordering = ['-last_used']
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
data = ExtraCombinationSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('extra_combination', 'deleted', data)
|
||||
|
||||
|
||||
def _live_slots():
|
||||
return StationSlot.objects.filter(last_seen__gte=licensing.live_slot_cutoff())
|
||||
|
||||
|
||||
def _station_allowed(station_key):
|
||||
"""Whether this station may measure.
|
||||
|
||||
A station already holding a live slot is always allowed - including when the
|
||||
licence is full - so a brief disconnection does not lock a working station
|
||||
out behind its own expiring slot.
|
||||
"""
|
||||
if not station_key:
|
||||
return False
|
||||
if _live_slots().filter(station_key=station_key).exists():
|
||||
return True
|
||||
return _live_slots().count() < licensing.effective_station_limit()
|
||||
|
||||
|
||||
class LicenseView(APIView):
|
||||
"""Licence status, and installing a new licence key."""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
status_info = licensing.license_status()
|
||||
slots = _live_slots()
|
||||
payload = {
|
||||
**status_info,
|
||||
'stations_used': slots.count(),
|
||||
'components': licensing.machine_components(),
|
||||
}
|
||||
if request.user.is_admin:
|
||||
payload['slots'] = [
|
||||
{'station_key': s.station_key, 'com_port': s.com_port,
|
||||
'bridge_version': s.bridge_version, 'last_seen': s.last_seen}
|
||||
for s in slots
|
||||
]
|
||||
return Response(payload)
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_admin:
|
||||
return Response({'detail': 'Only administrators can install a licence.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
blob = (request.data.get('key_blob') or '').strip()
|
||||
if not blob:
|
||||
return Response({'detail': 'key_blob is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate before storing, so a bad paste cannot displace a working licence.
|
||||
valid, payload, reason = licensing.verify_license(blob)
|
||||
if not valid:
|
||||
return Response({'detail': reason}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
record = License.objects.first() or License()
|
||||
record.key_blob = blob
|
||||
record.grace_started = None
|
||||
record.save()
|
||||
|
||||
return Response(licensing.license_status())
|
||||
|
||||
|
||||
class LicenseHeartbeatView(APIView):
|
||||
"""Stations report in here so the server can count them.
|
||||
|
||||
The bridge deliberately does not talk to Django - the browser, which already
|
||||
talks to both, reports which bridge it is using. That keeps the bridge pure
|
||||
transport with no startup dependency on the server.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
station_key = (request.data.get('station_key') or '').strip()
|
||||
if not station_key:
|
||||
return Response({'detail': 'station_key is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
limit = licensing.effective_station_limit()
|
||||
existing = StationSlot.objects.filter(station_key=station_key).first()
|
||||
live = _live_slots()
|
||||
|
||||
if existing is None and live.count() >= limit:
|
||||
return Response({
|
||||
'allowed': False,
|
||||
'stations_used': live.count(),
|
||||
'stations_licensed': limit,
|
||||
'detail': f'Licence limit reached ({limit} stations)',
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
slot, _ = StationSlot.objects.update_or_create(
|
||||
station_key=station_key,
|
||||
defaults={
|
||||
'bridge_version': request.data.get('bridge_version', '') or '',
|
||||
'com_port': request.data.get('com_port', '') or '',
|
||||
},
|
||||
)
|
||||
# update_or_create does not touch auto_now when nothing changed
|
||||
slot.save(update_fields=['last_seen'])
|
||||
|
||||
return Response({
|
||||
'allowed': True,
|
||||
'stations_used': _live_slots().count(),
|
||||
'stations_licensed': limit,
|
||||
})
|
||||
|
||||
|
||||
class ScaleProtocolViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for scale protocol definitions.
|
||||
|
||||
The frontend caches these and uses them to parse raw serial lines, so adding
|
||||
a scale type needs no change to the serial bridge running on station PCs.
|
||||
Built-in protocols cannot be deleted.
|
||||
"""
|
||||
queryset = ScaleProtocol.objects.all()
|
||||
serializer_class = ScaleProtocolSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
search_fields = ['name']
|
||||
ordering = ['name']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
sse_broadcast_update('scale_protocol', 'created', ScaleProtocolSerializer(instance).data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
sse_broadcast_update('scale_protocol', 'updated', ScaleProtocolSerializer(instance).data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.is_builtin:
|
||||
return Response({'detail': 'Built-in protocols cannot be deleted.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
data = ScaleProtocolSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('scale_protocol', 'deleted', data)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for nomenclature management.
|
||||
|
||||
Reference in New Issue
Block a user