stash
This commit is contained in:
parent
2f8683add1
commit
cfcc2c15d3
15 changed files with 644 additions and 77 deletions
|
|
@ -10,7 +10,8 @@ from rest_framework.authtoken.views import ObtainAuthToken
|
|||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser, AccountPreference
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
|
||||
split_userhandle_or_throw
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
from hostadmin.models import Domain
|
||||
|
|
@ -101,6 +102,9 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def getUserInfo(request):
|
||||
"""Get or update the authenticated local user's own account info. Only usable by the
|
||||
account owner on their own home server - see getUserProfile for viewing another (friend)
|
||||
user's public profile."""
|
||||
user = request.user
|
||||
if request.method == 'PATCH':
|
||||
old_file = user.profile_picture
|
||||
|
|
@ -137,6 +141,30 @@ def getUserInfo(request):
|
|||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def getUserProfile(request, handle):
|
||||
"""Get another local user's public profile by handle (username@domain), e.g. so a friend
|
||||
can look up someone's avatar. The caller must be a friend of that user (or the user
|
||||
itself, signing with their own known identity rather than their local credentials)."""
|
||||
try:
|
||||
username, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
return Response(status=400)
|
||||
try:
|
||||
target = ToolshedUser.objects.get(username=username, domain=domain)
|
||||
except ToolshedUser.DoesNotExist:
|
||||
return Response(status=404)
|
||||
if target not in request.user.friends_or_self():
|
||||
return Response(status=403)
|
||||
return Response({
|
||||
'username': target.username,
|
||||
'domain': target.domain,
|
||||
'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([])
|
||||
@authentication_classes([])
|
||||
|
|
@ -212,6 +240,7 @@ router.register(r'users', UserViewSet)
|
|||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('user/', getUserInfo),
|
||||
path('user/<str:handle>/', getUserProfile),
|
||||
path('register/', registerUser),
|
||||
path('token/', UserAuthToken.as_view()),
|
||||
path('preferences/', preference_definitions),
|
||||
|
|
|
|||
|
|
@ -106,11 +106,19 @@ def authenticate_request_against_local_users(request, raw_request_body):
|
|||
|
||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
return authenticate_request_against_known_identities(
|
||||
request, request.body.decode('utf-8')), None
|
||||
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
|
||||
# Returning a bare None (rather than a (None, None) tuple) tells DRF this
|
||||
# authenticator doesn't apply, so it moves on to the next authenticator in the
|
||||
# authentication_classes list instead of treating the request as authenticated
|
||||
# with an empty user.
|
||||
if identity is None:
|
||||
return None
|
||||
return identity, None
|
||||
|
||||
|
||||
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
return authenticate_request_against_local_users(
|
||||
request, request.body.decode('utf-8')), None
|
||||
user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
|
||||
if user is None:
|
||||
return None
|
||||
return user, None
|
||||
|
|
|
|||
|
|
@ -355,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
|
||||
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||
"""Tests for GET /auth/user/<handle>/ - viewing another (friend) user's public profile."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity)
|
||||
self.anonymous_client = Client(SERVER_NAME='testserver')
|
||||
self.client = SignatureAuthClient()
|
||||
|
||||
def test_view_friend_profile(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.client.get(target, self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
self.assertNotIn('email', reply.json())
|
||||
|
||||
def test_view_own_profile_via_handle(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.client.get(target, self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
|
||||
def test_view_profile_not_friend(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.client.get(target, self.f['ext_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_view_profile_unknown_user(self):
|
||||
target = '/auth/user/nosuchuser@example.com/'
|
||||
reply = self.client.get(target, self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_view_profile_bad_handle(self):
|
||||
target = '/auth/user/notahandle/'
|
||||
reply = self.client.get(target, self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_view_profile_unauthenticated(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.anonymous_client.get(target)
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
|
||||
class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ from files.models import File
|
|||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def media_urls(request, hash_path):
|
||||
# Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware,
|
||||
# configured in settings.py) adds them to every Django response automatically, so
|
||||
# setting them manually on these responses would just be redundant. The one exception
|
||||
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it
|
||||
# follows the X-Accel-Redirect and serves the file itself, so the CORS header for that
|
||||
# case has to be configured in nginx's `location /redirect_media/` block instead.
|
||||
try:
|
||||
file = File.objects.filter(
|
||||
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
||||
|
|
@ -29,14 +35,10 @@ def media_urls(request, hash_path):
|
|||
content_type=file.mime_type,
|
||||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}) # TODO Expires and Cache-Control
|
||||
else:
|
||||
return HttpResponse(status=status.HTTP_200_OK,
|
||||
content_type=file.mime_type,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
content=open(file.file.path, 'rb').read())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ def inventory_items(identity):
|
|||
except ToolshedUser.DoesNotExist:
|
||||
pass
|
||||
for friend in identity.friends.all():
|
||||
if friend_user := friend.user.get():
|
||||
friend_user = friend.user.first()
|
||||
if friend_user:
|
||||
for item in friend_user.inventory_items.all():
|
||||
if item.availability_policy != 'private':
|
||||
yield item
|
||||
|
|
@ -52,13 +53,25 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
|||
instance.delete()
|
||||
|
||||
|
||||
def matches_query(item, query):
|
||||
query = query.lower()
|
||||
if query in item.name.lower():
|
||||
return True
|
||||
if item.description and query in item.description.lower():
|
||||
return True
|
||||
if any(query in tag.name.lower() for tag in item.tags.all()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def search_inventory_items(request):
|
||||
query = request.query_params.get('query')
|
||||
if query:
|
||||
return Response(InventoryItemSerializer(inventory_items(request.user), many=True).data)
|
||||
matching_items = [item for item in inventory_items(request.user) if matches_query(item, query)]
|
||||
return Response(InventoryItemSerializer(matching_items, many=True).data)
|
||||
return Response({'error': 'No query provided.'}, status=400)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ class CategorySerializer(serializers.ModelSerializer):
|
|||
return obj.get_handle()
|
||||
|
||||
def to_representation(self, instance):
|
||||
return instance.get_handle()
|
||||
return instance.name
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return resolve_category_handle(data.split("/")[-1])
|
||||
|
|
@ -129,7 +129,7 @@ class ItemPropertySerializer(serializers.ModelSerializer):
|
|||
return obj.property.get_handle()
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {'value': instance.value, 'name': instance.property.name, 'handle': instance.property.get_handle()}
|
||||
return {'value': instance.value, 'name': instance.property.name}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
prop = resolve_property_handle(data.get('name') or data.get('handle'))
|
||||
|
|
@ -151,7 +151,7 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
||||
|
||||
def get_tags(self, obj):
|
||||
return [tag.get_handle() for tag in obj.tags.all()]
|
||||
return [tag.name for tag in obj.tags.all()]
|
||||
|
||||
def to_internal_value(self, data):
|
||||
files = data.pop('files', [])
|
||||
|
|
|
|||
264
backend/toolshed/tests/test_offlinedata.py
Normal file
264
backend/toolshed/tests/test_offlinedata.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
from django.core.files.base import ContentFile
|
||||
from django.test import Client
|
||||
|
||||
from authentication.models import AccountPreference, ToolshedUser
|
||||
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
|
||||
from files.models import File
|
||||
from toolshed.models import InventoryItem, ItemProperty, StorageLocation
|
||||
from toolshed.offlinedata import import_inventory, inventory_rows, rows_to_csv
|
||||
from toolshed.tests import CategoryTestMixin, LocationTestMixin, PropertyTestMixin, TagTestMixin
|
||||
|
||||
anonymous_client = Client()
|
||||
client = SignatureAuthClient()
|
||||
|
||||
|
||||
class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin):
|
||||
"""Shared fixture setup for the delete-data and delete-account test cases."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
self.prepare_categories()
|
||||
self.prepare_locations()
|
||||
|
||||
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
|
||||
|
||||
self.f['shared_file'] = File.objects.create(
|
||||
file=ContentFile(b'shared', 'shared'), mime_type='text/plain', hash='shared')
|
||||
self.f['orphan_file'] = File.objects.create(
|
||||
file=ContentFile(b'orphan', 'orphan'), mime_type='text/plain', hash='orphan')
|
||||
|
||||
self.f['item1'] = InventoryItem.objects.create(
|
||||
owner=self.f['local_user1'], owned_quantity=1, name='item1', category=self.f['cat1'])
|
||||
self.f['item1'].files.add(self.f['orphan_file'])
|
||||
|
||||
self.f['item_other_user'] = InventoryItem.objects.create(
|
||||
owner=self.f['local_user2'], owned_quantity=1, name='item2', category=self.f['cat1'])
|
||||
self.f['item_other_user'].files.add(self.f['shared_file'])
|
||||
|
||||
self.f['item1'].files.add(self.f['shared_file'])
|
||||
|
||||
AccountPreference.objects.create(user=self.f['local_user1'], key='theme', value='dark')
|
||||
|
||||
self.f['local_user1'].profile_picture = self.f['orphan_file']
|
||||
self.f['local_user1'].save()
|
||||
|
||||
|
||||
class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||
|
||||
def test_delete_data_anonymous(self):
|
||||
response = anonymous_client.delete('/api/account_data/')
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_delete_data_removes_all_owned_data_but_keeps_account(self):
|
||||
response = client.delete('/api/account_data/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
summary = response.json()
|
||||
self.assertEqual(summary['inventory_items'], 1)
|
||||
self.assertEqual(summary['locations'], 4)
|
||||
self.assertEqual(summary['settings'], 1)
|
||||
self.assertEqual(summary['friends'], 1)
|
||||
|
||||
# the account itself survives - this wipes data, it doesn't close the account
|
||||
self.f['local_user1'].refresh_from_db()
|
||||
self.assertTrue(ToolshedUser.objects.filter(username='testuser1').exists())
|
||||
self.assertIsNone(self.f['local_user1'].profile_picture)
|
||||
|
||||
self.assertFalse(InventoryItem.global_objects.filter(owner_id=self.f['local_user1'].id).exists())
|
||||
self.assertFalse(StorageLocation.objects.filter(owner_id=self.f['local_user1'].id).exists())
|
||||
self.assertFalse(AccountPreference.objects.filter(user_id=self.f['local_user1'].id).exists())
|
||||
self.assertEqual(self.f['local_user1'].public_identity.friends.count(), 0)
|
||||
|
||||
# orphaned file (only referenced by the deleted user/items) is gone
|
||||
self.assertFalse(File.objects.filter(hash='orphan').exists())
|
||||
# file still referenced by the other user's item survives
|
||||
self.assertTrue(File.objects.filter(hash='shared').exists())
|
||||
|
||||
# the other user's data and identity/friend relation to the deleted identity are untouched
|
||||
self.f['local_user2'].refresh_from_db()
|
||||
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
|
||||
|
||||
|
||||
class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||
|
||||
def test_delete_account_anonymous(self):
|
||||
response = anonymous_client.delete('/api/account/')
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_delete_account_removes_data_and_closes_account(self):
|
||||
user1_id = self.f['local_user1'].id
|
||||
identity_id = self.f['local_user1'].public_identity_id
|
||||
|
||||
response = client.delete('/api/account/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
summary = response.json()
|
||||
self.assertEqual(summary['inventory_items'], 1)
|
||||
self.assertEqual(summary['locations'], 4)
|
||||
self.assertEqual(summary['settings'], 1)
|
||||
self.assertEqual(summary['friends'], 1)
|
||||
self.assertTrue(summary['account'])
|
||||
|
||||
# the account itself is gone
|
||||
self.assertFalse(ToolshedUser.objects.filter(id=user1_id).exists())
|
||||
self.assertFalse(InventoryItem.global_objects.filter(owner_id=user1_id).exists())
|
||||
self.assertFalse(StorageLocation.objects.filter(owner_id=user1_id).exists())
|
||||
self.assertFalse(AccountPreference.objects.filter(user_id=user1_id).exists())
|
||||
|
||||
# the underlying identity is kept, so remote friends/history referencing it stay intact
|
||||
from authentication.models import KnownIdentity
|
||||
self.assertTrue(KnownIdentity.objects.filter(id=identity_id).exists())
|
||||
|
||||
# orphaned file (only referenced by the deleted user/items) is gone
|
||||
self.assertFalse(File.objects.filter(hash='orphan').exists())
|
||||
# file still referenced by the other user's item survives
|
||||
self.assertTrue(File.objects.filter(hash='shared').exists())
|
||||
|
||||
# the other user's data and identity/friend relation to the deleted identity are untouched
|
||||
self.f['local_user2'].refresh_from_db()
|
||||
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
|
||||
self.assertEqual(self.f['local_user2'].public_identity.friends.count(), 0)
|
||||
|
||||
|
||||
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||
ToolshedTestCase):
|
||||
"""Properties must round-trip through export/import even when their value contains a
|
||||
comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of
|
||||
the 'properties' CSV cell would misinterpret as a field/entry separator.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
self.prepare_categories()
|
||||
self.prepare_tags()
|
||||
self.prepare_properties()
|
||||
|
||||
def test_property_values_with_comma_and_equals_round_trip(self):
|
||||
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='widget')
|
||||
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
|
||||
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
|
||||
|
||||
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||
|
||||
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(imported, 1)
|
||||
|
||||
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='widget')
|
||||
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
|
||||
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})
|
||||
|
||||
def test_legacy_comma_equals_format_is_still_importable(self):
|
||||
handle1 = self.f['prop1'].get_handle()
|
||||
handle2 = self.f['prop2'].get_handle()
|
||||
csv_data = (
|
||||
'name,properties\r\n'
|
||||
f'legacy widget,"{handle1}=value1, {handle2}=value2"\r\n'
|
||||
).encode('utf-8')
|
||||
|
||||
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(imported, 1)
|
||||
|
||||
item = InventoryItem.objects.get(owner=self.f['local_user1'], name='legacy widget')
|
||||
values = {ip.property.name: ip.value for ip in item.itemproperty_set.select_related('property')}
|
||||
self.assertEqual(values, {'prop1': 'value1', 'prop2': 'value2'})
|
||||
|
||||
def test_item_without_properties_imports_cleanly(self):
|
||||
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='bare item')
|
||||
|
||||
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||
|
||||
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(imported, 1)
|
||||
|
||||
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='bare item')
|
||||
self.assertEqual(list(new_item.itemproperty_set.all()), [])
|
||||
|
||||
def test_category_and_tags_round_trip(self):
|
||||
item = InventoryItem.objects.create(
|
||||
owner=self.f['local_user1'], name='cat and tags item', category=self.f['cat1'])
|
||||
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
|
||||
|
||||
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||
|
||||
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(imported, 1)
|
||||
|
||||
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='cat and tags item')
|
||||
self.assertEqual(new_item.category, self.f['cat1'])
|
||||
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
|
||||
|
||||
def test_unknown_property_handle_skips_item_with_error(self):
|
||||
csv_data = (
|
||||
'name,properties\r\n'
|
||||
'ghost widget,test#property:doesnotexist=x\r\n'
|
||||
).encode('utf-8')
|
||||
|
||||
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
|
||||
self.assertEqual(imported, 0)
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn('doesnotexist', errors[0])
|
||||
self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], name='ghost widget').exists())
|
||||
|
||||
def test_property_value_with_quote_character_round_trips(self):
|
||||
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='quoted widget')
|
||||
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='12" screen')
|
||||
|
||||
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||
|
||||
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(imported, 1)
|
||||
|
||||
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='quoted widget')
|
||||
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
|
||||
self.assertEqual(values, {'prop1': '12" screen'})
|
||||
|
||||
|
||||
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||
ToolshedTestCase):
|
||||
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
|
||||
clients), rather than calling the internal helper functions directly - this is what a real
|
||||
export/import round trip between two accounts looks like.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
self.prepare_categories()
|
||||
self.prepare_tags()
|
||||
self.prepare_properties()
|
||||
|
||||
def test_export_then_import_preserves_category_tags_and_properties(self):
|
||||
import base64
|
||||
|
||||
item = InventoryItem.objects.create(
|
||||
owner=self.f['local_user1'], name='drill', description='cordless drill',
|
||||
category=self.f['cat1'], availability_policy='friends', owned_quantity=2)
|
||||
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
|
||||
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
|
||||
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
|
||||
|
||||
export_reply = client.get('/api/export/', self.f['local_user1'])
|
||||
self.assertEqual(export_reply.status_code, 200)
|
||||
zip_bytes = export_reply.content
|
||||
|
||||
import_reply = client.post('/api/import/', self.f['local_user2'],
|
||||
{'zip': base64.b64encode(zip_bytes).decode('ascii')})
|
||||
self.assertEqual(import_reply.status_code, 200)
|
||||
summary = import_reply.json()
|
||||
self.assertEqual(summary['inventory_items'], 1)
|
||||
self.assertEqual(summary['errors'], [])
|
||||
|
||||
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill')
|
||||
self.assertEqual(new_item.category, self.f['cat1'])
|
||||
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
|
||||
|
||||
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
|
||||
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ def create_test_data():
|
|||
if KnownIdentity.objects.filter(username='test_b').exists():
|
||||
identity = KnownIdentity.objects.get(username='test_b')
|
||||
identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost',
|
||||
public_key='4b0dffe21764c591762615ef84cfea7fd4055fab3e9f58ae077fd8c79c34af91')
|
||||
public_key='14c44f03c3a0406934cf3a27b0eeedae8a08a3f436690c7103eca13435172a8c')
|
||||
# pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40'
|
||||
identity.save()
|
||||
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,28 @@ http {
|
|||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /media {
|
||||
proxy_set_header Host $host:$server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /redirect_media/ {
|
||||
internal;
|
||||
alias /var/www/userfiles/;
|
||||
# This location serves the file directly, bypassing Django (and therefore
|
||||
# django-cors-headers) entirely - it's the target of the X-Accel-Redirect
|
||||
# response from files/media_urls.py, used when SERVE_X_ACCEL_REDIRECT=True.
|
||||
# CORS headers must be added here explicitly since nothing else will.
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Authorization, Accept, Content-Type, Origin, User-Agent' always;
|
||||
}
|
||||
|
||||
location /docs {
|
||||
proxy_pass http://backend/docs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ services:
|
|||
- ../deploy/dev/instance_a/a.env:/code/.env
|
||||
- ../deploy/dev/instance_a/testdata.py:/code/testdata.py
|
||||
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
|
||||
- ../deploy/dev/instance_a/userfiles:/code/userfiles
|
||||
expose:
|
||||
- 8000
|
||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py migrate; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||
|
||||
backend-b:
|
||||
build:
|
||||
|
|
@ -23,9 +24,10 @@ services:
|
|||
- ../deploy/dev/instance_b/b.env:/code/.env
|
||||
- ../deploy/dev/instance_b/testdata.py:/code/testdata.py
|
||||
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
|
||||
- ../deploy/dev/instance_b/userfiles:/code/userfiles
|
||||
expose:
|
||||
- 8000
|
||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py migrate; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
|
|
@ -57,6 +59,7 @@ services:
|
|||
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dev/instance_a/dns.json:/var/www/dns.json:ro
|
||||
- ./dev/instance_a/domains.json:/var/www/domains.json:ro
|
||||
- ./dev/instance_a/userfiles:/var/www/userfiles:ro
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.3:5353:5353"
|
||||
|
|
@ -67,6 +70,7 @@ services:
|
|||
dockerfile: dev/Dockerfile.proxy
|
||||
volumes:
|
||||
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dev/instance_b/userfiles:/var/www/userfiles:ro
|
||||
ports:
|
||||
- "127.0.0.2:8080:8080"
|
||||
|
||||
|
|
|
|||
77
frontend/src/components/UserNameTag.vue
Normal file
77
frontend/src/components/UserNameTag.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="d-flex align-items-center">
|
||||
<authenticated-image v-if="profilePictureUrl"
|
||||
:src="profilePictureUrl"
|
||||
:owner="displayUsername"
|
||||
:alt="displayUsername"
|
||||
:title="displayUsername"
|
||||
img-class="rounded-circle mr-2"
|
||||
:width="width"
|
||||
:height="height"
|
||||
fit="cover"/>
|
||||
<span>{{ displayUsername }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import {mapGetters, mapActions} from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'UserNameTag',
|
||||
components: {
|
||||
AuthenticatedImage
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 32
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 32
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['allUserProfiles']),
|
||||
displayUsername() {
|
||||
return this.user.username || this.user.owner || '';
|
||||
},
|
||||
profilePictureUrl() {
|
||||
// Check for explicitly set profile picture from allUserProfiles getter
|
||||
const storedProfile = this.allUserProfiles[this.displayUsername];
|
||||
if (storedProfile?.profile_picture?.name) {
|
||||
return storedProfile.profile_picture.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchFriendProfile']),
|
||||
fetchProfileIfNeeded() {
|
||||
// Fetch profile if not already in store
|
||||
if (!this.allUserProfiles[this.displayUsername] && this.displayUsername) {
|
||||
this.fetchFriendProfile({username: this.displayUsername});
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchProfileIfNeeded();
|
||||
},
|
||||
watch: {
|
||||
displayUsername() {
|
||||
this.fetchProfileIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
|
@ -282,6 +282,23 @@ class ServerSet {
|
|||
function ServerSetUnion(serverSets) {
|
||||
return new Proxy(serverSets, {
|
||||
get: function (target, prop, receiver) {
|
||||
// Note: 'add' must be checked before the generic funcs-forwarding branch below,
|
||||
// because ServerSet.prototype also defines its own `add(server)` method (for
|
||||
// adding a raw server address string to a single ServerSet). Without this check
|
||||
// first, `funcs.includes('add')` would always be true and the union-specific
|
||||
// "add a ServerSet to this union" logic below would never be reached.
|
||||
if (prop === 'add') {
|
||||
return function (serverset) {
|
||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
throw new Error('no serverset')
|
||||
}
|
||||
if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
console.warn('serverset already in union', serverset)
|
||||
return
|
||||
}
|
||||
target.push(serverset)
|
||||
}
|
||||
}
|
||||
const funcs = Object.getOwnPropertyNames(ServerSet.prototype)
|
||||
if (funcs.includes(prop)) {
|
||||
return async function (...args) {
|
||||
|
|
@ -295,17 +312,6 @@ function ServerSetUnion(serverSets) {
|
|||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
} else if (prop === 'add') {
|
||||
return function (serverset) {
|
||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
throw new Error('no serverset')
|
||||
}
|
||||
if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
console.warn('serverset already in union', serverset)
|
||||
return
|
||||
}
|
||||
target.push(serverset)
|
||||
}
|
||||
} else {
|
||||
return target[prop]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export default createStore({
|
|||
keypair: null,
|
||||
remember: false,
|
||||
friends: [],
|
||||
friendProfiles: {},
|
||||
item_map: {},
|
||||
home_servers: null,
|
||||
all_friends_servers: null,
|
||||
|
|
@ -97,6 +98,12 @@ export default createStore({
|
|||
setFriends(state, friends) {
|
||||
state.friends = friends;
|
||||
},
|
||||
setFriendProfile(state, {username, profile}) {
|
||||
state.friendProfiles = {...state.friendProfiles, [username]: profile};
|
||||
},
|
||||
setFriendProfiles(state, profiles) {
|
||||
state.friendProfiles = profiles;
|
||||
},
|
||||
setHomeServers(state, home_servers) {
|
||||
state.home_servers = home_servers;
|
||||
},
|
||||
|
|
@ -326,11 +333,21 @@ export default createStore({
|
|||
const home = await dispatch('getHomeServers')
|
||||
servers.add(home)
|
||||
for (const friend of friends) {
|
||||
try {
|
||||
const s = await dispatch('lookupServer', {username: friend.username})
|
||||
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
||||
} catch (e) {
|
||||
// Don't let a single unresolvable/unreachable friend abort the whole
|
||||
// search/federation lookup - just skip them and continue.
|
||||
console.error('could not resolve server for friend', friend.username, e)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
})()
|
||||
})().catch(e => {
|
||||
// Don't cache a permanently-rejected promise - allow a retry next time.
|
||||
state.all_friends_servers = null
|
||||
throw e
|
||||
})
|
||||
commit('setAllFriendsServers', promise)
|
||||
return promise
|
||||
},
|
||||
|
|
@ -376,6 +393,20 @@ export default createStore({
|
|||
commit('setFriends', data)
|
||||
return data
|
||||
},
|
||||
async fetchFriendProfile({commit, dispatch, getters}, {username}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username});
|
||||
// username here is already a full handle (username@domain), see FriendSerializer
|
||||
const profile = await servers.get(getters.signAuth, '/auth/user/' + username + '/');
|
||||
if (profile) {
|
||||
commit('setFriendProfile', {username, profile});
|
||||
}
|
||||
return profile;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch profile for ${username}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async fetchFriendRequests({state, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/friendrequests/')
|
||||
|
|
@ -612,6 +643,14 @@ export default createStore({
|
|||
|
||||
return state.user !== null && (state.token !== null || state.keypair !== null)
|
||||
},
|
||||
allUserProfiles(state) {
|
||||
// Combine user's own profile with all friend profiles
|
||||
const combined = {...state.friendProfiles};
|
||||
if (state.user && state.user_profile) {
|
||||
combined[state.user] = state.user_profile;
|
||||
}
|
||||
return combined;
|
||||
},
|
||||
signAuth(state) {
|
||||
return createSignAuth(state.user, state.keypair.signSk)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@
|
|||
</td>
|
||||
</tr>
|
||||
<tr v-for="friend in friendslist" :key="friend.name">
|
||||
<td>{{ friend.username }}</td>
|
||||
<td>
|
||||
<user-name-tag :user="friend"/>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
|
||||
<td class="table-action">
|
||||
<!--a href="#" class="align-middle">
|
||||
|
|
@ -76,7 +78,7 @@
|
|||
<tr v-for="request in requests" :key="request.befriender">
|
||||
<td>{{ request.befriender }}</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
{{ request.befriender_public_key.slice(0, 32) }}...
|
||||
{{ request.befriender_public_key?.slice(0, 32) ?? 'N/A' }}...
|
||||
</td>
|
||||
<td class="table-action">
|
||||
<button class="btn btn-sm btn-success" @click="tryAcceptFriend(request)">
|
||||
|
|
@ -100,14 +102,18 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
|
||||
export default {
|
||||
name: 'Inventory',
|
||||
components: {
|
||||
BaseLayout,
|
||||
AuthenticatedImage,
|
||||
UserNameTag,
|
||||
...BIcons
|
||||
},
|
||||
data() {
|
||||
|
|
@ -119,6 +125,7 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user']),
|
||||
username() {
|
||||
return this.$route.params.username
|
||||
},
|
||||
|
|
@ -128,12 +135,15 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['fetchFriends', "lookupServer", "requestFriend", "acceptFriend", "fetchFriendRequests",
|
||||
"declineFriend", "dropFriend"]),
|
||||
"declineFriend", "dropFriend", "getFriendServers", "fetchFriendProfile"]),
|
||||
fetchContent() {
|
||||
this.fetchFriends().then((friends) => {
|
||||
friends.map((friend) => {
|
||||
// Handle both object and array formats
|
||||
const friendsList = Array.isArray(friends) ? friends : Object.values(friends);
|
||||
friendsList.map((friend) => {
|
||||
this.lookupServer(friend).then((server) => {
|
||||
this.friends[friend.username] = {...friend, server: server}
|
||||
this.fetchFriendProfile({username: friend.username});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@
|
|||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Search Inventories</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<SearchBox @change=""/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Results for "{{ query }}"</h5>
|
||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
<button v-else @click="layout = 'grid'" class="btn">
|
||||
<b-icon-grid></b-icon-grid>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-striped" v-if="layout === 'table'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Name</th>
|
||||
|
|
@ -29,7 +29,9 @@
|
|||
<td>
|
||||
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
|
||||
</td>
|
||||
<td>{{ item.owner }}</td>
|
||||
<td>
|
||||
<user-name-tag :user="item"/>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||
<td class="table-action">
|
||||
<!--<router-link :to="`/inventory/${item.id}/edit`">
|
||||
|
|
@ -43,6 +45,29 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body" v-else>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in search_results"
|
||||
:key="item.id">
|
||||
<div class="card">
|
||||
<authenticated-image v-if="only_images(item.files).length > 0"
|
||||
:src="only_images(item.files)[0].name"
|
||||
:owner="only_images(item.files)[0].owner"
|
||||
class="card-img-top img-preview"/>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-0">
|
||||
<router-link :to="`/inventory/${item.id}`">
|
||||
{{ item.name }}
|
||||
</router-link></h5>
|
||||
<div class="card-text text-black-50">
|
||||
<user-name-tag :user="item" :width="24" :height="24"/>
|
||||
<br>
|
||||
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -57,11 +82,15 @@ import {mapActions} from 'vuex';
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import SearchBox from "@/components/SearchBox.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
|
||||
export default {
|
||||
name: 'Search',
|
||||
components: {
|
||||
SearchBox,
|
||||
AuthenticatedImage,
|
||||
UserNameTag,
|
||||
...BIcons,
|
||||
BaseLayout
|
||||
},
|
||||
|
|
@ -74,19 +103,37 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
search_results: [],
|
||||
layout: 'table'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchSearchResults']),
|
||||
only_images(files) {
|
||||
if (!files) return [];
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
async mounted() {
|
||||
loadResults() {
|
||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
||||
this.search_results = results;
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query: {
|
||||
immediate: false,
|
||||
handler() {
|
||||
this.loadResults();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadResults();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.img-preview {
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue