stash
This commit is contained in:
parent
32addb8ed1
commit
2f8683add1
15 changed files with 610 additions and 134 deletions
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -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)) || {}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
2
testdata/user-a.key
vendored
|
|
@ -1 +1 @@
|
||||||
f0cd0b223efb7ca24cd8ad6e39471fb1b4f69a20a3e5c6fdb41c9985b7dbf381
|
2a7ddeb75181afedbc755924db9f1d527278375ff562858c93a502ab783a6815
|
||||||
BIN
testdata/user-a.zip
vendored
BIN
testdata/user-a.zip
vendored
Binary file not shown.
2
testdata/user-b.key
vendored
2
testdata/user-b.key
vendored
|
|
@ -1 +1 @@
|
||||||
6b186fde49d12b7108e5246b5279be660fba185cfd514cb0d949fea5d7db9131
|
5b0f8e19806c5ab87eced9a0d08855429e1b2be957b760b46fbbef2309eccd43
|
||||||
BIN
testdata/user-b.zip
vendored
BIN
testdata/user-b.zip
vendored
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue