This commit is contained in:
j3d1 2026-08-01 12:17:37 +02:00
parent 32addb8ed1
commit 2f8683add1
15 changed files with 610 additions and 134 deletions

View file

@ -41,6 +41,10 @@ class DomainSerializer(serializers.ModelSerializer):
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False) 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): def validate(self, attrs):
if 'name' in attrs: if 'name' in attrs:
@ -56,13 +60,17 @@ class CategorySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Category model = Category
fields = ['name', 'description', 'parent', 'origin'] fields = ['name', 'description', 'parent', 'origin', 'handle']
read_only_fields = ['origin'] read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminCategory' ref_name = 'HostAdminCategory'
class PropertySerializer(serializers.ModelSerializer): class PropertySerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False) 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): def validate(self, attrs):
if 'name' in attrs: if 'name' in attrs:
@ -79,13 +87,17 @@ class PropertySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Property 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',
'dimensions', 'origin'] 'dimensions', 'origin', 'handle']
read_only_fields = ['origin'] read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminProperty' ref_name = 'HostAdminProperty'
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False) 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): def validate(self, attrs):
if 'name' in attrs: if 'name' in attrs:
@ -101,6 +113,6 @@ class TagSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Tag model = Tag
fields = ['name', 'description', 'category', 'origin'] fields = ['name', 'description', 'category', 'origin', 'handle']
read_only_fields = ['origin'] read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminTag' ref_name = 'HostAdminTag'

View file

@ -1,11 +1,39 @@
from django.contrib import admin 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): class InventoryItemAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner', 'storage_location') list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username', 'storage_location__name') '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) admin.site.register(InventoryItem, InventoryItemAdmin)

View file

@ -9,6 +9,7 @@ from toolshed.offlinedata import (
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv, inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
import_locations, import_friends, import_inventory, import_locations, import_friends, import_inventory,
profile_data, profile_picture_files, settings_data, import_profile, import_settings, 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 import mimetypes
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import transaction
from files.models import File from files.models import File
result = {} result = {}
@ -82,6 +84,7 @@ def import_files(zip_file):
if not name.startswith('files/') or name == 'files/': if not name.startswith('files/') or name == 'files/':
continue continue
try: try:
with transaction.atomic():
data = zip_file.read(name) data = zip_file.read(name)
content_hash = sha256(data).hexdigest() content_hash = sha256(data).hexdigest()
mime_type, _ = mimetypes.guess_type(name) mime_type, _ = mimetypes.guess_type(name)
@ -105,11 +108,20 @@ def import_user_data(user, data):
Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or 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 the 'files/' subfolder may be missing; whatever is present is imported and everything else
is silently skipped. 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 io
import zipfile 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: with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file:
names = set(zip_file.namelist()) names = set(zip_file.namelist())
@ -118,23 +130,46 @@ def import_user_data(user, data):
summary['files'] = len(available_files) summary['files'] = len(available_files)
if 'profile.json' in names: if 'profile.json' in names:
try:
with transaction.atomic():
summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files) 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: if 'settings.json' in names:
try:
with transaction.atomic():
summary['settings'] = import_settings(user, zip_file.read('settings.json')) 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: if 'locations.csv' in names:
try:
with transaction.atomic():
summary['locations'] = import_locations(user, zip_file.read('locations.csv')) 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: if 'friends.csv' in names:
try:
with transaction.atomic():
summary['friends'] = import_friends(user, zip_file.read('friends.csv')) 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: 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 return summary
def _extract_zip_bytes(zip_payload): def _extract_zip_bytes(zip_payload):
"""Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes).""" """Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes)."""
import base64 import base64
@ -180,14 +215,39 @@ def export_data(request, format=None):
@api_view(['DELETE']) @api_view(['DELETE'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication]) @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) local_user = local_user_or_none(request.user)
if local_user is None: if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403) 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 = [ urlpatterns = [
path('export/', export_data, name='export_data'), path('export/', export_data, name='export_data'),
path('import/', import_data, name='import_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'),
] ]

View file

