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:
+102
-40
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from .models import ComPortReading, User, Report
|
||||
from vehicles.models import Vehicle, VehicleExtra
|
||||
from .models import ComPortReading, User, Report, ScaleProtocol
|
||||
from vehicles.models import Vehicle, VehicleExtra, ExtraCombination
|
||||
from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry
|
||||
|
||||
|
||||
@@ -76,6 +76,54 @@ class ComPortReadingSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ['id', 'timestamp']
|
||||
|
||||
|
||||
def validate_vehicle_extra_data(value, lookup_only=False):
|
||||
"""Validate a {nomenclature_code: value} dict against vehicle nomenclatures.
|
||||
|
||||
lookup_only=True rejects any key whose nomenclature is not kind='lookup'.
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
nomenclatures = {
|
||||
n.code: n for n in
|
||||
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries')
|
||||
}
|
||||
|
||||
errors = {}
|
||||
for key, val in value.items():
|
||||
if key not in nomenclatures:
|
||||
errors[key] = f"Unknown nomenclature code '{key}'."
|
||||
continue
|
||||
|
||||
nom = nomenclatures[key]
|
||||
|
||||
if nom.kind == Nomenclature.LOOKUP:
|
||||
if not isinstance(val, int):
|
||||
errors[key] = "Must be a nomenclature entry ID (integer)."
|
||||
elif not nom.entries.filter(id=val, is_active=True).exists():
|
||||
errors[key] = f"Entry ID {val} not found in '{nom.code}'."
|
||||
|
||||
elif nom.kind == Nomenclature.FIELD:
|
||||
if lookup_only:
|
||||
errors[key] = "Only lookup nomenclatures are allowed here."
|
||||
continue
|
||||
field_def = nom.fields.first()
|
||||
if field_def:
|
||||
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
|
||||
errors[key] = "Must be a string."
|
||||
elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)):
|
||||
errors[key] = "Must be a number."
|
||||
elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool):
|
||||
errors[key] = "Must be a boolean."
|
||||
elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []):
|
||||
errors[key] = f"Must be one of {field_def.choices}."
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class VehicleExtraSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VehicleExtra
|
||||
@@ -83,44 +131,7 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ['id']
|
||||
|
||||
def validate_data(self, value):
|
||||
if not value:
|
||||
return value
|
||||
|
||||
nomenclatures = {
|
||||
n.code: n for n in
|
||||
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries')
|
||||
}
|
||||
|
||||
errors = {}
|
||||
for key, val in value.items():
|
||||
if key not in nomenclatures:
|
||||
errors[key] = f"Unknown nomenclature code '{key}'."
|
||||
continue
|
||||
|
||||
nom = nomenclatures[key]
|
||||
|
||||
if nom.kind == Nomenclature.LOOKUP:
|
||||
if not isinstance(val, int):
|
||||
errors[key] = "Must be a nomenclature entry ID (integer)."
|
||||
elif not nom.entries.filter(id=val, is_active=True).exists():
|
||||
errors[key] = f"Entry ID {val} not found in '{nom.code}'."
|
||||
|
||||
elif nom.kind == Nomenclature.FIELD:
|
||||
field_def = nom.fields.first()
|
||||
if field_def:
|
||||
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
|
||||
errors[key] = "Must be a string."
|
||||
elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)):
|
||||
errors[key] = "Must be a number."
|
||||
elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool):
|
||||
errors[key] = "Must be a boolean."
|
||||
elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []):
|
||||
errors[key] = f"Must be one of {field_def.choices}."
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return value
|
||||
return validate_vehicle_extra_data(value)
|
||||
|
||||
|
||||
class VehicleSerializer(serializers.ModelSerializer):
|
||||
@@ -133,11 +144,15 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
model = Vehicle
|
||||
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid',
|
||||
'tare', 'tare_date', 'tare_user', 'tare_user_name',
|
||||
'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note',
|
||||
'gross', 'gross_date', 'gross_user', 'gross_user_name',
|
||||
'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
|
||||
'net', 'net_date', 'net_user', 'net_user_name',
|
||||
'doc_number', 'extra']
|
||||
read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name',
|
||||
'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note',
|
||||
'gross_date', 'gross_user', 'gross_user_name',
|
||||
'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
|
||||
'net', 'net_date', 'net_user', 'net_user_name',
|
||||
'doc_number']
|
||||
|
||||
@@ -174,6 +189,53 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
return value.strip()
|
||||
|
||||
|
||||
class ExtraCombinationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ExtraCombination
|
||||
fields = ['id', 'data', 'use_count', 'last_used']
|
||||
read_only_fields = ['id', 'use_count', 'last_used']
|
||||
|
||||
def validate_data(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("Combination cannot be empty.")
|
||||
return validate_vehicle_extra_data(value, lookup_only=True)
|
||||
|
||||
|
||||
class ScaleProtocolSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScaleProtocol
|
||||
fields = ['id', 'name', 'kind', 'poll_command', 'poll_interval',
|
||||
'config', 'is_builtin', 'is_active']
|
||||
read_only_fields = ['id', 'is_builtin']
|
||||
|
||||
def validate_poll_interval(self, value):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Poll interval must be greater than zero.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
kind = attrs.get('kind', getattr(self.instance, 'kind', None))
|
||||
config = attrs.get('config', getattr(self.instance, 'config', None) or {})
|
||||
|
||||
if kind == ScaleProtocol.FIXED_COLUMN:
|
||||
# A misconfigured offset silently clips a digit off every weight, so
|
||||
# reject anything unusable rather than storing it.
|
||||
errors = {}
|
||||
for key in ('weight_start', 'weight_length', 'stable_pos'):
|
||||
value = config.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
errors[key] = "Must be a non-negative integer."
|
||||
if not isinstance(config.get('weight_length'), bool) and isinstance(config.get('weight_length'), int) and config['weight_length'] < 1:
|
||||
errors['weight_length'] = "Must be at least 1."
|
||||
stable_char = config.get('stable_char', ' ')
|
||||
if not isinstance(stable_char, str) or len(stable_char) != 1:
|
||||
errors['stable_char'] = "Must be exactly one character."
|
||||
if errors:
|
||||
raise serializers.ValidationError({'config': errors})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class NomenclatureFieldSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NomenclatureField
|
||||
|
||||
Reference in New Issue
Block a user