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:
2026-09-18 18:57:29 +03:00
parent 183908cbab
commit 4b6138f049
12 changed files with 1071 additions and 77 deletions
+305
View File
@@ -0,0 +1,305 @@
"""Offline node-locked licensing.
Customer sites are air-gapped, so there is no activation server. The flow is:
1. This machine reports a fingerprint built from several hardware components
2. The customer sends that string to the vendor
3. The vendor SIGNS it with their Ed25519 private key
4. The customer pastes the result back; this module VERIFIES it
The critical property is that this code can only ever verify, never generate.
The private key never leaves the vendor's machine, so unpacking the application
does not let anyone mint licences. A shared-secret scheme would be worthless.
See .claude/plans/licensing.md for the threat model: this stops casual copying
and gives a clear contractual line. It does not stop a determined attacker, and
is not intended to.
"""
import base64
import hashlib
import json
import platform
import subprocess
from datetime import timedelta
from django.utils import timezone
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
# Vendor's Ed25519 public key (base64 raw, 32 bytes). The matching PRIVATE key
# lives only on the vendor's machine and must never enter this repository.
#
# Replace this placeholder with the real public key before shipping. While it is
# empty, no licence can validate and the system runs in grace mode.
LICENSE_PUBLIC_KEY = 'HAbweykY+P9jdb81BpfiGVXwM2BIgkh9tBjSHq7RN+I='
# How long an unlicensed or newly-invalid install keeps working normally. A
# licence check must never stop a weighbridge without warning, so a hardware
# failure becomes a support call rather than an outage.
GRACE_DAYS = 14
# Station limit once grace has expired.
UNLICENSED_STATIONS = 1
# A slot not refreshed within this window is released. The frontend refreshes
# every 30s, so a station must miss four in a row - a brief hiccup is harmless.
SLOT_TIMEOUT_SECONDS = 120
# Hardware components must match at least this many of four, so replacing a disk
# or a network card does not invalidate an otherwise identical machine.
MIN_COMPONENT_MATCHES = 3
def _run(command):
"""Run a shell command and return stripped stdout, or None on any failure."""
try:
out = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=10
)
if out.returncode != 0:
return None
value = out.stdout.strip()
return value or None
except Exception:
return None
def _hash(value):
if not value:
return None
return hashlib.sha256(str(value).strip().encode('utf-8')).hexdigest()[:16]
def _powershell(script):
"""Run a PowerShell snippet and return its stripped output, or None."""
return _run(
'powershell -NoProfile -NonInteractive -Command "' + script.replace('"', '\\"') + '"'
)
def _windows_components():
"""Hardware identifiers via PowerShell CIM.
Uses PowerShell rather than wmic: wmic is deprecated and has been REMOVED
from recent Windows 11 builds, where it silently yields nothing. Each lookup
falls back to wmic so older machines still work.
"""
parts = {}
board = _powershell('(Get-CimInstance Win32_BaseBoard).SerialNumber')
if not board:
out = _run('wmic baseboard get serialnumber')
if out:
lines = [l.strip() for l in out.splitlines() if l.strip()]
board = lines[1] if len(lines) > 1 else None
if board and board.lower() not in ('none', 'default string', 'to be filled by o.e.m.'):
parts['board'] = board
disk = _powershell(
'(Get-CimInstance Win32_DiskDrive | Select-Object -First 1).SerialNumber'
)
if not disk:
out = _run('wmic diskdrive get serialnumber')
if out:
lines = [l.strip() for l in out.splitlines() if l.strip()]
disk = lines[1] if len(lines) > 1 else None
if disk:
parts['disk'] = disk
mac = _run('getmac /fo csv /nh')
if mac:
first = mac.splitlines()[0].split(',')[0].strip().strip('"')
if first and first != 'N/A':
parts['mac'] = first
guid = _powershell(
r"(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography').MachineGuid"
)
if not guid:
out = _run(r'reg query "HKLM\SOFTWARE\Microsoft\Cryptography" /v MachineGuid')
if out and 'MachineGuid' in out:
guid = out.split()[-1]
if guid:
parts['guid'] = guid
return parts
def _linux_components():
parts = {}
board = _run('cat /sys/class/dmi/id/board_serial 2>/dev/null')
if board:
parts['board'] = board
disk = _run("lsblk -dno SERIAL 2>/dev/null | head -1")
if disk:
parts['disk'] = disk
mac = _run(
"cat /sys/class/net/$(ls /sys/class/net | grep -v lo | head -1)/address 2>/dev/null"
)
if mac:
parts['mac'] = mac
guid = _run('cat /etc/machine-id 2>/dev/null')
if guid:
parts['guid'] = guid
return parts
def machine_components():
"""Hashed hardware identifiers for this machine.
Missing components come back as None rather than raising - a virtual machine
or restricted environment may not expose all of them, and the 3-of-4 rule
tolerates that as long as enough are present.
"""
raw = _windows_components() if platform.system() == 'Windows' else _linux_components()
return {key: _hash(raw.get(key)) for key in ('board', 'disk', 'mac', 'guid')}
def machine_fingerprint():
"""Short human-readable fingerprint, for the customer to send to the vendor.
Read aloud over the phone or copied from the admin screen, so it is grouped
and uppercase with no ambiguous characters beyond hex.
"""
components = machine_components()
present = [v for v in components.values() if v]
if not present:
return 'UNAVAILABLE'
digest = hashlib.sha256('|'.join(sorted(present)).encode('utf-8')).hexdigest()
body = digest[:16].upper()
return '-'.join(body[i:i + 4] for i in range(0, 16, 4))
def count_matching_components(licensed):
"""How many of the licensed component hashes still match this machine."""
current = machine_components()
matches = 0
for key in ('board', 'disk', 'mac', 'guid'):
want = (licensed or {}).get(key)
if want and current.get(key) == want:
matches += 1
return matches
def verify_license(blob):
"""Verify a signed licence blob.
Returns (valid, payload, reason). `payload` is returned even when the
signature is valid but the machine does not match, so callers can report
which machine the licence was actually issued for.
Blob format: base64("<payload json>.<signature>")
"""
if not blob:
return False, None, 'No licence installed'
if not LICENSE_PUBLIC_KEY:
return False, None, 'No vendor public key compiled in'
try:
decoded = base64.b64decode(blob.strip()).decode('utf-8')
payload_json, signature_b64 = decoded.rsplit('.', 1)
payload = json.loads(payload_json)
signature = base64.b64decode(signature_b64)
except Exception:
return False, None, 'Licence is malformed'
try:
public_key = Ed25519PublicKey.from_public_bytes(
base64.b64decode(LICENSE_PUBLIC_KEY)
)
public_key.verify(signature, payload_json.encode('utf-8'))
except InvalidSignature:
return False, None, 'Licence signature is not valid'
except Exception:
return False, None, 'Licence could not be verified'
matches = count_matching_components(payload.get('components'))
if matches < MIN_COMPONENT_MATCHES:
return False, payload, (
f'Licence was issued for a different machine '
f'({matches} of 4 hardware components match, {MIN_COMPONENT_MATCHES} required)'
)
# Perpetual licences have no expiry; the field exists so subscriptions can be
# added later without changing the signed format.
expires = payload.get('expires')
if expires:
try:
if timezone.now() > timezone.datetime.fromisoformat(expires):
return False, payload, f'Licence expired on {expires}'
except Exception:
return False, payload, 'Licence expiry date is malformed'
return True, payload, None
def license_status():
"""Current licence state, including how many stations may connect.
Falls back to a grace period so an unlicensed or newly-invalid install keeps
working while the customer obtains a key.
"""
from .models import License
record = License.objects.first()
blob = record.key_blob if record else ''
valid, payload, reason = verify_license(blob)
if valid:
return {
'valid': True,
'customer': payload.get('customer', ''),
'stations': int(payload.get('stations', 1)),
'reason': None,
'in_grace': False,
'grace_days_left': None,
'fingerprint': machine_fingerprint(),
}
# Invalid or absent - start (or continue) the grace period.
now = timezone.now()
if record is None:
grace_started = None
else:
if record.grace_started is None:
record.grace_started = now
record.save(update_fields=['grace_started'])
grace_started = record.grace_started
if grace_started is None:
# Nothing installed at all: grace runs from first use, tracked by the
# placeholder row the status endpoint creates.
grace_started = now
days_used = (now - grace_started).days
days_left = max(0, GRACE_DAYS - days_used)
in_grace = days_left > 0
licensed_stations = int(payload.get('stations', 1)) if payload else 1
return {
'valid': False,
'customer': payload.get('customer', '') if payload else '',
'stations': licensed_stations if in_grace else UNLICENSED_STATIONS,
'reason': reason,
'in_grace': in_grace,
'grace_days_left': days_left,
'fingerprint': machine_fingerprint(),
}
def effective_station_limit():
return license_status()['stations']
def live_slot_cutoff():
return timezone.now() - timedelta(seconds=SLOT_TIMEOUT_SECONDS)
@@ -0,0 +1,52 @@
# Generated by Django 6.0.1 on 2026-09-17 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0005_grant_admin_permissions"),
]
operations = [
migrations.CreateModel(
name="ScaleProtocol",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, unique=True)),
(
"kind",
models.CharField(
choices=[
("fixed_column", "Fixed column"),
("simulator", "Simulator (ST/US)"),
("toledo_continuous", "Toledo Continuous"),
("sics", "Mettler-Toledo SICS"),
],
default="fixed_column",
max_length=20,
),
),
(
"poll_command",
models.CharField(blank=True, default="", max_length=20),
),
("poll_interval", models.FloatField(default=0.2)),
("config", models.JSONField(blank=True, default=dict)),
("is_builtin", models.BooleanField(default=False)),
("is_active", models.BooleanField(default=True)),
],
options={
"ordering": ["name"],
},
),
]
@@ -0,0 +1,69 @@
from django.db import migrations
# Offsets are 0-based. The %p scale's original Delphi used 1-based Copy(s, 5, 8),
# i.e. the 5th character onward - which is index 4 here.
PROTOCOLS = [
{
'name': 'Simulator (ST/US)',
'kind': 'simulator',
'poll_command': '',
'poll_interval': 0.2,
'config': {},
},
{
'name': 'Fixed column 22-char (%p)',
'kind': 'fixed_column',
'poll_command': '%p',
'poll_interval': 0.2,
'config': {
'weight_start': 4,
'weight_length': 8,
'stable_pos': 21,
'stable_char': ' ',
'stable_means_balanced': True,
'decimals': 0,
},
},
{
'name': 'Toledo Continuous',
'kind': 'toledo_continuous',
'poll_command': '',
'poll_interval': 0.2,
'config': {},
},
{
'name': 'Mettler-Toledo SICS',
'kind': 'sics',
'poll_command': 'SI',
'poll_interval': 0.2,
'config': {},
},
]
def seed(apps, schema_editor):
ScaleProtocol = apps.get_model('api', 'ScaleProtocol')
for entry in PROTOCOLS:
ScaleProtocol.objects.update_or_create(
name=entry['name'],
defaults=dict(entry, is_builtin=True, is_active=True),
)
def unseed(apps, schema_editor):
ScaleProtocol = apps.get_model('api', 'ScaleProtocol')
ScaleProtocol.objects.filter(
name__in=[e['name'] for e in PROTOCOLS], is_builtin=True
).delete()
class Migration(migrations.Migration):
dependencies = [
('api', '0006_scaleprotocol'),
]
operations = [
migrations.RunPython(seed, unseed),
]
@@ -0,0 +1,55 @@
# Generated by Django 6.0.1 on 2026-09-17 16:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0007_seed_scale_protocols"),
]
operations = [
migrations.CreateModel(
name="License",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key_blob", models.TextField(blank=True, default="")),
("installed_at", models.DateTimeField(auto_now=True)),
("grace_started", models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name="StationSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("station_key", models.CharField(max_length=64, unique=True)),
(
"bridge_version",
models.CharField(blank=True, default="", max_length=20),
),
("com_port", models.CharField(blank=True, default="", max_length=20)),
("first_seen", models.DateTimeField(auto_now_add=True)),
("last_seen", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["first_seen"],
},
),
]
+74
View File
@@ -63,6 +63,80 @@ class Report(models.Model):
return self.name return self.name
class ScaleProtocol(models.Model):
"""How to read a particular scale indicator.
Definitions live here rather than in the serial bridge so a new scale type
is a configuration change, not a new binary on every station PC. The bridge
only moves bytes; the frontend parses them using these rules.
"""
FIXED_COLUMN = 'fixed_column'
SIMULATOR = 'simulator'
TOLEDO_CONTINUOUS = 'toledo_continuous'
SICS = 'sics'
KIND_CHOICES = [
(FIXED_COLUMN, 'Fixed column'),
(SIMULATOR, 'Simulator (ST/US)'),
(TOLEDO_CONTINUOUS, 'Toledo Continuous'),
(SICS, 'Mettler-Toledo SICS'),
]
name = models.CharField(max_length=100, unique=True)
kind = models.CharField(max_length=20, choices=KIND_CHOICES, default=FIXED_COLUMN)
# Empty poll_command means the scale streams on its own (continuous mode).
poll_command = models.CharField(max_length=20, blank=True, default='')
poll_interval = models.FloatField(default=0.2)
# Kind-specific parameters. For fixed_column: weight_start, weight_length,
# stable_pos, stable_char, stable_means_balanced, decimals. Offsets are 0-based.
config = models.JSONField(default=dict, blank=True)
is_builtin = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class License(models.Model):
"""The installed licence. Single row - enforced by save()."""
key_blob = models.TextField(blank=True, default='')
installed_at = models.DateTimeField(auto_now=True)
# Grace period start, stored here rather than in a file so it cannot be reset
# by deleting something on disk, and is included in database backups.
grace_started = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
if not self.pk and License.objects.exists():
self.pk = License.objects.first().pk
super().save(*args, **kwargs)
def __str__(self):
return 'License' if self.key_blob else 'License (none installed)'
class StationSlot(models.Model):
"""A station that has recently reported in.
Slots are counted against the licence limit. The frontend refreshes every
30s and a slot is released after SLOT_TIMEOUT_SECONDS, so switching off a
station PC frees its slot automatically.
"""
station_key = models.CharField(max_length=64, unique=True)
bridge_version = models.CharField(max_length=20, blank=True, default='')
com_port = models.CharField(max_length=20, blank=True, default='')
first_seen = models.DateTimeField(auto_now_add=True)
last_seen = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['first_seen']
def __str__(self):
return f"{self.station_key} ({self.com_port})"
class DocumentCounter(models.Model): class DocumentCounter(models.Model):
year = models.IntegerField(unique=True) year = models.IntegerField(unique=True)
last_number = models.IntegerField(default=0) last_number = models.IntegerField(default=0)
+102 -40
View File
@@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from .models import ComPortReading, User, Report from .models import ComPortReading, User, Report, ScaleProtocol
from vehicles.models import Vehicle, VehicleExtra from vehicles.models import Vehicle, VehicleExtra, ExtraCombination
from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry
@@ -76,6 +76,54 @@ class ComPortReadingSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'timestamp'] 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 VehicleExtraSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = VehicleExtra model = VehicleExtra
@@ -83,44 +131,7 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
read_only_fields = ['id'] read_only_fields = ['id']
def validate_data(self, value): def validate_data(self, value):
if not value: return validate_vehicle_extra_data(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
class VehicleSerializer(serializers.ModelSerializer): class VehicleSerializer(serializers.ModelSerializer):
@@ -133,11 +144,15 @@ class VehicleSerializer(serializers.ModelSerializer):
model = Vehicle model = Vehicle
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid',
'tare', 'tare_date', 'tare_user', 'tare_user_name', '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', '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', 'net', 'net_date', 'net_user', 'net_user_name',
'doc_number', 'extra'] 'doc_number', 'extra']
read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name', 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_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', 'net', 'net_date', 'net_user', 'net_user_name',
'doc_number'] 'doc_number']
@@ -174,6 +189,53 @@ class VehicleSerializer(serializers.ModelSerializer):
return value.strip() 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 NomenclatureFieldSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = NomenclatureField model = NomenclatureField
+6
View File
@@ -12,9 +12,15 @@ router.register(r'readings', views.ComPortReadingViewSet, basename='reading')
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle') router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature') router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
router.register(r'nomenclature-entries', views.NomenclatureEntryViewSet, basename='nomenclature-entry') router.register(r'nomenclature-entries', views.NomenclatureEntryViewSet, basename='nomenclature-entry')
router.register(r'extra-combinations', views.ExtraCombinationViewSet, basename='extra-combination')
router.register(r'scale-protocols', views.ScaleProtocolViewSet, basename='scale-protocol')
router.register(r'reports', views.ReportViewSet, basename='report') router.register(r'reports', views.ReportViewSet, basename='report')
urlpatterns = [ urlpatterns = [
# Licensing
path('license/', views.LicenseView.as_view(), name='license'),
path('license/heartbeat/', views.LicenseHeartbeatView.as_view(), name='license-heartbeat'),
# JWT token endpoints # JWT token endpoints
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
+274 -35
View File
@@ -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.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated, AllowAny 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 ( from .serializers import (
ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
NomenclatureEntrySerializer, ReportSerializer, NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
ScaleProtocolSerializer,
) )
from django.utils import timezone 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 nomenclatures.models import Nomenclature, NomenclatureEntry
from scalesapp.sse import sse_broadcast_update from scalesapp.sse import sse_broadcast_update
from . import licensing
class IsAdminUser(IsAuthenticated): class IsAdminUser(IsAuthenticated):
@@ -137,6 +141,77 @@ def _maybe_finalize(vehicle, user):
vehicle.save() 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): class VehicleViewSet(viewsets.ModelViewSet):
""" """
API endpoint for vehicle management. API endpoint for vehicle management.
@@ -148,8 +223,7 @@ class VehicleViewSet(viewsets.ModelViewSet):
partial_update: Partial update of vehicle partial_update: Partial update of vehicle
destroy: Delete a vehicle (cascades to VehicleExtra) destroy: Delete a vehicle (cascades to VehicleExtra)
by_number: Get vehicle by vehicle_number by_number: Get vehicle by vehicle_number
set_tare: Set tare weight from scale reading measure: Record a weighing (app assigns tare vs gross by weight)
set_gross: Set gross weight from scale reading
""" """
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all() queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
serializer_class = VehicleSerializer serializer_class = VehicleSerializer
@@ -162,11 +236,13 @@ class VehicleViewSet(viewsets.ModelViewSet):
instance = serializer.save() instance = serializer.save()
data = VehicleSerializer(instance).data data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'created', data) sse_broadcast_update('vehicle', 'created', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_update(self, serializer): def perform_update(self, serializer):
instance = serializer.save() instance = serializer.save()
data = VehicleSerializer(instance).data data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'updated', data) sse_broadcast_update('vehicle', 'updated', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_destroy(self, instance): def perform_destroy(self, instance):
data = VehicleSerializer(instance).data data = VehicleSerializer(instance).data
@@ -193,10 +269,33 @@ class VehicleViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
@action(detail=True, methods=['post'], url_path='set-tare') @action(detail=True, methods=['post'], url_path='measure')
def set_tare(self, request, pk=None): def measure(self, request, pk=None):
"""Set tare weight from scale reading""" """Record a weighing. The app assigns tare vs gross by weight."""
vehicle = self.get_object() 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') value = request.data.get('value')
if value is None: if value is None:
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
@@ -205,39 +304,179 @@ class VehicleViewSet(viewsets.ModelViewSet):
except (ValueError, TypeError): except (ValueError, TypeError):
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST) 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() 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: try:
value = int(value) _assign_weighing(vehicle, value, request.user, manual=manual, note=note or None)
except (ValueError, TypeError): except ValueError as exc:
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(exc)}, 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)
serializer = self.get_serializer(vehicle) serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'updated', serializer.data) sse_broadcast_update('vehicle', 'updated', serializer.data)
return Response(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): class NomenclatureViewSet(viewsets.ModelViewSet):
""" """
API endpoint for nomenclature management. API endpoint for nomenclature management.
+1
View File
@@ -9,3 +9,4 @@ psycopg==3.3.2
psycopg-binary==3.3.2 psycopg-binary==3.3.2
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
whitenoise==6.9.0 whitenoise==6.9.0
cryptography==50.0.1
@@ -0,0 +1,40 @@
# Generated by Django 6.0.1 on 2026-09-17 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"vehicles",
"0004_vehicle_doc_number_vehicle_gross_manual_vehicle_net_and_more",
),
]
operations = [
migrations.CreateModel(
name="ExtraCombination",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("data", models.JSONField(default=dict)),
(
"data_hash",
models.CharField(editable=False, max_length=64, unique=True),
),
("use_count", models.IntegerField(default=1)),
("last_used", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["-last_used"],
},
),
]
@@ -0,0 +1,58 @@
# Generated by Django 6.0.1 on 2026-09-17 14:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("vehicles", "0005_extracombination"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="vehicle",
name="gross_manual_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="vehicle",
name="gross_manual_note",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="vehicle",
name="gross_manual_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_vehicle_gross_manual",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="vehicle",
name="tare_manual_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="vehicle",
name="tare_manual_note",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="vehicle",
name="tare_manual_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_vehicle_tare_manual",
to=settings.AUTH_USER_MODEL,
),
),
]
+33
View File
@@ -1,3 +1,6 @@
import hashlib
import json
from typing import Required from typing import Required
from django.db import models from django.db import models
@@ -14,11 +17,17 @@ class Vehicle(models.Model):
tare_date = models.DateTimeField(null=True, blank=True) tare_date = models.DateTimeField(null=True, blank=True)
tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL) tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL)
tare_manual = models.BooleanField(default=False) tare_manual = models.BooleanField(default=False)
tare_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare_manual', on_delete=models.SET_NULL)
tare_manual_date = models.DateTimeField(null=True, blank=True)
tare_manual_note = models.CharField(max_length=255, null=True, blank=True)
gross = models.IntegerField(null=True, blank=True) gross = models.IntegerField(null=True, blank=True)
gross_date = models.DateTimeField(null=True, blank=True) gross_date = models.DateTimeField(null=True, blank=True)
gross_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross', on_delete=models.SET_NULL) gross_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross', on_delete=models.SET_NULL)
gross_manual = models.BooleanField(default=False) gross_manual = models.BooleanField(default=False)
gross_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross_manual', on_delete=models.SET_NULL)
gross_manual_date = models.DateTimeField(null=True, blank=True)
gross_manual_note = models.CharField(max_length=255, null=True, blank=True)
net = models.IntegerField(null=True, blank=True) net = models.IntegerField(null=True, blank=True)
net_date = models.DateTimeField(null=True, blank=True) net_date = models.DateTimeField(null=True, blank=True)
@@ -36,3 +45,27 @@ class VehicleExtra(models.Model):
Vehicle, on_delete=models.CASCADE, related_name="extra" Vehicle, on_delete=models.CASCADE, related_name="extra"
) )
data = models.JSONField(default=dict) data = models.JSONField(default=dict)
def combination_hash(data):
"""Order-independent canonical hash of a {nomenclature_code: entry_id} dict."""
canonical = json.dumps(data, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
class ExtraCombination(models.Model):
"""A previously-used combination of lookup nomenclature values for a vehicle's extra data."""
data = models.JSONField(default=dict)
data_hash = models.CharField(max_length=64, unique=True, editable=False)
use_count = models.IntegerField(default=1)
last_used = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-last_used"]
def save(self, *args, **kwargs):
self.data_hash = combination_hash(self.data)
super().save(*args, **kwargs)
def __str__(self):
return f"{self.data} (x{self.use_count})"