@ -26,6 +26,10 @@ class Category(SoftDeleteModel):
parent = str(self.parent) + "/" if self.parent else "" parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name 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): class Property(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -50,6 +54,10 @@ class Property(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class Tag(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -69,6 +77,10 @@ class Tag(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class InventoryItem(SoftDeleteModel):
AVAILABILITY_POLICY_CHOICES = ( AVAILABILITY_POLICY_CHOICES = (

View file

@ -27,12 +27,12 @@ def inventory_rows(user):
'id': item.id, 'id': item.id,
'name': item.name or '', 'name': item.name or '',
'description': item.description 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, 'availability_policy': item.availability_policy,
'owned_quantity': item.owned_quantity, 'owned_quantity': item.owned_quantity,
'storage_location': str(item.storage_location) if item.storage_location else '', 'storage_location': str(item.storage_location) if item.storage_location else '',
'tags': ', '.join(tag.name for tag in item.tags.all()), 'tags': ', '.join(tag.get_handle() for tag in item.tags.all()),
'properties': ', '.join(f"{ip.property.name}={ip.value}" for ip in item.itemproperty_set.all()), 'properties': _encode_properties_cell(item.itemproperty_set.all()),
'files': ', '.join(file_paths), 'files': ', '.join(file_paths),
'created_at': item.created_at.isoformat() if item.created_at else '', 'created_at': item.created_at.isoformat() if item.created_at else '',
} }
@ -216,6 +216,89 @@ def import_settings(user, data):
return 0 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'): 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.""" """Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
import csv 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. 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 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. 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 from toolshed.models import StorageLocation
rows = list(_read_csv_rows(data)) rows = list(_read_csv_rows(data))
@ -292,6 +380,7 @@ def import_locations(user, data):
for row in rows: for row in rows:
try: try:
with transaction.atomic():
name = (row.get('name') or '').strip() name = (row.get('name') or '').strip()
if not name and not _field_is_optional(StorageLocation, 'name'): if not name and not _field_is_optional(StorageLocation, 'name'):
continue # required column missing, skip this row continue # required column missing, skip this row
@ -323,11 +412,14 @@ def import_locations(user, data):
def import_friends(user, data): def import_friends(user, data):
"""Fault-tolerant import of friends.csv, adding valid entries to the user's known friends.""" """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 from authentication.models import KnownIdentity
imported = 0 imported = 0
for row in _read_csv_rows(data): for row in _read_csv_rows(data):
try: try:
with transaction.atomic():
username = row.get('username') username = row.get('username')
domain = row.get('domain') domain = row.get('domain')
handle = row.get('handle') handle = row.get('handle')
@ -350,29 +442,189 @@ def import_friends(user, data):
return imported 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): def import_inventory(user, data, available_files):
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. """Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
`available_files` maps the 'files' column's paths to File instances successfully extracted `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. 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 imported = 0
errors = []
for row in _read_csv_rows(data): for row in _read_csv_rows(data):
try:
name = (row.get('name') or '').strip() name = (row.get('name') or '').strip()
try:
with transaction.atomic():
file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()] 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] files = [available_files[p] for p in file_paths if p in available_files]
if not name and not files: if not name and not files:
continue # InventoryItem requires a name or at least one file continue # InventoryItem requires a name or at least one file
category = None category = resolve_category_from_csv((row.get('category') or '').strip())
category_path = row.get('category')
if category_path: tags = []
category = get_or_create_category(category_path) 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)
properties = _parse_properties_cell(row.get('properties') or '', resolve_property_from_csv)
storage_location = None storage_location = None
location_path_value = row.get('storage_location') location_path_value = row.get('storage_location')
@ -395,28 +647,24 @@ def import_inventory(user, data, available_files):
storage_location=storage_location, storage_location=storage_location,
) )
for tag_name in (row.get('tags') or '').split(','): for tag in tags:
tag_name = tag_name.strip()
if tag_name:
tag, _ = Tag.objects.get_or_create(name=tag_name, category=None,
defaults={'origin': 'import'})
item.tags.add(tag, through_defaults={}) item.tags.add(tag, through_defaults={})
for prop_entry in (row.get('properties') or '').split(','): for prop, value in properties:
prop_entry = prop_entry.strip() ItemProperty.objects.create(inventory_item=item, property=prop, value=value)
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 file in files: for file in files:
item.files.add(file) 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: 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

