261 lines
12 KiB
Python
261 lines
12 KiB
Python
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})"
|
|
|
|
|