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):
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,6 +84,7 @@ def import_files(zip_file):
|
|||
if not name.startswith('files/') or name == 'files/':
|
||||
continue
|
||||
try:
|
||||
with transaction.atomic():
|
||||
data = zip_file.read(name)
|
||||
content_hash = sha256(data).hexdigest()
|
||||
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
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,6 +380,7 @@ def import_locations(user, data):
|
|||
|
||||
for row in rows:
|
||||
try:
|
||||
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
|
||||
|
|
@ -323,11 +412,14 @@ 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:
|
||||
with transaction.atomic():
|
||||
username = row.get('username')
|
||||
domain = row.get('domain')
|
||||
handle = row.get('handle')
|
||||
|
|
@ -350,29 +442,189 @@ def import_friends(user, data):
|
|||
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):
|
||||
try:
|
||||
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()]
|
||||
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
|
||||
|
||||
category = None
|
||||
category_path = row.get('category')
|
||||
if category_path:
|
||||
category = get_or_create_category(category_path)
|
||||
category = resolve_category_from_csv((row.get('category') or '').strip())
|
||||
|
||||
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)
|
||||
|
||||
properties = _parse_properties_cell(row.get('properties') or '', resolve_property_from_csv)
|
||||
|
||||
storage_location = None
|
||||
location_path_value = row.get('storage_location')
|
||||
|
|
@ -395,28 +647,24 @@ def import_inventory(user, data, available_files):
|
|||
storage_location=storage_location,
|
||||
)
|
||||
|
||||
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'})
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<badge-select-field :value="this.value" :options="this.tags" @addElement="addTag" ref="badgeSelect">
|
||||
<template v-slot:default="{option, index}">
|
||||
<span class="badge bg-dark" @click="removeTag(index)">
|
||||
{{ option }}
|
||||
{{ getNameFromHandle(option) }}
|
||||
</span>
|
||||
</template>
|
||||
</badge-select-field>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
<script>
|
||||
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 "@/components/inputs/BadgeSelectField.vue";
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
...mapState(["tags"]),
|
||||
...mapGetters(["getNameFromHandle"]),
|
||||
availableTags() {
|
||||
return this.tags.filter(tag => !this.localValue.includes(tag));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -678,5 +678,17 @@ export default createStore({
|
|||
}
|
||||
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">
|
||||
<label for="tags" class="form-label">Tags</label>
|
||||
<span class="badge bg-dark" v-for="(tag, index) in item.tags" :key="index">
|
||||
{{ tag }}
|
||||
{{ getNameFromHandle(tag) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
|
|
@ -74,7 +74,7 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["loaded_items"]),
|
||||
...mapGetters(["loaded_items", "getNameFromHandle"]),
|
||||
...mapState(["storage_locations"]),
|
||||
item() {
|
||||
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
|
||||
|
|
|
|||
|
|
@ -51,14 +51,35 @@
|
|||
|
||||
<script>
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {mapActions, mapGetters, mapMutations} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {BaseLayout},
|
||||
computed: {
|
||||
...mapGetters(['signAuth'])
|
||||
},
|
||||
methods: {
|
||||
deleteAccount() {
|
||||
if (confirm('Are you sure you want to delete your account?')) {
|
||||
alert('Account deleted');
|
||||
...mapActions(['getHomeServers']),
|
||||
...mapMutations(['logout']),
|
||||
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>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters, mapMutations} from 'vuex';
|
||||
import {mapActions, mapGetters} from 'vuex';
|
||||
import router from "@/router";
|
||||
|
||||
//import VueQrcode from '@chenfengyuan/vue-qrcode';
|
||||
|
|
@ -95,9 +95,29 @@ export default {
|
|||
this.selectedFile = file;
|
||||
}
|
||||
},
|
||||
deleteData() {
|
||||
if (confirm('Are you sure you want to delete your data?')) {
|
||||
alert('Data deleted');
|
||||
async deleteData() {
|
||||
if (!confirm('Are you sure you want to permanently delete all your data (inventory, locations, ' +
|
||||
'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() {
|
||||
|
|
|
|||
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