diff --git a/backend/api/licensing.py b/backend/api/licensing.py new file mode 100644 index 0000000..73b5c4a --- /dev/null +++ b/backend/api/licensing.py @@ -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(".") + """ + 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) diff --git a/backend/api/migrations/0006_scaleprotocol.py b/backend/api/migrations/0006_scaleprotocol.py new file mode 100644 index 0000000..8864488 --- /dev/null +++ b/backend/api/migrations/0006_scaleprotocol.py @@ -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"], + }, + ), + ] diff --git a/backend/api/migrations/0007_seed_scale_protocols.py b/backend/api/migrations/0007_seed_scale_protocols.py new file mode 100644 index 0000000..966b0dd --- /dev/null +++ b/backend/api/migrations/0007_seed_scale_protocols.py @@ -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), + ] diff --git a/backend/api/migrations/0008_license_stationslot.py b/backend/api/migrations/0008_license_stationslot.py new file mode 100644 index 0000000..48963f5 --- /dev/null +++ b/backend/api/migrations/0008_license_stationslot.py @@ -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"], + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 94b1f9d..4cb9568 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -63,6 +63,80 @@ class Report(models.Model): 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): year = models.IntegerField(unique=True) last_number = models.IntegerField(default=0) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index f5dd6c9..cc619bf 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -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 diff --git a/backend/api/urls.py b/backend/api/urls.py index 01822f3..14eb1be 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -12,9 +12,15 @@ router.register(r'readings', views.ComPortReadingViewSet, basename='reading') router.register(r'vehicles', views.VehicleViewSet, basename='vehicle') router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature') 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') urlpatterns = [ + # Licensing + path('license/', views.LicenseView.as_view(), name='license'), + path('license/heartbeat/', views.LicenseHeartbeatView.as_view(), name='license-heartbeat'), + # JWT token endpoints path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), diff --git a/backend/api/views.py b/backend/api/views.py index 149d1b2..43281d2 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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. diff --git a/backend/requirements.txt b/backend/requirements.txt index 2a12356..1e09dcb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,5 @@ sqlparse==0.5.3 psycopg==3.3.2 psycopg-binary==3.3.2 uvicorn[standard]==0.34.0 -whitenoise==6.9.0 \ No newline at end of file +whitenoise==6.9.0 +cryptography==50.0.1 diff --git a/backend/vehicles/migrations/0005_extracombination.py b/backend/vehicles/migrations/0005_extracombination.py new file mode 100644 index 0000000..6cac7e5 --- /dev/null +++ b/backend/vehicles/migrations/0005_extracombination.py @@ -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"], + }, + ), + ] diff --git a/backend/vehicles/migrations/0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more.py b/backend/vehicles/migrations/0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more.py new file mode 100644 index 0000000..a8dd28c --- /dev/null +++ b/backend/vehicles/migrations/0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more.py @@ -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, + ), + ), + ] diff --git a/backend/vehicles/models.py b/backend/vehicles/models.py index 5102c72..7e77445 100644 --- a/backend/vehicles/models.py +++ b/backend/vehicles/models.py @@ -1,3 +1,6 @@ +import hashlib +import json + from typing import Required from django.db import models @@ -14,11 +17,17 @@ class Vehicle(models.Model): 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_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_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_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_date = models.DateTimeField(null=True, blank=True) @@ -35,4 +44,28 @@ class VehicleExtra(models.Model): vehicle = models.OneToOneField( Vehicle, on_delete=models.CASCADE, related_name="extra" ) - data = models.JSONField(default=dict) \ No newline at end of file + 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})" \ No newline at end of file