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 @@ @@ -20,7 +20,7 @@