stash
This commit is contained in:
parent
d606de8773
commit
2218cc3543
12 changed files with 393 additions and 2204 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue