This commit is contained in:
j3d1 2026-08-31 13:02:06 +02:00
parent d606de8773
commit 2218cc3543
12 changed files with 393 additions and 2204 deletions

View file

@ -14,7 +14,7 @@ class FileSerializer(serializers.Serializer):
def to_representation(self, instance):
return {'id': instance.id, 'name': instance.file.url, 'size': instance.file.size,
'mime_type': instance.mime_type}
'mime_type': instance.mime_type, 'hash': instance.hash}
def create(self, validated_data):
return File.objects.get_or_create(**validated_data)[0]

View file

@ -55,8 +55,8 @@ def post_item_file(request, item_id):
if not request.user.user.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
try:
file = File.objects.get(hash=request.data['file_hash'],
staged_by_workflows__owner=request.user.user.get())
file = File.objects.filter(hash=request.data['file_hash'],
staged_by_workflows__owner=request.user.user.get()).distinct().get()
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
item.files.add(file)
@ -101,6 +101,31 @@ def item_files(request, item_id, format=None):
return post_item_file(request, item_id)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def delete_file(request, file_id, format=None):
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
# Only detach from items this identity is actually authorized to act on - a file is
# content-addressed and can be shared by other users'/groups' items via the same hash,
# so it must never be removed from connections this request has no authority over.
authorized_items = file.connected_items.filter(
Q(owner=request.user) | Q(owner_group__in=request.user.public_identity.member_of_groups.all())
)
if not authorized_items.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
for item in authorized_items:
item.files.remove(file)
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
@ -151,6 +176,7 @@ def delete_staged_file(request, workflow_id, file_hash, format=None):
urlpatterns = [
path('files/', list_all_files),
path('files/<int:file_id>/', delete_file),
path('item_files/<int:item_id>/', item_files),
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
path('staged_files/<int:workflow_id>/', staged_files),

View file

@ -49,15 +49,21 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
if owner_group:
if not owner_group.is_member(identity):
return InventoryItem.objects.none()
return InventoryItem.objects.filter(owner_group=owner_group)
if owner_user:
queryset = InventoryItem.objects.filter(owner_group=owner_group)
elif owner_user:
if owner_user not in identity.friends_or_self():
return InventoryItem.objects.none()
queryset = InventoryItem.objects.filter(owner=owner_user)
if not identity.user.filter(pk=owner_user.pk).exists():
queryset = queryset.exclude(availability_policy='private')
return queryset
return InventoryItem.objects.none()
else:
return InventoryItem.objects.none()
# InventoryItemSerializer touches owner/owner_group/category/storage_location (FKs) and
# tags/files/itemproperty_set (M2M/reverse-FK) for every row - without this, listing N
# items costs ~5N extra queries (each a full network round trip once every other API
# call also goes through the federation ServerSet layer, not just local Django).
return queryset.select_related('owner', 'owner_group', 'category', 'storage_location').prefetch_related(
'tags', 'files', 'itemproperty_set__property')
def perform_create(self, serializer):
try:
@ -209,6 +215,18 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
file.file.delete(save=False)
file.delete()
@action(detail=True, methods=['post'])
def update_step(self, request, pk=None):
# get_queryset already scopes to the request user's own workflows, so get_object 404s for anyone else's.
instance = self.get_object()
with transaction.atomic():
if 'current_step' in request.data:
instance.current_step = request.data['current_step']
if 'payload' in request.data:
instance.payload = request.data['payload']
instance.save()
return Response(self.get_serializer(instance).data)
router = routers.SimpleRouter()
router.register(r'inventory_items/(?P<handle>[^/]+)', InventoryItemViewSet, basename='inventory_items')

View file

@ -1,5 +1,5 @@
from django.test import Client
from authentication.models import Group
from authentication.models import Group, ToolshedUser
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import File, InventoryItem
@ -140,6 +140,49 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
self.assertEqual(File.objects.count(), 3)
self.assertEqual(self.f['item1'].files.count(), 2)
def test_delete_file_top_level(self):
response = client.delete(f"/api/v1/files/{self.f['test_file2'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(File.objects.count(), 2)
self.assertEqual(self.f['item1'].files.count(), 1)
def test_delete_file_top_level_removes_from_all_owned_items(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(File.objects.count(), 2)
self.assertEqual(self.f['item1'].files.count(), 1)
self.assertEqual(self.f['item2'].files.count(), 0)
def test_delete_file_top_level_not_found(self):
response = client.delete(f"/api/v1/files/99999/", self.f['local_user1'])
self.assertEqual(response.status_code, 404)
self.assertEqual(File.objects.count(), 3)
def test_delete_file_top_level_not_owner(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user2'])
self.assertEqual(response.status_code, 404)
self.assertEqual(File.objects.count(), 3)
self.assertEqual(self.f['item1'].files.count(), 2)
def test_delete_file_top_level_anonymous(self):
response = anonymous_client.delete(f"/api/v1/files/{self.f['test_file1'].id}/")
self.assertEqual(response.status_code, 403)
self.assertEqual(File.objects.count(), 3)
def test_delete_file_top_level_only_detaches_own_items_when_shared_by_hash(self):
other_item = InventoryItem.create_for_owner(
owner=self.f['local_user2'], owned_quantity=1, name='other-user-item',
availability_policy='private')
other_item.files.add(self.f['test_file1'])
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['item1'].files.count(), 1)
self.assertEqual(self.f['item2'].files.count(), 0)
self.assertEqual(other_item.files.count(), 1)
self.assertEqual(File.objects.filter(id=self.f['test_file1'].id).count(), 1)
def test_get_inventory(self):
reply = client.get('/api/v1/inventory_items/{}/'.format(self.f['local_user1']), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
@ -215,6 +258,18 @@ class GroupOwnedFileApiTestCase(UserTestMixin, GroupTestMixin, FilesTestMixin, T
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)
def test_other_member_can_delete_file_top_level(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user2'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['group_item'].files.count(), 0)
def test_non_member_cannot_delete_file_top_level(self):
outsider = ToolshedUser.objects.create_user('testuser3', 'test3@abc.de', 'testpassword4',
domain=self.f['example_com'].name)
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", outsider)
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)
def test_item_files_when_id_collides_across_two_groups(self):
group2 = Group.objects.create(name='group2', domain=self.f['example_com'].name)
group2.members.add(self.f['local_user2'].public_identity)

View file

@ -7,6 +7,8 @@
<p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p>
</div>
<div v-if="uploadError" class="alert alert-danger">{{ uploadError }}</div>
<!-- File Upload Area -->
<div class="upload-section mb-4">
<div class="card">
@ -321,6 +323,7 @@ export default {
processingFile: false,
processingStatus: '',
isDragOver: false,
uploadError: null,
fileAnalysis: null,
detectedColumns: [],
parsedRows: [],
@ -385,8 +388,9 @@ export default {
},
async processFile(file) {
this.uploadError = null;
if (!this.isValidFileType(file)) {
alert('Please upload a CSV or Excel file (.csv, .xlsx, .xls)');
this.uploadError = 'Please upload a CSV or Excel file (.csv, .xlsx, .xls)';
return;
}
@ -406,7 +410,7 @@ export default {
this.updateStep1Payload();
} catch (error) {
console.error('Error processing file:', error);
alert('Error processing file. Please try again.');
this.uploadError = 'Error processing file. Please try again.';
this.removeFile();
} finally {
this.processingFile = false;
@ -565,10 +569,11 @@ export default {
proceedFromStep1() {
if (!this.canProceedFromStep1) {
alert('Please upload a file and map the required Name column before proceeding.');
this.uploadError = 'Please upload a file and map the required Name column before proceeding.';
return;
}
this.uploadError = null;
this.updateStep1Payload();
this.$emit('next');
},

View file

@ -184,7 +184,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, {path: '/workflows',
component: Workflows,
meta: {requiresAuth: true},
}, {path: '/workflows/:id/:phase?',
}, {path: '/workflows/:id/:step?',
name: 'workflow-detail',
component: WorkflowDetail,
meta: {requiresAuth: true},

View file

@ -426,9 +426,9 @@ export default createStore({
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers')
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
const ret = await servers.delete(getters.signAuth, path)
dispatch('fetchInventoryItems')
return ret
// No self-refetch here, same as createInventoryItem/updateInventoryItem - callers
// refetch whichever owner-scoped list (own/group/friend) is actually on screen.
return await servers.delete(getters.signAuth, path)
},
async fetchSearchResults({state, commit, dispatch, getters}, {query}) {
const servers = await dispatch('getAllKnownServers')

View file

@ -8,9 +8,9 @@
<h5 class="card-title">Images</h5>
</div>
<div class="card-body">
<span v-for="files in only_images(files)" :key="files.id">
<deletable-wrapper @delete="deleteFile(files)">
<authenticated-image :src="files.name" :owner="files.owner"
<span v-for="file in only_images(files)" :key="file.id">
<deletable-wrapper @delete="deleteFile(file)">
<authenticated-image :src="thumbnailPathForHash(file.hash)" :owner="file.owner"
class="img-thumbnail"/>
</deletable-wrapper>
</span>
@ -58,6 +58,10 @@ export default {
},
whithout_images(files) {
return files.filter(file => !file.mime_type.startsWith("image/"));
},
// See docs/implementation.md#thumbnail-lookup-by-hash.
thumbnailPathForHash(hash, size = 256) {
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
}
},
mounted() {

View file

@ -3,6 +3,10 @@
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Settings</h1>
<div v-if="deleteError" class="alert alert-danger">{{ deleteError }}</div>
<div v-if="deleteSuccess" class="alert alert-success">
Your account has been permanently deleted. Signing you out...
</div>
<div class="row">
<div class="col-md-3 col-xl-2">
<div class="card">
@ -56,6 +60,12 @@ import {mapActions, mapGetters, mapMutations} from "vuex";
export default {
name: 'Settings',
components: {BaseLayout},
data() {
return {
deleteError: null,
deleteSuccess: false
}
},
computed: {
...mapGetters(['signAuth'])
},
@ -63,6 +73,7 @@ export default {
...mapActions(['getHomeServers']),
...mapMutations(['logout']),
async deleteAccount() {
this.deleteError = null;
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;
@ -72,14 +83,16 @@ export default {
const response = await servers.delete(this.signAuth, '/api/v1/account/');
if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {};
alert('Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error'));
this.deleteError = 'Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error');
return;
}
alert('Your account has been permanently deleted.');
this.logout();
this.deleteSuccess = true;
// logout() redirects to /login right away - give the user a moment to read the
// confirmation above before that navigation happens.
setTimeout(() => this.logout(), 2000);
} catch (error) {
console.error('Account deletion failed', error);
alert('Account deletion failed: ' + error);
this.deleteError = 'Account deletion failed: ' + error;
}
},
}

View file

@ -43,6 +43,8 @@
<br>
<small>Delete all your data including photos, videos,
comments, profile information and more</small>
<div v-if="deleteError" class="alert alert-danger mt-3 mb-0">{{ deleteError }}</div>
<div v-if="deleteSuccess" class="alert alert-success mt-3 mb-0">{{ deleteSuccess }}</div>
</div>
</form>
</div>
@ -64,6 +66,8 @@
</div>
<br>
<small>Import data from backup or other instances</small>
<div v-if="importError" class="alert alert-danger mt-3 mb-0">{{ importError }}</div>
<div v-if="importSuccess" class="alert alert-success mt-3 mb-0">{{ importSuccess }}</div>
</div>
</form>
</div>
@ -82,7 +86,11 @@ export default {
components: {},
data: () => ({
selectedFile: null,
localUserIdentityRecord: null
localUserIdentityRecord: null,
deleteError: null,
deleteSuccess: null,
importError: null,
importSuccess: null
}),
computed: {
...mapGetters(['signAuth'])
@ -96,6 +104,8 @@ export default {
}
},
async deleteData() {
this.deleteError = null;
this.deleteSuccess = null;
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;
@ -105,19 +115,19 @@ export default {
const response = await servers.delete(this.signAuth, '/api/v1/account_data/');
if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {};
alert('Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error'));
this.deleteError = 'Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error');
return;
}
const summary = await response.json().catch(() => ({}));
alert('All your data has been deleted: ' +
this.deleteSuccess = '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.`);
`${summary.files || 0} files.`;
} catch (error) {
console.error('Data deletion failed', error);
alert('Data deletion failed: ' + error);
this.deleteError = 'Data deletion failed: ' + error;
}
},
exportKey() {
@ -141,8 +151,10 @@ export default {
});
},
async importData() {
this.importError = null;
this.importSuccess = null;
if (!this.selectedFile) {
alert('Please select a file to import');
this.importError = 'Please select a file to import';
return;
}
try {
@ -150,16 +162,16 @@ export default {
const servers = await this.getHomeServers();
const summary = await servers.post(this.signAuth, '/api/v1/import/', {zip: base64});
if (summary && summary.detail) {
alert('Data import failed: ' + summary.detail);
this.importError = 'Data import failed: ' + summary.detail;
return;
}
alert('Data imported successfully: ' +
this.importSuccess = 'Data imported successfully: ' +
`${summary.profile ? 'profile, ' : ''}` +
`${summary.settings || 0} settings, ` +
`${summary.inventory_items || 0} inventory items, ` +
`${summary.friends || 0} friends, ` +
`${summary.locations || 0} locations, ` +
`${summary.files || 0} files.`);
`${summary.files || 0} files.`;
this.selectedFile = null;
const fileInput = document.getElementById('inputFile');
if (fileInput) {
@ -167,7 +179,7 @@ export default {
}
} catch (error) {
console.error('Data import failed', error);
alert('Data import failed: ' + error);
this.importError = 'Data import failed: ' + error;
}
},
async exportData() {