View file

@ -6,6 +6,46 @@ from files.serializers import FileSerializer
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance 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): class FriendSerializer(serializers.ModelSerializer):
username = serializers.SerializerMethodField() username = serializers.SerializerMethodField()
@ -29,23 +69,35 @@ class FriendRequestSerializer(serializers.ModelSerializer):
class PropertySerializer(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: class Meta:
model = Property 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): class CategorySerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta: class Meta:
model = Category model = Category
fields = ['name'] fields = ['name', 'handle']
def get_handle(self, obj):
return obj.get_handle()
def to_representation(self, instance): def to_representation(self, instance):
return str(instance) return instance.get_handle()
def to_internal_value(self, data): 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): class StorageLocationSerializer(serializers.ModelSerializer):
@ -67,23 +119,28 @@ class StorageLocationSerializer(serializers.ModelSerializer):
class ItemPropertySerializer(serializers.ModelSerializer): class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True) property = PropertySerializer(read_only=True)
handle = serializers.SerializerMethodField()
class Meta: class Meta:
model = ItemProperty model = ItemProperty
fields = ['property', 'value'] fields = ['property', 'value', 'handle']
def get_handle(self, obj):
return obj.property.get_handle()
def to_representation(self, instance): 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): 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'] value = data['value']
return {'property': prop, 'value': value} return {'property': prop, 'value': value}
class InventoryItemSerializer(serializers.ModelSerializer): class InventoryItemSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True) 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') properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True) category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True) files = FileSerializer(many=True, read_only=True)
@ -91,11 +148,16 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner', 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): def to_internal_value(self, data):
files = data.pop('files', []) files = data.pop('files', [])
tags_input = data.pop('tags_input', data.pop('tags', []))
ret = super().to_internal_value(data) ret = super().to_internal_value(data)
ret['tags'] = [resolve_tag_handle(tag) for tag in tags_input]
ret['files'] = files ret['files'] = files
return ret return ret

View file

@ -2,7 +2,7 @@
<badge-select-field :value="this.value" :options="this.tags" @addElement="addTag" ref="badgeSelect"> <badge-select-field :value="this.value" :options="this.tags" @addElement="addTag" ref="badgeSelect">
<template v-slot:default="{option, index}"> <template v-slot:default="{option, index}">
<span class="badge bg-dark" @click="removeTag(index)"> <span class="badge bg-dark" @click="removeTag(index)">
{{ option }} {{ getNameFromHandle(option) }}
</span> </span>
</template> </template>
</badge-select-field> </badge-select-field>
@ -20,7 +20,7 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState, mapGetters} from "vuex";
//import BadgeSelectField from "@/../extras/components/inputs/BadgeSelectField.vue"; //import BadgeSelectField from "@/../extras/components/inputs/BadgeSelectField.vue";
import BadgeSelectField from "@/components/inputs/BadgeSelectField.vue"; import BadgeSelectField from "@/components/inputs/BadgeSelectField.vue";
@ -42,6 +42,7 @@ export default {
}, },
computed: { computed: {
...mapState(["tags"]), ...mapState(["tags"]),
...mapGetters(["getNameFromHandle"]),
availableTags() { availableTags() {
return this.tags.filter(tag => !this.localValue.includes(tag)); return this.tags.filter(tag => !this.localValue.includes(tag));
}, },

View file

@ -678,5 +678,17 @@ export default createStore({
} }
return fallbackDefault return fallbackDefault
}, },
/**
* Extracts the human-readable name from a fully qualified handle.
* Handles look like "git:tools#tag:drill" or "git:base#property:length".
* If the given value does not look like a handle, it is returned unchanged.
*/
getNameFromHandle: () => (handle) => {
if (typeof handle !== 'string') {
return handle;
}
const match = handle.match(/#(?:tag|property):(.+)$/);
return match ? match[1] : handle;
},
} }
}) })

