from django.db import models, transaction from django.core.validators import MinValueValidator, MaxValueValidator from django_softdelete.models import SoftDeleteModel from rest_framework.exceptions import ValidationError from authentication.models import ToolshedUser, KnownIdentity, Group from files.models import File class Category(SoftDeleteModel): name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, related_name='children') origin = models.CharField(max_length=255, null=False, blank=False) class Meta: verbose_name_plural = 'categories' constraints = [ models.UniqueConstraint(fields=['name', 'parent'], condition=models.Q(parent__isnull=False), name='category_unique_name_parent'), models.UniqueConstraint(fields=['name'], condition=models.Q(parent__isnull=True), name='category_unique_name_no_parent') ] def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name def get_handle(self): """Return a fully qualified handle like 'git:base#category:tools'""" return f"{self.origin}#category:{self.name}" class Property(models.Model): name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='properties') unit_symbol = models.CharField(max_length=16, null=True, blank=True) unit_name = models.CharField(max_length=255, null=True, blank=True) unit_name_plural = models.CharField(max_length=255, null=True, blank=True) base2_prefix = models.BooleanField(default=False) dimensions = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)]) origin = models.CharField(max_length=255, null=False, blank=False) class Meta: verbose_name_plural = 'properties' constraints = [ models.UniqueConstraint(fields=['name', 'category'], condition=models.Q(category__isnull=False), name='property_unique_name_category'), models.UniqueConstraint(fields=['name'], condition=models.Q(category__isnull=True), name='property_unique_name_no_category') ] def __str__(self): return self.name def get_handle(self): """Return a fully qualified handle like 'git:base#property:length'""" return f"{self.origin}#property:{self.name}" class Tag(models.Model): name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='tags') origin = models.CharField(max_length=255, null=False, blank=False) class Meta: verbose_name_plural = 'tags' constraints = [ models.UniqueConstraint(fields=['name', 'category'], condition=models.Q(category__isnull=False), name='tag_unique_name_category'), models.UniqueConstraint(fields=['name'], condition=models.Q(category__isnull=True), name='tag_unique_name_no_category') ] def __str__(self): return self.name def get_handle(self): """Return a fully qualified handle like 'git:tools#tag:drill'""" return f"{self.origin}#tag:{self.name}" class OwnerItemSequence(models.Model): """Tracks the last InventoryItem id handed out per owner/owner_group scope for sequential, gapless allocation (see InventoryItem.create_for_owner); exactly one of owner/owner_group is set, mirroring InventoryItem's own split.""" owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+') owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+') last_id = models.PositiveIntegerField(default=0) class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], condition=models.Q(owner__isnull=False), name='owneritemsequence_unique_owner'), models.UniqueConstraint(fields=['owner_group'], condition=models.Q(owner_group__isnull=False), name='owneritemsequence_unique_owner_group'), ] @classmethod def allocate(cls, *, owner=None, owner_group=None): with transaction.atomic(): seq, _ = cls.objects.select_for_update().get_or_create(owner=owner, owner_group=owner_group) seq.last_id += 1 seq.save(update_fields=['last_id']) return seq.last_id VISIBILITY_POLICY_CHOICES = ( ('public', 'Public'), ('friends', 'Friends'), ('private', 'Private'), ) class InventoryItem(SoftDeleteModel): AVAILABILITY_POLICY_CHOICES = ( ('sell', 'Sell'), ('rent', 'Rent'), ('lend', 'Lend'), ('share', 'Share'), ('private', 'Private'), ) VISIBILITY_POLICY_CHOICES = VISIBILITY_POLICY_CHOICES internal_id = models.AutoField(primary_key=True) # Externally visible id, sequential/gapless within owner/owner_group's own items (see # OwnerItemSequence), never internal_id; always allocate via create_for_owner, not .objects.create(). id = models.PositiveIntegerField(editable=False) published = models.BooleanField(default=False) name = models.CharField(max_length=255, null=True, blank=True) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items') availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private') visibility_policy = models.CharField(max_length=20, choices=VISIBILITY_POLICY_CHOICES, default='private') owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)]) owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='inventory_items') owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='inventory_items') created_at = models.DateTimeField(auto_now_add=True) tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items') properties = models.ManyToManyField(Property, through='ItemProperty') files = models.ManyToManyField(File, related_name='connected_items') storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True, related_name='inventory_items') class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'owner_group', 'id'], name='inventoryitem_unique_owner_scoped_id'), ] def clean(self): if (self.name is None or self.name == "") and self.files.count() == 0: raise ValidationError("Name or at least one file must be set") if (self.owner is None) == (self.owner_group is None): raise ValidationError("Exactly one of owner or owner_group must be set") @classmethod def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs): """The only supported way to create an InventoryItem: atomically allocates the next id for this owner/owner_group scope.""" with transaction.atomic(): next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group) return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs) class ItemProperty(models.Model): property = models.ForeignKey(Property, on_delete=models.CASCADE) inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) value = models.CharField(max_length=255) class ItemTag(models.Model): tag = models.ForeignKey(Tag, on_delete=models.CASCADE) inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) class OwnerStorageLocationSequence(models.Model): """Tracks the last StorageLocation id handed out per owner/owner_group scope for sequential, gapless allocation (see StorageLocation.create_for_owner); exactly one of owner/owner_group is set, mirroring OwnerItemSequence/InventoryItem's own split.""" owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+') owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+') last_id = models.PositiveIntegerField(default=0) class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], condition=models.Q(owner__isnull=False), name='ownerstoragelocationsequence_unique_owner'), models.UniqueConstraint(fields=['owner_group'], condition=models.Q(owner_group__isnull=False), name='ownerstoragelocationsequence_unique_owner_group'), ] @classmethod def allocate(cls, *, owner=None, owner_group=None): with transaction.atomic(): seq, _ = cls.objects.select_for_update().get_or_create(owner=owner, owner_group=owner_group) seq.last_id += 1 seq.save(update_fields=['last_id']) return seq.last_id class StorageLocation(models.Model): VISIBILITY_POLICY_CHOICES = VISIBILITY_POLICY_CHOICES internal_id = models.AutoField(primary_key=True) # Externally visible id, sequential/gapless within the owner/owner_group's own locations (see # OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not # .objects.create(). id = models.PositiveIntegerField(editable=False) name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='storage_locations') parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='storage_locations') owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='storage_locations') visibility_policy = models.CharField(max_length=20, choices=VISIBILITY_POLICY_CHOICES, default='private') class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'owner_group', 'id'], name='storagelocation_unique_owner_scoped_id'), ] def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name def clean(self): if (self.owner is None) == (self.owner_group is None): raise ValidationError("Exactly one of owner or owner_group must be set") @classmethod def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs): """The only supported way to create a StorageLocation: atomically allocates the next id for this owner/owner_group's scope.""" with transaction.atomic(): next_id = OwnerStorageLocationSequence.allocate(owner=owner, owner_group=owner_group) return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs) class WorkflowInstance(models.Model): slug = models.CharField(max_length=255) state = models.CharField(max_length=255) current_step = models.PositiveIntegerField(default=1) payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend. owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows') staged_files = models.ManyToManyField(File, related_name='staged_by_workflows', blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return f"{self.slug} ({self.state})"