Files
scalesApp/backend/api/models.py
T
gitea 4b6138f049 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>
2026-09-18 18:57:29 +03:00

153 lines
5.2 KiB
Python

from django.db import models, transaction
from django.contrib.auth.models import AbstractUser
from django.utils import timezone
class User(AbstractUser):
"""Custom User model with role and admin flag"""
ROLE_CHOICES = [
('employee', 'Employee'),
('viewer', 'Viewer'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
is_admin = models.BooleanField(default=False)
can_measure = models.BooleanField(default=False)
can_manually_measure = models.BooleanField(default=False)
can_manage_entities = models.BooleanField(default=False)
can_edit_documents = models.BooleanField(default=False)
class Meta:
db_table = 'api_user'
def __str__(self):
return f"{self.username} ({self.get_role_display()})"
class ComPortReading(models.Model):
"""Model to store serial port readings"""
port = models.CharField(max_length=20)
data = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
source_ip = models.GenericIPAddressField(null=True, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['-timestamp']),
models.Index(fields=['port', '-timestamp']),
]
def __str__(self):
return f"{self.port} - {self.timestamp}"
class Report(models.Model):
"""Model to store report templates for the report editor"""
name = models.CharField(max_length=255)
page_width = models.IntegerField(default=80)
page_height = models.IntegerField(default=66)
api_endpoint = models.CharField(max_length=500, blank=True, default='')
elements = models.JSONField(default=list)
created_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='reports'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
def __str__(self):
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)
def generate_doc_number(year):
with transaction.atomic():
counter, _ = DocumentCounter.objects.select_for_update().get_or_create(
year=year, defaults={'last_number': 0}
)
counter.last_number += 1
counter.save()
return f"{year}-{counter.last_number:06d}"