diff --git a/backend/hostadmin/serializers.py b/backend/hostadmin/serializers.py
index 02fadb6..03c1805 100644
--- a/backend/hostadmin/serializers.py
+++ b/backend/hostadmin/serializers.py
@@ -41,6 +41,10 @@ class DomainSerializer(serializers.ModelSerializer):
class CategorySerializer(serializers.ModelSerializer):
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
+ handle = serializers.SerializerMethodField()
+
+ def get_handle(self, obj):
+ return obj.get_handle()
def validate(self, attrs):
if 'name' in attrs:
@@ -56,13 +60,17 @@ class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
- fields = ['name', 'description', 'parent', 'origin']
- read_only_fields = ['origin']
+ fields = ['name', 'description', 'parent', 'origin', 'handle']
+ read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminCategory'
class PropertySerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
+ handle = serializers.SerializerMethodField()
+
+ def get_handle(self, obj):
+ return obj.get_handle()
def validate(self, attrs):
if 'name' in attrs:
@@ -79,13 +87,17 @@ class PropertySerializer(serializers.ModelSerializer):
class Meta:
model = Property
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix',
- 'dimensions', 'origin']
- read_only_fields = ['origin']
+ 'dimensions', 'origin', 'handle']
+ read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminProperty'
class TagSerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
+ handle = serializers.SerializerMethodField()
+
+ def get_handle(self, obj):
+ return obj.get_handle()
def validate(self, attrs):
if 'name' in attrs:
@@ -101,6 +113,6 @@ class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
- fields = ['name', 'description', 'category', 'origin']
- read_only_fields = ['origin']
+ fields = ['name', 'description', 'category', 'origin', 'handle']
+ read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminTag'
diff --git a/backend/toolshed/admin.py b/backend/toolshed/admin.py
index 142eea5..bbd1525 100644
--- a/backend/toolshed/admin.py
+++ b/backend/toolshed/admin.py
@@ -1,11 +1,39 @@
from django.contrib import admin
-from toolshed.models import InventoryItem, Property, Tag, Category, StorageLocation, WorkflowInstance
+from toolshed.models import (
+ InventoryItem, ItemProperty, ItemTag, Property, Tag, Category, StorageLocation, WorkflowInstance,
+)
+
+
+class ItemTagInline(admin.TabularInline):
+ model = ItemTag
+ extra = 0
+ autocomplete_fields = ('tag',)
+
+
+class ItemPropertyInline(admin.TabularInline):
+ model = ItemProperty
+ extra = 0
+ autocomplete_fields = ('property',)
class InventoryItemAdmin(admin.ModelAdmin):
- list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner', 'storage_location')
- search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username', 'storage_location__name')
+ list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
+ 'storage_location', 'get_tags', 'get_properties')
+ search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
+ 'storage_location__name', 'tags__name', 'itemproperty__property__name')
+ inlines = (ItemTagInline, ItemPropertyInline)
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).prefetch_related('tags', 'itemproperty_set__property')
+
+ @admin.display(description='Tags')
+ def get_tags(self, obj):
+ return ', '.join(tag.name for tag in obj.tags.all())
+
+ @admin.display(description='Properties')
+ def get_properties(self, obj):
+ return ', '.join(f'{ip.property.name}={ip.value}' for ip in obj.itemproperty_set.all())
admin.site.register(InventoryItem, InventoryItemAdmin)
diff --git a/backend/toolshed/api/offlinedata.py b/backend/toolshed/api/offlinedata.py
index eadd248..6fdd571 100644
--- a/backend/toolshed/api/offlinedata.py
+++ b/backend/toolshed/api/offlinedata.py
@@ -9,6 +9,7 @@ from toolshed.offlinedata import (
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
import_locations, import_friends, import_inventory,
profile_data, profile_picture_files, settings_data, import_profile, import_settings,
+ delete_user_data, delete_user_account,
)
@@ -75,6 +76,7 @@ def import_files(zip_file):
import mimetypes
from django.core.files.base import ContentFile
+ from django.db import transaction
from files.models import File
result = {}
@@ -82,17 +84,18 @@ def import_files(zip_file):
if not name.startswith('files/') or name == 'files/':
continue
try:
- data = zip_file.read(name)
- content_hash = sha256(data).hexdigest()
- mime_type, _ = mimetypes.guess_type(name)
- file_obj = File.objects.filter(hash=content_hash).first()
- if file_obj is None:
- file_obj = File.objects.create(
- file=ContentFile(data, content_hash),
- mime_type=mime_type or 'application/octet-stream',
- hash=content_hash,
- )
- result[name] = file_obj
+ with transaction.atomic():
+ data = zip_file.read(name)
+ content_hash = sha256(data).hexdigest()
+ mime_type, _ = mimetypes.guess_type(name)
+ file_obj = File.objects.filter(hash=content_hash).first()
+ if file_obj is None:
+ file_obj = File.objects.create(
+ file=ContentFile(data, content_hash),
+ mime_type=mime_type or 'application/octet-stream',
+ hash=content_hash,
+ )
+ result[name] = file_obj
except Exception as error:
print(f'Skipping file "{name}" during import: {error}')
return result
@@ -105,11 +108,20 @@ def import_user_data(user, data):
Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or
the 'files/' subfolder may be missing; whatever is present is imported and everything else
is silently skipped.
+
+ Each section is imported inside its own transaction savepoint (`transaction.atomic()`), so a
+ DB-level failure in one section (e.g. a profile.json whose email collides with another
+ account) can't leave the connection in a broken/aborted-transaction state that would
+ otherwise silently take down every subsequent section (including the inventory items and
+ their properties) with an opaque "current transaction is aborted" error.
"""
import io
import zipfile
- summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0}
+ from django.db import transaction
+
+ summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0,
+ 'errors': []}
with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file:
names = set(zip_file.namelist())
@@ -118,23 +130,46 @@ def import_user_data(user, data):
summary['files'] = len(available_files)
if 'profile.json' in names:
- summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files)
+ try:
+ with transaction.atomic():
+ summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files)
+ except Exception as error:
+ summary['errors'].append(f'Could not import profile.json: {error}')
if 'settings.json' in names:
- summary['settings'] = import_settings(user, zip_file.read('settings.json'))
+ try:
+ with transaction.atomic():
+ summary['settings'] = import_settings(user, zip_file.read('settings.json'))
+ except Exception as error:
+ summary['errors'].append(f'Could not import settings.json: {error}')
if 'locations.csv' in names:
- summary['locations'] = import_locations(user, zip_file.read('locations.csv'))
+ try:
+ with transaction.atomic():
+ summary['locations'] = import_locations(user, zip_file.read('locations.csv'))
+ except Exception as error:
+ summary['errors'].append(f'Could not import locations.csv: {error}')
if 'friends.csv' in names:
- summary['friends'] = import_friends(user, zip_file.read('friends.csv'))
+ try:
+ with transaction.atomic():
+ summary['friends'] = import_friends(user, zip_file.read('friends.csv'))
+ except Exception as error:
+ summary['errors'].append(f'Could not import friends.csv: {error}')
if 'inventory.csv' in names:
- summary['inventory_items'] = import_inventory(user, zip_file.read('inventory.csv'), available_files)
+ try:
+ with transaction.atomic():
+ summary['inventory_items'], inventory_errors = import_inventory(
+ user, zip_file.read('inventory.csv'), available_files)
+ summary['errors'].extend(inventory_errors)
+ except Exception as error:
+ summary['errors'].append(f'Could not import inventory.csv: {error}')
return summary
+
def _extract_zip_bytes(zip_payload):
"""Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes)."""
import base64
@@ -180,14 +215,39 @@ def export_data(request, format=None):
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
-def delete_account(request, format=None):
+def delete_data(request, format=None):
+ """Wipe all of the local user's data (everything included in the export), but keep the account.
+
+ This is *not* account deletion/closure - the user stays logged in and can keep using the
+ account (with all their data reset to a blank slate) afterwards.
+ """
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
+ summary = delete_user_data(local_user)
+ return Response(summary, status=200)
+
+
+@api_view(['DELETE'])
+@permission_classes([IsAuthenticated])
+@authentication_classes([SignatureAuthentication])
+def delete_account(request, format=None):
+ """Permanently close the local user's account, after wiping all of its data.
+
+ Unlike `delete_data()`, this also removes the account itself - the user can no longer log
+ in afterwards. Their public identity is kept so remote friends/history referencing it stay
+ intact, but the local ToolshedUser row is gone.
+ """
+ local_user = local_user_or_none(request.user)
+ if local_user is None:
+ return Response({'detail': 'This endpoint is only available to local users'}, status=403)
+ summary = delete_user_account(local_user)
+ return Response(summary, status=200)
urlpatterns = [
path('export/', export_data, name='export_data'),
path('import/', import_data, name='import_data'),
- path('account_data/', delete_account, name='delete_account'),
+ path('account_data/', delete_data, name='delete_data'),
+ path('account/', delete_account, name='delete_account'),
]
diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py
index ea038a7..47a790c 100644
--- a/backend/toolshed/models.py
+++ b/backend/toolshed/models.py
@@ -26,6 +26,10 @@ class Category(SoftDeleteModel):
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)
@@ -50,6 +54,10 @@ class Property(models.Model):
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)
@@ -69,6 +77,10 @@ class Tag(models.Model):
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 InventoryItem(SoftDeleteModel):
AVAILABILITY_POLICY_CHOICES = (
diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py
index e9bee3c..9a6c5f5 100644
--- a/backend/toolshed/offlinedata.py
+++ b/backend/toolshed/offlinedata.py
@@ -27,12 +27,12 @@ def inventory_rows(user):
'id': item.id,
'name': item.name or '',
'description': item.description or '',
- 'category': str(item.category) if item.category else '',
+ 'category': item.category.get_handle() if item.category else '',
'availability_policy': item.availability_policy,
'owned_quantity': item.owned_quantity,
'storage_location': str(item.storage_location) if item.storage_location else '',
- 'tags': ', '.join(tag.name for tag in item.tags.all()),
- 'properties': ', '.join(f"{ip.property.name}={ip.value}" for ip in item.itemproperty_set.all()),
+ 'tags': ', '.join(tag.get_handle() for tag in item.tags.all()),
+ 'properties': _encode_properties_cell(item.itemproperty_set.all()),
'files': ', '.join(file_paths),
'created_at': item.created_at.isoformat() if item.created_at else '',
}
@@ -216,6 +216,89 @@ def import_settings(user, data):
return 0
+def delete_user_data(user):
+ """Permanently delete everything that `user_data()` exports, keeping the account itself intact.
+
+ This removes the user's inventory items (hard delete, bypassing soft-delete), storage
+ locations, account preferences, the friends relation on their public identity, the profile
+ picture, and any File blobs (profile picture / inventory attachments) that would otherwise be
+ orphaned - i.e. not referenced by any other inventory item or user. Files still referenced
+ elsewhere (they're deduplicated by content hash) are left untouched. The ToolshedUser account
+ (and its underlying KnownIdentity) is *not* deleted - this only wipes the account's data, it
+ doesn't close the account. See `delete_account()` in toolshed/api/offlinedata.py for that.
+
+ Returns a summary dict describing what was removed.
+ """
+ from django.db import transaction
+
+ from toolshed.models import InventoryItem, StorageLocation
+
+ summary = {'inventory_items': 0, 'locations': 0, 'settings': 0, 'friends': 0, 'files': 0}
+
+ with transaction.atomic():
+ candidate_file_ids = set(
+ InventoryItem.global_objects.filter(owner=user).values_list('files__id', flat=True))
+ candidate_file_ids.discard(None)
+ if user.profile_picture_id:
+ candidate_file_ids.add(user.profile_picture_id)
+
+ for item in InventoryItem.global_objects.filter(owner=user):
+ item.hard_delete()
+ summary['inventory_items'] += 1
+
+ summary['locations'], _ = StorageLocation.objects.filter(owner=user).delete()
+ summary['settings'], _ = user.preferences.all().delete()
+
+ summary['friends'] = user.public_identity.friends.count()
+ user.public_identity.friends.clear()
+
+ user.profile_picture = None
+ user.save(update_fields=['profile_picture'])
+
+ summary['files'] = _delete_orphaned_files(candidate_file_ids)
+
+
+ return summary
+
+
+def delete_user_account(user):
+ """Permanently delete the local user's account, after wiping all of its data.
+
+ This first calls `delete_user_data()` to remove everything covered by the data export
+ (inventory, locations, settings, friends relation, profile picture and orphaned files),
+ then deletes the ToolshedUser row itself. The underlying KnownIdentity is kept so remote
+ friends/history relating to this identity remain intact - only the local account is closed.
+
+ Returns a summary dict describing what was removed, with `account` set to True.
+ """
+ from django.db import transaction
+
+ with transaction.atomic():
+ summary = delete_user_data(user)
+ user.delete()
+ summary['account'] = True
+
+ return summary
+
+
+def _delete_orphaned_files(file_ids):
+ """Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
+
+ A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture)
+ references it anymore. Returns the number of files deleted.
+ """
+ from files.models import File
+
+ deleted = 0
+ for file_obj in File.objects.filter(id__in=file_ids):
+ if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists():
+ continue
+ file_obj.file.delete(save=False)
+ file_obj.delete()
+ deleted += 1
+ return deleted
+
+
def rows_to_csv(rows, fieldnames=None, encoding='utf-8'):
"""Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
import csv
@@ -281,7 +364,12 @@ def import_locations(user, data):
Rows are read by header label. Rows missing the required 'name' column are skipped.
Optional columns ('description', 'category') are simply omitted if absent. Locations are
processed in path-depth order so a child's parent already exists by the time it's needed.
+ Each row runs in its own transaction savepoint so a DB-level failure on one row (e.g. a
+ constraint violation) can't poison the surrounding transaction and silently break every
+ subsequent row.
"""
+ from django.db import transaction
+
from toolshed.models import StorageLocation
rows = list(_read_csv_rows(data))
@@ -292,29 +380,30 @@ def import_locations(user, data):
for row in rows:
try:
- name = (row.get('name') or '').strip()
- if not name and not _field_is_optional(StorageLocation, 'name'):
- continue # required column missing, skip this row
+ with transaction.atomic():
+ name = (row.get('name') or '').strip()
+ if not name and not _field_is_optional(StorageLocation, 'name'):
+ continue # required column missing, skip this row
- path = (row.get('path') or name).strip()
- parent = None
- if '/' in path:
- parent = resolved_by_path.get(path.rsplit('/', 1)[0])
+ path = (row.get('path') or name).strip()
+ parent = None
+ if '/' in path:
+ parent = resolved_by_path.get(path.rsplit('/', 1)[0])
- category = None
- category_path = row.get('category')
- if category_path:
- category = get_or_create_category(category_path)
+ category = None
+ category_path = row.get('category')
+ if category_path:
+ category = get_or_create_category(category_path)
- location, _ = StorageLocation.objects.update_or_create(
- owner=user, name=name, parent=parent,
- defaults={
- 'description': row.get('description', '') or '',
- 'category': category,
- },
- )
- resolved_by_path[path] = location
- imported += 1
+ location, _ = StorageLocation.objects.update_or_create(
+ owner=user, name=name, parent=parent,
+ defaults={
+ 'description': row.get('description', '') or '',
+ 'category': category,
+ },
+ )
+ resolved_by_path[path] = location
+ imported += 1
except Exception as error:
print(f'Skipping location row {row}: {error}')
@@ -323,100 +412,259 @@ def import_locations(user, data):
def import_friends(user, data):
"""Fault-tolerant import of friends.csv, adding valid entries to the user's known friends."""
+ from django.db import transaction
+
from authentication.models import KnownIdentity
imported = 0
for row in _read_csv_rows(data):
try:
- username = row.get('username')
- domain = row.get('domain')
- handle = row.get('handle')
- if (not username or not domain) and handle and '@' in handle:
- username, domain = handle.split('@', 1)
+ with transaction.atomic():
+ username = row.get('username')
+ domain = row.get('domain')
+ handle = row.get('handle')
+ if (not username or not domain) and handle and '@' in handle:
+ username, domain = handle.split('@', 1)
- public_key = row.get('public_key')
- if not username or not domain or not public_key:
- continue # required fields missing, skip this row
+ public_key = row.get('public_key')
+ if not username or not domain or not public_key:
+ continue # required fields missing, skip this row
- identity, _ = KnownIdentity.objects.get_or_create(
- username=username, domain=domain,
- defaults={'public_key': public_key},
- )
- user.public_identity.friends.add(identity)
- imported += 1
+ identity, _ = KnownIdentity.objects.get_or_create(
+ username=username, domain=domain,
+ defaults={'public_key': public_key},
+ )
+ user.public_identity.friends.add(identity)
+ imported += 1
except Exception as error:
print(f'Skipping friend row {row}: {error}')
return imported
+class _HandleNotFound(Exception):
+ """Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved.
+
+ This intentionally aborts the whole row (rather than falling back to creating a new
+ entity), since a handle references a *specific* entity from a *specific* origin - silently
+ creating a new local entity named after the raw handle string would be incorrect.
+ """
+
+
+def _resolve_handle(value, entity_type, model):
+ """Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance.
+
+ Never creates anything: a handle references a specific entity from a specific origin, so
+ silently creating a new local entity named after the raw handle string would be incorrect.
+ Raises `_HandleNotFound` if the handle's entity type doesn't match or no such object exists,
+ so the caller can skip the row and report a helpful error instead.
+ """
+ origin, rest = value.split('#', 1)
+ if ':' in rest:
+ found_type, name = rest.split(':', 1)
+ if found_type != entity_type:
+ raise _HandleNotFound(
+ f"expected a {entity_type} handle but got '{value}' (type '{found_type}')")
+ else:
+ name = rest
+ try:
+ return model.objects.get(origin=origin, name=name)
+ except model.DoesNotExist:
+ raise _HandleNotFound(f"{entity_type} '{value}' does not exist, skipping item")
+
+
+def _quote_value_if_needed(value):
+ """Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a
+ comma or a quote character, so it survives sitting inside a comma-separated "handle=value"
+ list unambiguously.
+ """
+ if any(ch in value for ch in ',"'):
+ return '"' + value.replace('"', '""') + '"'
+ return value
+
+
+def _encode_properties_cell(item_properties):
+ """Encode an item's properties as a comma-separated "handle=value" list for the 'properties'
+ CSV cell, quoting a value (CSV-style) when it contains a comma or a quote character so it
+ still round-trips correctly. See `_parse_properties_cell()` for the reader side.
+ """
+ entries = [
+ f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
+ for ip in item_properties
+ ]
+ return ', '.join(entries)
+
+
+def _split_quoted_comma_list(raw_value):
+ """Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style,
+ wherever they appear in an entry) so a quoted value's own commas aren't mistaken for
+ separators. Doubled quotes ("") inside a quoted substring are unescaped to a single literal
+ quote, and the surrounding quotes themselves are stripped from the result.
+
+ Only the single space after each ", " separator (as written by `_encode_properties_cell()`)
+ is dropped - unlike a blanket `.strip()`, this preserves any leading/trailing whitespace that
+ is genuinely part of a (quoted) value.
+ """
+ entries = []
+ current = []
+ in_quotes = False
+ i = 0
+ length = len(raw_value)
+
+ while i < length:
+ char = raw_value[i]
+ if char == '"':
+ if in_quotes and i + 1 < length and raw_value[i + 1] == '"':
+ current.append('"')
+ i += 2
+ continue
+ in_quotes = not in_quotes
+ i += 1
+ continue
+ if char == ',' and not in_quotes:
+ entries.append(''.join(current))
+ current = []
+ i += 1
+ if i < length and raw_value[i] == ' ':
+ i += 1 # skip the single space of the ", " separator
+ continue
+ current.append(char)
+ i += 1
+
+ entries.append(''.join(current))
+ return [entry for entry in entries if entry]
+
+
+def _parse_properties_cell(raw_value, resolve_property):
+ """Parse the 'properties' CSV cell into a list of (Property, value) tuples.
+
+ The cell is a comma-separated list of "handle=value" entries; a value containing a comma or
+ a quote character is double-quoted (CSV-style) by `_encode_properties_cell()` so it survives
+ the round trip intact.
+ """
+ raw_value = (raw_value or '').strip()
+ if not raw_value:
+ return []
+
+ properties = []
+ for prop_entry in _split_quoted_comma_list(raw_value):
+ if '=' not in prop_entry:
+ continue
+ prop_name, value = prop_entry.split('=', 1)
+ prop = resolve_property(prop_name.strip())
+ if prop:
+ properties.append((prop, value))
+
+ return properties
+
+
+
def import_inventory(user, data, available_files):
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
`available_files` maps the 'files' column's paths to File instances successfully extracted
from the zip; references to missing files are ignored rather than failing the row.
+
+ If a row references a fully qualified tag/property/category handle that doesn't exist
+ locally, the whole item is skipped (rather than creating a bogus local entity named after
+ the raw handle) and a helpful message is added to the returned `errors` list. Each row runs
+ in its own transaction savepoint so a DB-level failure on one row can't poison the
+ surrounding transaction and silently break every subsequent row.
"""
- from toolshed.models import InventoryItem, ItemProperty, StorageLocation, Tag, Property
+ from django.db import transaction
+
+ from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
+
+ def resolve_category_from_csv(value):
+ """Resolve a category from CSV - supports both old format and fully qualified handles"""
+ if not value:
+ return None
+ if '#' in value:
+ return _resolve_handle(value, 'category', Category)
+ # Fallback to old path-based lookup
+ return get_or_create_category(value)
+
+ def resolve_tag_from_csv(value):
+ """Resolve a tag from CSV - supports both old format and fully qualified handles"""
+ if not value:
+ return None
+ if '#' in value:
+ return _resolve_handle(value, 'tag', Tag)
+ # Fallback to old name-only lookup or create
+ return Tag.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
+
+ def resolve_property_from_csv(value):
+ """Resolve a property from CSV - supports both old format and fully qualified handles"""
+ if not value:
+ return None
+ if '#' in value:
+ return _resolve_handle(value, 'property', Property)
+ # Fallback to old name-only lookup or create
+ return Property.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
imported = 0
+ errors = []
for row in _read_csv_rows(data):
+ name = (row.get('name') or '').strip()
try:
- name = (row.get('name') or '').strip()
+ with transaction.atomic():
+ file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()]
+ files = [available_files[p] for p in file_paths if p in available_files]
- file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()]
- files = [available_files[p] for p in file_paths if p in available_files]
+ if not name and not files:
+ continue # InventoryItem requires a name or at least one file
- if not name and not files:
- continue # InventoryItem requires a name or at least one file
+ category = resolve_category_from_csv((row.get('category') or '').strip())
- category = None
- category_path = row.get('category')
- if category_path:
- category = get_or_create_category(category_path)
+ tags = []
+ for tag_name in (row.get('tags') or '').split(','):
+ tag_name = tag_name.strip()
+ if tag_name:
+ tag = resolve_tag_from_csv(tag_name)
+ if tag:
+ tags.append(tag)
- storage_location = None
- location_path_value = row.get('storage_location')
- if location_path_value:
- storage_location = StorageLocation.objects.filter(
- owner=user, name=location_path_value.rsplit('/', 1)[-1]).first()
+ properties = _parse_properties_cell(row.get('properties') or '', resolve_property_from_csv)
- try:
- owned_quantity = int(row.get('owned_quantity') or 1)
- except (TypeError, ValueError):
- owned_quantity = 1
+ storage_location = None
+ location_path_value = row.get('storage_location')
+ if location_path_value:
+ storage_location = StorageLocation.objects.filter(
+ owner=user, name=location_path_value.rsplit('/', 1)[-1]).first()
- item = InventoryItem.objects.create(
- owner=user,
- name=name or None,
- description=row.get('description', '') or '',
- category=category,
- availability_policy=row.get('availability_policy') or 'private',
- owned_quantity=owned_quantity,
- storage_location=storage_location,
- )
+ try:
+ owned_quantity = int(row.get('owned_quantity') or 1)
+ except (TypeError, ValueError):
+ owned_quantity = 1
- for tag_name in (row.get('tags') or '').split(','):
- tag_name = tag_name.strip()
- if tag_name:
- tag, _ = Tag.objects.get_or_create(name=tag_name, category=None,
- defaults={'origin': 'import'})
+ item = InventoryItem.objects.create(
+ owner=user,
+ name=name or None,
+ description=row.get('description', '') or '',
+ category=category,
+ availability_policy=row.get('availability_policy') or 'private',
+ owned_quantity=owned_quantity,
+ storage_location=storage_location,
+ )
+
+ for tag in tags:
item.tags.add(tag, through_defaults={})
- for prop_entry in (row.get('properties') or '').split(','):
- prop_entry = prop_entry.strip()
- if '=' not in prop_entry:
- continue
- prop_name, value = prop_entry.split('=', 1)
- prop, _ = Property.objects.get_or_create(name=prop_name.strip(), category=None,
- defaults={'origin': 'import'})
- ItemProperty.objects.create(inventory_item=item, property=prop, value=value.strip())
+ for prop, value in properties:
+ ItemProperty.objects.create(inventory_item=item, property=prop, value=value)
- for file in files:
- item.files.add(file)
+ for file in files:
+ item.files.add(file)
- imported += 1
+ imported += 1
+ except _HandleNotFound as error:
+ message = f"Skipping item '{name or 'unnamed'}': {error}"
+ print(message)
+ errors.append(message)
except Exception as error:
- print(f'Skipping inventory row {row}: {error}')
+ message = f'Skipping inventory row {row}: {error}'
+ print(message)
+ errors.append(message)
- return imported
+ return imported, errors
diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py
index a53b913..02a5111 100644
--- a/backend/toolshed/serializers.py
+++ b/backend/toolshed/serializers.py
@@ -6,6 +6,46 @@ from files.serializers import FileSerializer
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
+def parse_handle(handle):
+ """Parse a fully qualified handle like 'git:base#property:length' into (origin, entity_type, name)"""
+ if '#' not in handle:
+ # Fallback to old format (just name)
+ return None, None, handle
+ origin, rest = handle.split('#', 1)
+ if ':' not in rest:
+ return origin, None, rest
+ entity_type, name = rest.split(':', 1)
+ return origin, entity_type, name
+
+
+def resolve_category_handle(handle):
+ """Resolve a fully qualified handle to a Category object"""
+ origin, entity_type, name = parse_handle(handle)
+ if origin and entity_type == 'category':
+ return Category.objects.get(origin=origin, name=name)
+ # Fallback to name-only lookup
+ return Category.objects.get(name=handle.split('/')[-1])
+
+
+def resolve_property_handle(handle):
+ """Resolve a fully qualified handle to a Property object"""
+ origin, entity_type, name = parse_handle(handle)
+ if origin and entity_type == 'property':
+ return Property.objects.get(origin=origin, name=name)
+ # Fallback to name-only lookup
+ return Property.objects.get(name=handle)
+
+
+def resolve_tag_handle(handle):
+ """Resolve a fully qualified handle to a Tag object"""
+ origin, entity_type, name = parse_handle(handle)
+ if origin and entity_type == 'tag':
+ return Tag.objects.get(origin=origin, name=name)
+ # Fallback to name-only lookup
+ return Tag.objects.get(name=handle)
+
+
+
class FriendSerializer(serializers.ModelSerializer):
username = serializers.SerializerMethodField()
@@ -29,23 +69,35 @@ class FriendRequestSerializer(serializers.ModelSerializer):
class PropertySerializer(serializers.ModelSerializer):
- category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
+ category = serializers.SerializerMethodField()
+ handle = serializers.SerializerMethodField()
+
+ def get_category(self, obj):
+ return resolve_category_handle(obj.category.get_handle()) if obj.category else None
+
+ def get_handle(self, obj):
+ return obj.get_handle()
class Meta:
model = Property
- fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix']
+ fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix', 'handle']
class CategorySerializer(serializers.ModelSerializer):
+ handle = serializers.SerializerMethodField()
+
class Meta:
model = Category
- fields = ['name']
+ fields = ['name', 'handle']
+
+ def get_handle(self, obj):
+ return obj.get_handle()
def to_representation(self, instance):
- return str(instance)
+ return instance.get_handle()
def to_internal_value(self, data):
- return Category.objects.get(name=data.split("/")[-1])
+ return resolve_category_handle(data.split("/")[-1])
class StorageLocationSerializer(serializers.ModelSerializer):
@@ -67,23 +119,28 @@ class StorageLocationSerializer(serializers.ModelSerializer):
class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True)
+ handle = serializers.SerializerMethodField()
class Meta:
model = ItemProperty
- fields = ['property', 'value']
+ fields = ['property', 'value', 'handle']
+
+ def get_handle(self, obj):
+ return obj.property.get_handle()
def to_representation(self, instance):
- return {'value': instance.value, 'name': instance.property.name}
+ return {'value': instance.value, 'name': instance.property.name, 'handle': instance.property.get_handle()}
def to_internal_value(self, data):
- prop = Property.objects.get(name=data['name'])
+ prop = resolve_property_handle(data.get('name') or data.get('handle'))
value = data['value']
return {'property': prop, 'value': value}
class InventoryItemSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
- tags = serializers.SlugRelatedField(many=True, required=False, queryset=Tag.objects.all(), slug_field='name')
+ tags = serializers.SerializerMethodField()
+ tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True)
@@ -91,11 +148,16 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class Meta:
model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
- 'tags', 'properties', 'files', 'storage_location']
+ 'tags', 'tags_input', 'properties', 'files', 'storage_location']
+
+ def get_tags(self, obj):
+ return [tag.get_handle() for tag in obj.tags.all()]
def to_internal_value(self, data):
files = data.pop('files', [])
+ tags_input = data.pop('tags_input', data.pop('tags', []))
ret = super().to_internal_value(data)
+ ret['tags'] = [resolve_tag_handle(tag) for tag in tags_input]
ret['files'] = files
return ret
diff --git a/frontend/src/components/TagField.vue b/frontend/src/components/TagField.vue
index 8649a9e..a97eeb6 100644
--- a/frontend/src/components/TagField.vue
+++ b/frontend/src/components/TagField.vue
@@ -2,7 +2,7 @@
- {{ option }}
+ {{ getNameFromHandle(option) }}
@@ -20,7 +20,7 @@