View file

@ -13,7 +13,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="tags" class="form-label">Tags</label> <label for="tags" class="form-label">Tags</label>
<span class="badge bg-dark" v-for="(tag, index) in item.tags" :key="index"> <span class="badge bg-dark" v-for="(tag, index) in item.tags" :key="index">
{{ tag }} {{ getNameFromHandle(tag) }}
</span> </span>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -74,7 +74,7 @@ export default {
} }
}, },
computed: { computed: {
...mapGetters(["loaded_items"]), ...mapGetters(["loaded_items", "getNameFromHandle"]),
...mapState(["storage_locations"]), ...mapState(["storage_locations"]),
item() { item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {} return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}

View file

@ -51,14 +51,35 @@
<script> <script>
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapMutations} from "vuex";
export default { export default {
name: 'Settings', name: 'Settings',
components: {BaseLayout}, components: {BaseLayout},
computed: {
...mapGetters(['signAuth'])
},
methods: { methods: {
deleteAccount() { ...mapActions(['getHomeServers']),
if (confirm('Are you sure you want to delete your account?')) { ...mapMutations(['logout']),
alert('Account deleted'); async deleteAccount() {
if (!confirm('Are you sure you want to permanently delete your account? ' +
'All your data will be deleted and you will not be able to log back in. This cannot be undone.')) {
return;
}
try {
const servers = await this.getHomeServers();
const response = await servers.delete(this.signAuth, '/api/account/');
if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {};
alert('Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error'));
return;
}
alert('Your account has been permanently deleted.');
this.logout();
} catch (error) {
console.error('Account deletion failed', error);
alert('Account deletion failed: ' + error);
} }
}, },
} }

View file

@ -72,7 +72,7 @@
</template> </template>
<script> <script>
import {mapActions, mapGetters, mapMutations} from 'vuex'; import {mapActions, mapGetters} from 'vuex';
import router from "@/router"; import router from "@/router";
//import VueQrcode from '@chenfengyuan/vue-qrcode'; //import VueQrcode from '@chenfengyuan/vue-qrcode';
@ -95,9 +95,29 @@ export default {
this.selectedFile = file; this.selectedFile = file;
} }
}, },
deleteData() { async deleteData() {
if (confirm('Are you sure you want to delete your data?')) { if (!confirm('Are you sure you want to permanently delete all your data (inventory, locations, ' +
alert('Data deleted'); 'settings, friends and files)? Your account itself will stay - this cannot be undone.')) {
return;
}
try {
const servers = await this.getHomeServers();
const response = await servers.delete(this.signAuth, '/api/account_data/');
if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {};
alert('Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error'));
return;
}
const summary = await response.json().catch(() => ({}));
alert('All your data has been deleted: ' +
`${summary.inventory_items || 0} inventory items, ` +
`${summary.locations || 0} locations, ` +
`${summary.settings || 0} settings, ` +
`${summary.friends || 0} friends, ` +
`${summary.files || 0} files.`);
} catch (error) {
console.error('Data deletion failed', error);
alert('Data deletion failed: ' + error);
} }
}, },
exportKey() { exportKey() {

2
testdata/user-a.key vendored
View file

@ -1 +1 @@
f0cd0b223efb7ca24cd8ad6e39471fb1b4f69a20a3e5c6fdb41c9985b7dbf381 2a7ddeb75181afedbc755924db9f1d527278375ff562858c93a502ab783a6815

BIN
testdata/user-a.zip vendored

Binary file not shown.

2
testdata/user-b.key vendored
View file

@ -1 +1 @@
6b186fde49d12b7108e5246b5279be660fba185cfd514cb0d949fea5d7db9131 5b0f8e19806c5ab87eced9a0d08855429e1b2be957b760b46fbbef2309eccd43

BIN
testdata/user-b.zip vendored

Binary file not shown.