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""" MATRIX = 'matrix' A4 = 'a4' KIND_CHOICES = [ (MATRIX, 'Character matrix'), (A4, 'A4 free positioning'), ] name = models.CharField(max_length=255) kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX) 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) # mm page setup for kind='a4': page, orientation, margins{top,right,bottom,left}, # defaultFont, defaultFontSize page_setup = models.JSONField(default=dict, blank=True) 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}"