Compare commits
18 commits
9f3dc7c993
...
6a9ed35bcd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a9ed35bcd | |||
| 9c9f0f2d9b | |||
| 15dad8e3be | |||
| 18a3e40435 | |||
| 4671860fa4 | |||
| 2218cc3543 | |||
| d606de8773 | |||
| 341967a611 | |||
| 7276750c66 | |||
| 2bb6624d50 | |||
| ebd0db4b43 | |||
| 8cba1f52e8 | |||
| fdb83f65d4 | |||
| 22931452e3 | |||
| 9685b4020e | |||
| 6ee5ae38b1 | |||
| d56784eb8d | |||
| f46eff65fd |
80 changed files with 3187 additions and 3956 deletions
|
|
@ -1,8 +1,15 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group as DjangoPermissionGroup
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
|
||||
|
||||
# django.contrib.auth's own permission-groups model is unused (this app authenticates via
|
||||
# signature_auth, not Django's built-in permission system) and otherwise collides in the admin
|
||||
# sidebar with our own Group (the actor-type group), which is registered below under the same
|
||||
# "Groups" label.
|
||||
admin.site.unregister(DjangoPermissionGroup)
|
||||
|
||||
|
||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
|
||||
|
|
|
|||
|
|
@ -18,47 +18,6 @@ from hostadmin.models import Domain
|
|||
|
||||
router = routers.SimpleRouter()
|
||||
|
||||
# Schema for account-level preferences a client may store server-side (see AccountPreference);
|
||||
# device-level preferences stay in the browser's local storage instead.
|
||||
PREFERENCE_DEFINITIONS = [
|
||||
{
|
||||
'key': 'ui.compact_mode',
|
||||
'type': 'boolean',
|
||||
'default': False,
|
||||
'label': 'Compact mode',
|
||||
'description': 'Show denser item rows and reduce spacing in lists.',
|
||||
},
|
||||
{
|
||||
'key': 'ui.default_search_scope',
|
||||
'type': 'enum',
|
||||
'options': ['inventory', 'friends', 'all'],
|
||||
'default': 'inventory',
|
||||
'label': 'Default search scope',
|
||||
'description': 'Choose where the global search starts.',
|
||||
},
|
||||
{
|
||||
'key': 'notifications.desktop_enabled',
|
||||
'type': 'boolean',
|
||||
'default': True,
|
||||
'label': 'Desktop notifications',
|
||||
'description': 'Enable in-browser notifications for important updates.',
|
||||
},
|
||||
{
|
||||
'key': 'files.max_upload_mb',
|
||||
'type': 'integer',
|
||||
'default': 25,
|
||||
'label': 'Default upload size limit (MB)',
|
||||
'description': 'Used as a prefill hint in upload dialogs.',
|
||||
},
|
||||
{
|
||||
'key': 'ui.experimental_flags',
|
||||
'type': 'json',
|
||||
'default': {},
|
||||
'label': 'Experimental flags',
|
||||
'description': 'Optional JSON toggles for feature previews.',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class UserAuthToken(ObtainAuthToken):
|
||||
|
||||
|
|
@ -199,14 +158,6 @@ def registerUser(request):
|
|||
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([])
|
||||
@authentication_classes([])
|
||||
def preference_definitions(request):
|
||||
"""Return the schema (types, defaults, labels) for the account preferences clients may set."""
|
||||
return Response(PREFERENCE_DEFINITIONS)
|
||||
|
||||
|
||||
@api_view(['GET', 'PUT'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
|
|
@ -241,7 +192,6 @@ urlpatterns = [
|
|||
path('user/<str:handle>/', getUserProfile),
|
||||
path('register/', registerUser),
|
||||
path('token/', UserAuthToken.as_view()),
|
||||
path('preferences/', preference_definitions),
|
||||
path('self/preferences/', account_preferences),
|
||||
path('self/preferences/<str:key>/', account_preference_detail),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from nacl.exceptions import BadSignatureError
|
|||
from nacl.signing import VerifyKey
|
||||
from rest_framework import authentication
|
||||
|
||||
from authentication.models import KnownIdentity, ToolshedUser
|
||||
from authentication.models import Group, KnownIdentity, ToolshedUser
|
||||
|
||||
|
||||
def split_userhandle_or_throw(userhandle):
|
||||
|
|
@ -22,6 +22,21 @@ def split_grouphandle_or_throw(grouphandle):
|
|||
return split_userhandle_or_throw(grouphandle[1:])
|
||||
|
||||
|
||||
def resolve_owner_handle(handle):
|
||||
"""Resolves a "user@domain" or "+name@domain" handle to (owner_user, owner_group), exactly one set. Raises ValueError if the handle doesn't parse at all (no '@'); returns (None, None) if it parses but names nothing that exists."""
|
||||
is_group = handle.startswith('+')
|
||||
name, domain = split_userhandle_or_throw(handle[1:] if is_group else handle)
|
||||
if is_group:
|
||||
try:
|
||||
return None, Group.objects.get(name=name, domain=domain)
|
||||
except Group.DoesNotExist:
|
||||
return None, None
|
||||
try:
|
||||
return ToolshedUser.objects.get(username=name, domain=domain), None
|
||||
except ToolshedUser.DoesNotExist:
|
||||
return None, None
|
||||
|
||||
|
||||
def verify_request(request, raw_request_body):
|
||||
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
||||
|
||||
|
|
|
|||
|
|
@ -412,26 +412,26 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.client = SignatureAuthClient()
|
||||
|
||||
def test_friend_local(self):
|
||||
reply = self.client.get('/api/friends/', self.f['local_user1'])
|
||||
reply = self.client.get('/api/v1/friends/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_friend_external(self):
|
||||
reply = self.client.get('/api/friends/', self.f['ext_user1'])
|
||||
reply = self.client.get('/api/v1/friends/', self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_friend_fail(self):
|
||||
reply = self.anonymous_client.get('/api/friends/')
|
||||
reply = self.anonymous_client.get('/api/v1/friends/')
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_friend_fail2(self):
|
||||
target = "/api/friends/"
|
||||
target = "/api/v1/friends/"
|
||||
signature = self.f['local_user1'].sign("http://testserver2" + target)
|
||||
header = {'HTTP_AUTHORIZATION': 'Signature ' + str(self.f['local_user1']) + ':' + signature}
|
||||
reply = self.anonymous_client.get(target, **header)
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_friend_fail3(self):
|
||||
target = "/api/friends/"
|
||||
target = "/api/v1/friends/"
|
||||
unknown_user = DummyExternalUser('extuser3', 'external.org', False)
|
||||
signature = unknown_user.sign("http://testserver" + target)
|
||||
header = {'HTTP_AUTHORIZATION': 'Signature ' + str(unknown_user) + ':' + signature}
|
||||
|
|
@ -439,7 +439,7 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_friend_fail4(self):
|
||||
target = "/api/friends/"
|
||||
target = "/api/v1/friends/"
|
||||
signature = self.f['local_user1'].sign("http://testserver" + target)
|
||||
header = {'HTTP_AUTHORIZATION': 'Auth ' + str(self.f['local_user1']) + ':' + signature}
|
||||
reply = self.anonymous_client.get(target, **header)
|
||||
|
|
|
|||
|
|
@ -19,13 +19,14 @@ urlpatterns = [
|
|||
path('djangoadmin/', admin.site.urls),
|
||||
path('auth/', include('authentication.api')),
|
||||
path('admin/', include('hostadmin.api')),
|
||||
path('api/', include('toolshed.api.friend')),
|
||||
path('api/', include('toolshed.api.group')),
|
||||
path('api/', include('toolshed.api.idmap')),
|
||||
path('api/', include('toolshed.api.inventory')),
|
||||
path('api/', include('toolshed.api.info')),
|
||||
path('api/', include('toolshed.api.files')),
|
||||
path('api/', include('toolshed.api.offlinedata')),
|
||||
path('api/version/', include('toolshed.api.version')),
|
||||
path('api/v1/', include('toolshed.api.info')),
|
||||
path('api/v1/', include('toolshed.api.friend')),
|
||||
path('api/v1/', include('toolshed.api.group')),
|
||||
path('api/v1/', include('toolshed.api.idmap')),
|
||||
path('api/v1/', include('toolshed.api.inventory')),
|
||||
path('api/v1/', include('toolshed.api.files')),
|
||||
path('api/v1/', include('toolshed.api.offlinedata')),
|
||||
path('media/', include('files.media_urls')),
|
||||
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ THUMBNAIL_SIZES = (32, 64, 256)
|
|||
|
||||
def _accessible_files(request):
|
||||
# Shared by media_urls and thumbnail_urls: a file is visible if the requester is
|
||||
# friends-or-self with whatever references it (item, profile picture), or it's their own staged photo.
|
||||
# friends-or-self with whatever references it (item, profile picture), a member of the group
|
||||
# that owns the item it's attached to, or it's their own staged photo.
|
||||
return File.objects.filter(
|
||||
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
||||
Q(connected_items__owner__in=request.user.friends_or_self(), connected_items__is_deleted=False) |
|
||||
Q(connected_items__owner_group__in=request.user.member_of_groups.all(), connected_items__is_deleted=False) |
|
||||
Q(profile_picture_users__in=request.user.friends_or_self()) |
|
||||
Q(staged_by_workflows__owner__in=request.user.user.all())
|
||||
).distinct()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ from django.core.files.base import ContentFile
|
|||
from django.core.files.storage import DefaultStorage, default_storage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import Client, override_settings
|
||||
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
||||
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin, GroupTestMixin
|
||||
from toolshed.models import InventoryItem
|
||||
from toolshed.tests import InventoryTestMixin
|
||||
from nacl.hash import sha256
|
||||
from nacl.encoding import HexEncoder
|
||||
|
|
@ -186,6 +187,16 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
|||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertTrue('X-Accel-Redirect' not in reply.headers)
|
||||
|
||||
def test_file_url_only_connected_via_deleted_item(self):
|
||||
# test_file2 is only reachable through item1; soft-deleting it doesn't sever the files
|
||||
# M2M row, so this would regress to serving test_file2 as if item1 were still live if
|
||||
# _accessible_files ever stops excluding soft-deleted items again.
|
||||
self.f['item1'].delete()
|
||||
reply = client.get(
|
||||
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||
def test_profile_picture_url(self):
|
||||
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||
|
|
@ -216,6 +227,35 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
|||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
|
||||
class GroupOwnedMediaUrlTestCase(FilesTestMixin, UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||
"""_accessible_files() only checked connected_items__owner (personal items) before, never
|
||||
connected_items__owner_group - a group-owned item's own files were unreachable via /media/ or
|
||||
/thumbnails/ for every member, including ones who could see and edit the item itself."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_files()
|
||||
self.prepare_users()
|
||||
self.prepare_groups()
|
||||
self.f['group1'].members.add(self.f['local_user2'].public_identity)
|
||||
self.f['group_item'] = InventoryItem.create_for_owner(
|
||||
owner_group=self.f['group1'], owned_quantity=1, name='group-drill')
|
||||
self.f['group_item'].files.add(self.f['test_file1'])
|
||||
|
||||
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||
def test_group_member_can_view_group_item_file(self):
|
||||
reply = client.get(
|
||||
f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_non_member_cannot_view_group_item_file(self):
|
||||
reply = client.get(
|
||||
f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
|
||||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
|
||||
class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class DomainApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.prepare_users()
|
||||
|
||||
def test_get_domains(self):
|
||||
response = client.get('/api/domains/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/domains/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), ['example.com'])
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
|
|||
self.prepare_categories()
|
||||
|
||||
def test_get_categories(self):
|
||||
response = client.get('/api/categories/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/categories/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(),
|
||||
["cat1", "cat2", "cat3", "cat1/subcat1",
|
||||
|
|
@ -215,7 +215,7 @@ class TagApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, ToolshedTes
|
|||
self.prepare_tags()
|
||||
|
||||
def test_get_tags(self):
|
||||
response = client.get('/api/tags/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/tags/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), ["tag1", "tag2", "tag3"])
|
||||
|
||||
|
|
@ -275,7 +275,7 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
|
|||
self.prepare_properties()
|
||||
|
||||
def test_get_properties(self):
|
||||
response = client.get('/api/properties/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/properties/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 3)
|
||||
self.assertEqual(response.json()[0]['name'], 'prop1')
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from django.db.models import Q
|
||||
from django.urls import path
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
|
|
@ -11,29 +12,30 @@ from toolshed.models import InventoryItem, WorkflowInstance
|
|||
|
||||
|
||||
def _get_authorized_item(identity, item_id):
|
||||
"""Look up an item by its owner-scoped id and confirm identity may act on it - either as its
|
||||
personal owner (requires a local ToolshedUser account) or as a current member of its owning
|
||||
group (works for a remote member too, since group membership is identity-level, see
|
||||
docs/design-in-progress/groups-mvp.md). id is only unique within one owner/group's own items,
|
||||
so the lookup itself must be scoped rather than a bare global get. Returns None if not found
|
||||
or not authorized, the same shape InventoryItem.DoesNotExist handling around it already
|
||||
expects."""
|
||||
"""Owner-or-group-scoped item lookup; returns None if identity may not act on it."""
|
||||
if identity.user.exists():
|
||||
try:
|
||||
return InventoryItem.objects.get(owner=identity.user.get(), id=item_id)
|
||||
except InventoryItem.DoesNotExist:
|
||||
pass
|
||||
try:
|
||||
return InventoryItem.objects.get(owner_group__in=identity.member_of_groups.all(), id=item_id)
|
||||
except InventoryItem.DoesNotExist:
|
||||
# Checked one group at a time, not owner_group__in=<all>, since id is only unique within one group's own items and a combined query could raise MultipleObjectsReturned on a collision.
|
||||
for group in identity.member_of_groups.all():
|
||||
item = InventoryItem.objects.filter(owner_group=group, id=item_id).first()
|
||||
if item:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def list_all_files(request, format=None): # /files/
|
||||
files = File.objects.select_related().filter(connected_items__owner=request.user).distinct()
|
||||
def list_all_files(request, format=None):
|
||||
# request.user is a ToolshedUser here; reach group membership via public_identity.
|
||||
files = File.objects.select_related().filter(
|
||||
Q(connected_items__owner=request.user) |
|
||||
Q(connected_items__owner_group__in=request.user.public_identity.member_of_groups.all()),
|
||||
connected_items__is_deleted=False
|
||||
).distinct()
|
||||
return Response(FileSerializer(files, many=True).data)
|
||||
|
||||
|
||||
|
|
@ -54,8 +56,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)
|
||||
|
|
@ -93,17 +95,42 @@ def post_staged_file(request, workflow_id):
|
|||
@api_view(['POST', 'GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def item_files(request, item_id, format=None): # /item_files/
|
||||
def item_files(request, item_id, format=None):
|
||||
if request.method == 'GET':
|
||||
return get_item_files(request, item_id)
|
||||
elif request.method == 'POST':
|
||||
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])
|
||||
def delete_item_file(request, item_id, file_id, format=None): # /item_files/
|
||||
def delete_item_file(request, item_id, file_id, format=None):
|
||||
item = _get_authorized_item(request.user, item_id)
|
||||
if item is None:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
|
@ -122,7 +149,7 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/
|
|||
@api_view(['POST', 'GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def staged_files(request, workflow_id, format=None): # /staged_files/
|
||||
def staged_files(request, workflow_id, format=None):
|
||||
if request.method == 'GET':
|
||||
return get_staged_files(request, workflow_id)
|
||||
elif request.method == 'POST':
|
||||
|
|
@ -132,7 +159,7 @@ def staged_files(request, workflow_id, format=None): # /staged_files/
|
|||
@api_view(['DELETE'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged_files/
|
||||
def delete_staged_file(request, workflow_id, file_hash, format=None):
|
||||
try:
|
||||
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
||||
file = workflow.staged_files.get(hash=file_hash)
|
||||
|
|
@ -150,6 +177,7 @@ def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged
|
|||
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ class Friends(APIView, ViewSetMixin):
|
|||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, format=None): # /api/friends/ #
|
||||
def get(self, request, format=None):
|
||||
user = request.user
|
||||
friends = user.friends.all()
|
||||
serializer = FriendSerializer(friends, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, format=None): # /api/friends/
|
||||
def post(self, request, format=None):
|
||||
# only for local users
|
||||
try:
|
||||
user = request.user
|
||||
|
|
@ -47,7 +47,7 @@ class Friends(APIView, ViewSetMixin):
|
|||
|
||||
|
||||
class FriendsRequests(APIView, ViewSetMixin):
|
||||
def get(self, request, format=None): # /api/friendrequests/
|
||||
def get(self, request, format=None):
|
||||
raw_request = request.body.decode('utf-8')
|
||||
if user := authenticate_request_against_local_users(request, raw_request):
|
||||
friends_requests = user.friend_requests_incoming.all()
|
||||
|
|
@ -56,7 +56,7 @@ class FriendsRequests(APIView, ViewSetMixin):
|
|||
else:
|
||||
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
||||
|
||||
def post(self, request, format=None): # /api/friendrequests/
|
||||
def post(self, request, format=None):
|
||||
raw_request = request.body.decode('utf-8')
|
||||
if 'befriender' not in request.data or 'befriendee' not in request.data:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'missing parameters'})
|
||||
|
|
@ -131,7 +131,7 @@ class FriendsRequests(APIView, ViewSetMixin):
|
|||
@api_view(['DELETE'])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def dropFriend(request, pk, format=None): # /api/friends/<id>/
|
||||
def dropFriend(request, pk, format=None):
|
||||
user = request.user
|
||||
friend = get_object_or_404(user.friends, pk=pk)
|
||||
user.friends.remove(friend)
|
||||
|
|
@ -142,7 +142,7 @@ def dropFriend(request, pk, format=None): # /api/friends/<id>/
|
|||
@api_view(['DELETE'])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def deleteFriendRequest(request, pk, format=None): # /api/friendrequests/<id>/
|
||||
def deleteFriendRequest(request, pk, format=None):
|
||||
user = request.user
|
||||
get_object_or_404(user.friend_requests_incoming, pk=pk).delete()
|
||||
user.save()
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ class Groups(APIView, ViewSetMixin):
|
|||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, format=None): # /api/groups/
|
||||
def get(self, request, format=None):
|
||||
groups = request.user.member_of_groups.all()
|
||||
return Response(GroupSerializer(groups, many=True).data)
|
||||
|
||||
def post(self, request, format=None): # /api/groups/
|
||||
def post(self, request, format=None):
|
||||
name = request.data.get('name')
|
||||
if not name:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'})
|
||||
|
|
@ -40,7 +40,7 @@ class GroupDetail(APIView, ViewSetMixin):
|
|||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, handle, format=None): # /api/groups/<name@domain>/
|
||||
def get(self, request, handle, format=None):
|
||||
try:
|
||||
name, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
|
|
@ -54,7 +54,7 @@ class GroupDetail(APIView, ViewSetMixin):
|
|||
@api_view(['DELETE'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def removeGroupMember(request, handle, identity_id, format=None): # /api/groups/<name@domain>/members/<identity_id>/
|
||||
def removeGroupMember(request, handle, identity_id, format=None):
|
||||
try:
|
||||
name, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
|
|
@ -73,7 +73,7 @@ def removeGroupMember(request, handle, identity_id, format=None): # /api/groups
|
|||
@api_view(['POST'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def createGroupInvite(request, handle, format=None): # /api/groups/<name@domain>/invites/
|
||||
def createGroupInvite(request, handle, format=None):
|
||||
try:
|
||||
name, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
|
|
@ -97,18 +97,14 @@ def createGroupInvite(request, handle, format=None): # /api/groups/<name@domain
|
|||
|
||||
|
||||
class GroupInvitesIncoming(APIView, ViewSetMixin):
|
||||
"""/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery
|
||||
endpoint an inviter's client posts to directly on the invitee's own home backend (see
|
||||
docs/design-in-progress/groups-mvp.md's invite/accept dance)."""
|
||||
|
||||
def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites
|
||||
def get(self, request, format=None):
|
||||
raw_request = request.body.decode('utf-8')
|
||||
if not (user := authenticate_request_against_local_users(request, raw_request)):
|
||||
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
||||
invites = user.group_invites_incoming.all()
|
||||
return Response(GroupInviteIncomingSerializer(invites, many=True).data)
|
||||
|
||||
def post(self, request, format=None): # /api/groupinvites/ - delivery, self-certified, caller isn't known here
|
||||
def post(self, request, format=None):
|
||||
raw_request = request.body.decode('utf-8')
|
||||
for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'):
|
||||
if field not in request.data:
|
||||
|
|
@ -135,7 +131,7 @@ class GroupInvitesIncoming(APIView, ViewSetMixin):
|
|||
@api_view(['DELETE'])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
|
||||
def declineGroupInvite(request, pk, format=None):
|
||||
get_object_or_404(request.user.group_invites_incoming, pk=pk).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
|
@ -143,12 +139,7 @@ def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
|
|||
@api_view(['POST'])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def recordGroupMembership(request, pk, format=None): # /api/groupinvites/<pk>/accept/
|
||||
"""Local bookkeeping call on the invitee's own home backend, made by the client once its
|
||||
accept request against the group's home backend (acceptGroupInvite) has succeeded. Turns the
|
||||
now-obsolete GroupInviteIncoming into a durable GroupMembership pointer, so this backend
|
||||
remembers which foreign groups its own user belongs to -- the same way FriendRequestIncoming
|
||||
ends up recorded as a friend on both sides, not just the group's authoritative backend."""
|
||||
def recordGroupMembership(request, pk, format=None):
|
||||
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
|
||||
membership, _ = GroupMembership.objects.get_or_create(
|
||||
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
|
||||
|
|
@ -157,19 +148,16 @@ def recordGroupMembership(request, pk, format=None): # /api/groupinvites/<pk>/a
|
|||
|
||||
|
||||
class GroupMemberships(APIView, ViewSetMixin):
|
||||
"""/api/groupmemberships/ - the personal index of every group (local or remote) this backend
|
||||
has recorded the caller as belonging to, kept independently of the group's own membership
|
||||
roster (see GroupMembership and recordGroupMembership)."""
|
||||
authentication_classes = [SignatureAuthenticationLocal]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, format=None): # /api/groupmemberships/
|
||||
def get(self, request, format=None):
|
||||
memberships = request.user.group_memberships.all()
|
||||
return Response(GroupMembershipSerializer(memberships, many=True).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
|
||||
def acceptGroupInvite(request, format=None):
|
||||
raw_request = request.body.decode('utf-8')
|
||||
for field in ('group', 'invitee', 'invitee_key', 'secret'):
|
||||
if field not in request.data:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from django.urls import path
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
|
||||
from authentication.models import KnownIdentity
|
||||
from authentication.models import KnownIdentity, ToolshedUser, Group
|
||||
from authentication.signature_auth import SignatureAuthentication
|
||||
from toolshed.models import InventoryItem, StorageLocation
|
||||
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
|
||||
|
||||
|
||||
|
|
@ -13,7 +15,7 @@ class IdMap(APIView, ViewSetMixin):
|
|||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, format=None): # /api/idmap/
|
||||
def get(self, request, format=None):
|
||||
identity = request.user
|
||||
identities = identity.friends.all() | KnownIdentity.objects.filter(pk=identity.pk)
|
||||
groups = identity.member_of_groups.all()
|
||||
|
|
@ -23,6 +25,45 @@ class IdMap(APIView, ViewSetMixin):
|
|||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def resolve_short_id(request, kind, owner_id, local_id):
|
||||
"""Resolves a short id's (kind, owner_id, local_id) against this backend's own numbering. See docs/implementation.md#domain-qualified-short-id-resolution."""
|
||||
if kind in ('item', 'storage_location'):
|
||||
try:
|
||||
owner = KnownIdentity.objects.get(pk=owner_id).user.get()
|
||||
except (KnownIdentity.DoesNotExist, ToolshedUser.DoesNotExist):
|
||||
return Response(status=404)
|
||||
if owner not in request.user.friends_or_self():
|
||||
return Response(status=403)
|
||||
model = InventoryItem if kind == 'item' else StorageLocation
|
||||
try:
|
||||
obj = model.objects.get(owner=owner, id=local_id)
|
||||
except model.DoesNotExist:
|
||||
return Response(status=404)
|
||||
is_owner = request.user.user.filter(pk=owner.pk).exists()
|
||||
if getattr(obj, 'availability_policy', 'share') == 'private' and not is_owner:
|
||||
return Response(status=403)
|
||||
return Response({'handle': f'{owner.username}@{owner.domain}', 'id': obj.id})
|
||||
if kind in ('group_item', 'group_storage_location'):
|
||||
try:
|
||||
group = Group.objects.get(pk=owner_id)
|
||||
except Group.DoesNotExist:
|
||||
return Response(status=404)
|
||||
if not group.is_member(request.user):
|
||||
return Response(status=403)
|
||||
model = InventoryItem if kind == 'group_item' else StorageLocation
|
||||
try:
|
||||
obj = model.objects.get(owner_group=group, id=local_id)
|
||||
except model.DoesNotExist:
|
||||
return Response(status=404)
|
||||
return Response({'handle': str(group), 'id': obj.id})
|
||||
return Response(status=400)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('idmap/', IdMap.as_view(), name='idmap'),
|
||||
path('resolve_short_id/<str:kind>/<int:owner_id>/<int:local_id>/', resolve_short_id,
|
||||
name='resolve_short_id'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,20 +7,12 @@ from hostadmin.models import Domain
|
|||
from authentication.signature_auth import SignatureAuthentication
|
||||
from toolshed.models import Tag, Property, Category, InventoryItem
|
||||
from toolshed.serializers import CategorySerializer, PropertySerializer
|
||||
from backend.settings import TOOLSHED_VERSION, GIT_COMMIT
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([])
|
||||
@authentication_classes([])
|
||||
def get_version(request, format=None): # /version/
|
||||
return Response({'version': TOOLSHED_VERSION, 'commit': GIT_COMMIT})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([])
|
||||
@authentication_classes([])
|
||||
def list_domains(request, format=None): # /domains/
|
||||
def list_domains(request, format=None):
|
||||
domains = [domain.name for domain in Domain.objects.filter(open_registration=True)]
|
||||
return Response(domains)
|
||||
|
||||
|
|
@ -28,7 +20,7 @@ def list_domains(request, format=None): # /domains/
|
|||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def list_tags(format=None): # /tags/
|
||||
def list_tags(format=None):
|
||||
tags = [tag.name for tag in Tag.objects.all()]
|
||||
return Response(tags)
|
||||
|
||||
|
|
@ -36,28 +28,28 @@ def list_tags(format=None): # /tags/
|
|||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def list_properties(request, format=None): # /properties/
|
||||
def list_properties(request, format=None):
|
||||
return Response(PropertySerializer(Property.objects.all(), many=True).data)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def list_categories(request, format=None): # /categories/
|
||||
def list_categories(request, format=None):
|
||||
return Response(CategorySerializer(Category.objects.all(), many=True).data)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def list_availability_policies(request, format=None): # /availability_policies/
|
||||
def list_availability_policies(request, format=None):
|
||||
return Response(InventoryItem.AVAILABILITY_POLICY_CHOICES)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def combined_info(request, format=None): # /info/
|
||||
def combined_info(request, format=None):
|
||||
tags = [tag.name for tag in Tag.objects.all()]
|
||||
properties = PropertySerializer(Property.objects.all(), many=True).data
|
||||
categories = [str(category) for category in Category.objects.all()]
|
||||
|
|
@ -69,7 +61,7 @@ def combined_info(request, format=None): # /info/
|
|||
|
||||
|
||||
urlpatterns = [
|
||||
path('version/', get_version, name='version'),
|
||||
|
||||
path('availability_policies/', list_availability_policies, name='availability_policies'),
|
||||
path('properties/', list_properties, name='propertylist'),
|
||||
path('categories/', list_categories, name='categorylist'),
|
||||
|
|
|
|||
|
|
@ -2,33 +2,16 @@ from django.db import transaction
|
|||
from django.urls import path
|
||||
from rest_framework import routers, viewsets, status
|
||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity, Group
|
||||
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.signature_auth import SignatureAuthentication, resolve_owner_handle
|
||||
from files.models import File
|
||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
|
||||
|
||||
def resolve_group_by_handle(handle):
|
||||
"""handle is "name@domain" (no leading '#') - the same format/parser group.py's GroupDetail
|
||||
uses, so a group reference parses identically everywhere it appears (URL path, ?group= query
|
||||
param, or an owner_group payload field), rather than some spots taking a handle and others a
|
||||
bare pk."""
|
||||
try:
|
||||
name, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
return Group.objects.get(name=name, domain=domain)
|
||||
except Group.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def inventory_items(identity):
|
||||
try:
|
||||
|
|
@ -50,47 +33,54 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = InventoryItemSerializer
|
||||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Detail routes address an item by its owner-scoped id, not the internal row id. See
|
||||
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
||||
# Detail routes address an item by its owner-scoped id, not the internal row id. See docs/implementation.md#owner-handle-scoped-routes.
|
||||
lookup_field = 'id'
|
||||
lookup_url_kwarg = 'pk'
|
||||
|
||||
def get_queryset(self):
|
||||
# A pure group-member KnownIdentity may have no local ToolshedUser account; only
|
||||
# personal items require .user.exists(). See
|
||||
# docs/implementation.md#group-member-identities-without-local-accounts.
|
||||
# Every route is scoped by the owner handle URL param (own/friend/group); no handle-less fallback exists. See docs/implementation.md#owner-handle-scoped-routes.
|
||||
if type(self.request.user) != KnownIdentity:
|
||||
return InventoryItem.objects.none()
|
||||
identity = self.request.user
|
||||
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
|
||||
if self.action != 'list':
|
||||
# retrieve/update/destroy: any item the caller may act on, own or group. See
|
||||
# docs/implementation.md#inventory-queryset-scope-by-action.
|
||||
if identity.user.exists():
|
||||
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
|
||||
return group_items
|
||||
group_handle = self.request.query_params.get('group')
|
||||
if group_handle:
|
||||
group = resolve_group_by_handle(group_handle)
|
||||
if not group or not group.is_member(identity):
|
||||
try:
|
||||
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||
except ValueError:
|
||||
raise ValidationError('invalid owner handle')
|
||||
if owner_group:
|
||||
if not owner_group.is_member(identity):
|
||||
return InventoryItem.objects.none()
|
||||
return InventoryItem.objects.filter(owner_group=group)
|
||||
if identity.user.exists():
|
||||
return InventoryItem.objects.filter(owner=identity.user.get())
|
||||
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')
|
||||
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):
|
||||
group_handle = self.request.data.get('owner_group')
|
||||
try:
|
||||
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||
except ValueError:
|
||||
raise ValidationError('invalid owner handle')
|
||||
with transaction.atomic():
|
||||
if group_handle:
|
||||
group = resolve_group_by_handle(group_handle)
|
||||
if not group:
|
||||
raise NotFound('No such group')
|
||||
if not group.is_member(self.request.user):
|
||||
if owner_group:
|
||||
if not owner_group.is_member(self.request.user):
|
||||
raise PermissionDenied('Not a member of this group')
|
||||
serializer.save(owner=None, owner_group=group).clean()
|
||||
serializer.save(owner=None, owner_group=owner_group).clean()
|
||||
elif owner_user:
|
||||
if not self.request.user.user.filter(pk=owner_user.pk).exists():
|
||||
raise PermissionDenied('Cannot create items for another user')
|
||||
serializer.save(owner=owner_user).clean()
|
||||
else:
|
||||
serializer.save(owner=self.request.user.user.get()).clean()
|
||||
raise NotFound('No such owner')
|
||||
|
||||
@staticmethod
|
||||
def _is_authorized(request, instance):
|
||||
|
|
@ -99,12 +89,15 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
|||
return instance.owner_group.is_member(request.user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# get_queryset() may return a friend's read-only item; reject explicitly rather than silently no-op. See docs/implementation.md#perform-update-rejects-non-owned-items-explicitly.
|
||||
if not self._is_authorized(self.request, serializer.instance):
|
||||
raise PermissionDenied('Not authorized to modify this item')
|
||||
with transaction.atomic():
|
||||
if self._is_authorized(self.request, serializer.instance):
|
||||
serializer.save().clean()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if self._is_authorized(self.request, instance):
|
||||
if not self._is_authorized(self.request, instance):
|
||||
raise PermissionDenied('Not authorized to delete this item')
|
||||
instance.delete()
|
||||
|
||||
|
||||
|
|
@ -130,75 +123,49 @@ def search_inventory_items(request):
|
|||
return Response({'error': 'No query provided.'}, status=400)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_shared_item(request, handle, id):
|
||||
"""Fetch a single item by its owner's handle and local id, for /i/<handle>/<id> or
|
||||
/inventory/shared/<handle>/<id>. See docs/implementation.md#get-shared-item-looks-up-by-owner."""
|
||||
try:
|
||||
username, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
return Response(status=400)
|
||||
try:
|
||||
owner = ToolshedUser.objects.get(username=username, domain=domain)
|
||||
except ToolshedUser.DoesNotExist:
|
||||
return Response(status=404)
|
||||
if owner not in request.user.friends_or_self():
|
||||
return Response(status=403)
|
||||
try:
|
||||
item = owner.inventory_items.get(id=id)
|
||||
except InventoryItem.DoesNotExist:
|
||||
return Response(status=404)
|
||||
is_owner = request.user.user.filter(pk=owner.pk).exists()
|
||||
if item.availability_policy == 'private' and not is_owner:
|
||||
return Response(status=403)
|
||||
return Response(InventoryItemSerializer(item).data)
|
||||
|
||||
|
||||
class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = StorageLocationSerializer
|
||||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Detail routes address a location by its owner-scoped id, not the internal row id. See
|
||||
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
||||
# Detail routes address a location by its owner-scoped id, not the internal row id. See docs/implementation.md#owner-handle-scoped-routes.
|
||||
lookup_field = 'id'
|
||||
lookup_url_kwarg = 'pk'
|
||||
|
||||
def get_queryset(self):
|
||||
# Mirrors InventoryItemViewSet.get_queryset() - see its own comments for why the
|
||||
# list/detail scopes differ and why group membership alone (no linked ToolshedUser
|
||||
# required) is enough for the group branch.
|
||||
# See docs/implementation.md#owner-handle-scoped-routes; unlike items, StorageLocation has no availability_policy, so a friend sees all of a user's locations.
|
||||
if type(self.request.user) != KnownIdentity:
|
||||
return StorageLocation.objects.none()
|
||||
identity = self.request.user
|
||||
group_locations = StorageLocation.objects.filter(owner_group__in=identity.member_of_groups.all())
|
||||
if self.action != 'list':
|
||||
if identity.user.exists():
|
||||
return StorageLocation.objects.filter(owner=identity.user.get()) | group_locations
|
||||
return group_locations
|
||||
group_handle = self.request.query_params.get('group')
|
||||
if group_handle:
|
||||
group = resolve_group_by_handle(group_handle)
|
||||
if not group or not group.is_member(identity):
|
||||
try:
|
||||
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||
except ValueError:
|
||||
raise ValidationError('invalid owner handle')
|
||||
if owner_group:
|
||||
if not owner_group.is_member(identity):
|
||||
return StorageLocation.objects.none()
|
||||
return StorageLocation.objects.filter(owner_group=group)
|
||||
if identity.user.exists():
|
||||
return StorageLocation.objects.filter(owner=identity.user.get())
|
||||
return StorageLocation.objects.filter(owner_group=owner_group)
|
||||
if owner_user:
|
||||
if owner_user not in identity.friends_or_self():
|
||||
return StorageLocation.objects.none()
|
||||
return StorageLocation.objects.filter(owner=owner_user)
|
||||
return StorageLocation.objects.none()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
group_handle = self.request.data.get('owner_group')
|
||||
try:
|
||||
owner_user, owner_group = resolve_owner_handle(self.kwargs.get('handle', ''))
|
||||
except ValueError:
|
||||
raise ValidationError('invalid owner handle')
|
||||
with transaction.atomic():
|
||||
if group_handle:
|
||||
group = resolve_group_by_handle(group_handle)
|
||||
if not group:
|
||||
raise NotFound('No such group')
|
||||
if not group.is_member(self.request.user):
|
||||
if owner_group:
|
||||
if not owner_group.is_member(self.request.user):
|
||||
raise PermissionDenied('Not a member of this group')
|
||||
serializer.save(owner=None, owner_group=group).clean()
|
||||
serializer.save(owner=None, owner_group=owner_group).clean()
|
||||
elif owner_user:
|
||||
if not self.request.user.user.filter(pk=owner_user.pk).exists():
|
||||
raise PermissionDenied('Cannot create locations for another user')
|
||||
serializer.save(owner=owner_user).clean()
|
||||
else:
|
||||
serializer.save(owner=self.request.user.user.get()).clean()
|
||||
raise NotFound('No such owner')
|
||||
|
||||
@staticmethod
|
||||
def _is_authorized(request, instance):
|
||||
|
|
@ -207,12 +174,15 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
|
|||
return instance.owner_group.is_member(request.user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# See docs/implementation.md#perform-update-rejects-non-owned-items-explicitly.
|
||||
if not self._is_authorized(self.request, serializer.instance):
|
||||
raise PermissionDenied('Not authorized to modify this location')
|
||||
with transaction.atomic():
|
||||
if self._is_authorized(self.request, serializer.instance):
|
||||
serializer.save().clean()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if self._is_authorized(self.request, instance):
|
||||
if not self._is_authorized(self.request, instance):
|
||||
raise PermissionDenied('Not authorized to delete this location')
|
||||
instance.delete()
|
||||
|
||||
|
||||
|
|
@ -245,12 +215,24 @@ 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.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
|
||||
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r'inventory_items/(?P<handle>[^/]+)', InventoryItemViewSet, basename='inventory_items')
|
||||
router.register(r'storage_locations/(?P<handle>[^/]+)', StorageLocationViewSet, basename='storage_locations')
|
||||
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
|
||||
|
||||
urlpatterns = router.urls + [
|
||||
path('search/', search_inventory_items, name='search_inventory_items'),
|
||||
path('inventory_items/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
|
||||
]
|
||||
|
|
|
|||
17
backend/toolshed/api/version.py
Normal file
17
backend/toolshed/api/version.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.urls import path
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from backend.settings import TOOLSHED_VERSION, GIT_COMMIT
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([])
|
||||
@authentication_classes([])
|
||||
def get_api_version(request, format=None):
|
||||
return Response({'version': TOOLSHED_VERSION, 'commit': GIT_COMMIT})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', get_api_version, name='version'),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.2
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('toolshed', '0023_remove_storagelocation_storagelocation_unique_owner_scoped_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workflowinstance',
|
||||
name='current_step',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
]
|
||||
|
|
@ -236,6 +236,7 @@ class StorageLocation(models.Model):
|
|||
class WorkflowInstance(models.Model):
|
||||
slug = models.CharField(max_length=255)
|
||||
state = models.CharField(max_length=255)
|
||||
current_step = models.PositiveIntegerField(default=1)
|
||||
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
|
||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
|
||||
staged_files = models.ManyToManyField(File, related_name='staged_by_workflows', blank=True)
|
||||
|
|
|
|||
|
|
@ -50,13 +50,13 @@ def resolve_tag_handle(handle):
|
|||
|
||||
|
||||
class FriendSerializer(serializers.ModelSerializer):
|
||||
username = serializers.SerializerMethodField()
|
||||
handle = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = KnownIdentity
|
||||
fields = ['id', 'username', 'public_key']
|
||||
fields = ['id', 'handle', 'public_key']
|
||||
|
||||
def get_username(self, obj):
|
||||
def get_handle(self, obj):
|
||||
return obj.username + '@' + obj.domain
|
||||
|
||||
|
||||
|
|
@ -72,13 +72,13 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class GroupMemberSerializer(serializers.ModelSerializer):
|
||||
username = serializers.SerializerMethodField()
|
||||
handle = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = KnownIdentity
|
||||
fields = ['id', 'username', 'public_key']
|
||||
fields = ['id', 'handle', 'public_key']
|
||||
|
||||
def get_username(self, obj):
|
||||
def get_handle(self, obj):
|
||||
return obj.username + '@' + obj.domain
|
||||
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = WorkflowInstance
|
||||
fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at']
|
||||
fields = ['id', 'slug', 'state', 'current_step', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at']
|
||||
read_only_fields = ['owner', 'staged_files', 'created_at', 'updated_at']
|
||||
|
||||
def get_staged_files(self, obj):
|
||||
|
|
|
|||
|
|
@ -19,39 +19,39 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
|
|||
def test_version_anonymous(self):
|
||||
response = anonymous_client.get('/api/version/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION})
|
||||
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION, 'commit': settings.GIT_COMMIT})
|
||||
|
||||
def test_version_authenticated(self):
|
||||
response = client.get('/api/version/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION})
|
||||
self.assertEqual(response.json(), {'version': settings.TOOLSHED_VERSION, 'commit': settings.GIT_COMMIT})
|
||||
|
||||
def test_domains_anonymous(self):
|
||||
response = anonymous_client.get('/api/domains/')
|
||||
response = anonymous_client.get('/api/v1/domains/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), ['example.com'])
|
||||
|
||||
def test_domains_authenticated(self):
|
||||
response = client.get('/api/domains/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/domains/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), ['example.com'])
|
||||
|
||||
def test_policy_api_anonymous(self):
|
||||
response = anonymous_client.get('/api/availability_policies/')
|
||||
response = anonymous_client.get('/api/v1/availability_policies/')
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_policy_api(self):
|
||||
response = client.get('/api/availability_policies/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/availability_policies/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'], ['share', 'Share'],
|
||||
['private', 'Private']])
|
||||
|
||||
def test_combined_api_anonymous(self):
|
||||
response = anonymous_client.get('/api/info/')
|
||||
response = anonymous_client.get('/api/v1/info/')
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_combined_api(self):
|
||||
response = client.get('/api/info/', self.f['local_user1'])
|
||||
response = client.get('/api/v1/info/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'],
|
||||
['share', 'Share'], ['private', 'Private']])
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
|
|||
self.prepare_categories()
|
||||
|
||||
def test_get_categories(self):
|
||||
reply = client.get('/api/categories/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/categories/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 7)
|
||||
self.assertEqual(reply.json()[0], 'cat1')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from django.test import Client
|
||||
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
|
||||
|
|
@ -24,11 +25,11 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
|
|||
self.f['item2'].files.add(self.f['test_file1'])
|
||||
|
||||
def test_files_anonymous(self):
|
||||
response = anonymous_client.get(f"/api/item_files/{self.f['item1'].id}/")
|
||||
response = anonymous_client.get(f"/api/v1/item_files/{self.f['item1'].id}/")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_list_all_files(self):
|
||||
response = client.get(f"/api/files/", self.f['local_user1'])
|
||||
response = client.get(f"/api/v1/files/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
self.assertEqual(response.json()[0]['mime_type'], 'text/plain')
|
||||
|
|
@ -38,8 +39,19 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
|
|||
self.assertEqual(response.json()[1]['name'],
|
||||
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
||||
|
||||
def test_list_all_files_excludes_files_only_connected_via_deleted_item(self):
|
||||
# test_file2 is only reachable through item1; test_file1 is also reachable through item2,
|
||||
# which stays live. Soft-deleting item1 doesn't sever its files M2M rows, so this would
|
||||
# regress to listing test_file2 as if it were still owned if the join-based filter ever
|
||||
# stops excluding soft-deleted items again.
|
||||
self.f['item1'].delete()
|
||||
response = client.get(f"/api/v1/files/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
hashes = [f['hash'] for f in response.json()]
|
||||
self.assertEqual(hashes, [self.f['hash1']])
|
||||
|
||||
def test_files(self):
|
||||
response = client.get(f"/api/item_files/{self.f['item1'].id}/", self.f['local_user1'])
|
||||
response = client.get(f"/api/v1/item_files/{self.f['item1'].id}/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
self.assertEqual(response.json()[0]['mime_type'], 'text/plain')
|
||||
|
|
@ -50,11 +62,11 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
|
|||
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
||||
|
||||
def test_files_not_found(self):
|
||||
response = client.get(f"/api/item_files/99999/", self.f['local_user1'])
|
||||
response = client.get(f"/api/v1/item_files/99999/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_post_file(self):
|
||||
response = client.post(f"/api/item_files/{self.f['item1'].id}/", self.f['local_user1'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['item1'].id}/", self.f['local_user1'],
|
||||
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(File.objects.count(), 4)
|
||||
|
|
@ -67,7 +79,7 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
|
|||
def test_post_file_duplicate(self):
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
self.assertEqual(self.f['item1'].files.count(), 2)
|
||||
response = client.post(f"/api/item_files/{self.f['item1'].id}/", self.f['local_user1'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['item1'].id}/", self.f['local_user1'],
|
||||
{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
|
|
@ -78,69 +90,112 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
|
|||
f"{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}")
|
||||
|
||||
def test_post_file_invalid(self):
|
||||
response = client.post(f"/api/item_files/{self.f['item1'].id}/", self.f['local_user1'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['item1'].id}/", self.f['local_user1'],
|
||||
{'data': self.f['encoded_content4']})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_post_file_not_found_item(self):
|
||||
response = client.post(f"/api/item_files/99999/", self.f['local_user1'],
|
||||
response = client.post(f"/api/v1/item_files/99999/", self.f['local_user1'],
|
||||
{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
|
||||
def test_post_file_not_authenticated(self):
|
||||
response = anonymous_client.post(f"/api/item_files/{self.f['item1'].id}/",
|
||||
response = anonymous_client.post(f"/api/v1/item_files/{self.f['item1'].id}/",
|
||||
{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
|
||||
def test_post_file_not_authorized(self):
|
||||
response = client.post(f"/api/item_files/{self.f['item1'].id}/", self.f['local_user2'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['item1'].id}/", self.f['local_user2'],
|
||||
{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
|
||||
def test_delete_file(self):
|
||||
response = client.delete(f"/api/item_files/{self.f['item1'].id}/{self.f['test_file1'].id}/",
|
||||
response = client.delete(f"/api/v1/item_files/{self.f['item1'].id}/{self.f['test_file1'].id}/",
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
self.assertEqual(self.f['item1'].files.count(), 1)
|
||||
|
||||
def test_delete_file_last_use(self):
|
||||
response = client.delete(f"/api/item_files/{self.f['item1'].id}/{self.f['test_file2'].id}/",
|
||||
response = client.delete(f"/api/v1/item_files/{self.f['item1'].id}/{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_not_found(self):
|
||||
response = client.delete(f"/api/item_files/{self.f['item1'].id}/99999/", self.f['local_user1'])
|
||||
response = client.delete(f"/api/v1/item_files/{self.f['item1'].id}/99999/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
self.assertEqual(self.f['item1'].files.count(), 2)
|
||||
|
||||
def test_delete_file_not_found_item(self):
|
||||
response = client.delete(f"/api/item_files/99999/{self.f['test_file1'].id}/", self.f['local_user1'])
|
||||
response = client.delete(f"/api/v1/item_files/99999/{self.f['test_file1'].id}/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(File.objects.count(), 3)
|
||||
self.assertEqual(self.f['item1'].files.count(), 2)
|
||||
|
||||
def test_delete_file_not_owner(self):
|
||||
response = client.delete(f"/api/item_files/{self.f['item1'].id}/{self.f['test_file1'].id}/",
|
||||
response = client.delete(f"/api/v1/item_files/{self.f['item1'].id}/{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_anonymous(self):
|
||||
response = anonymous_client.delete(f"/api/item_files/{self.f['item1'].id}/{self.f['test_file1'].id}/")
|
||||
response = anonymous_client.delete(f"/api/v1/item_files/{self.f['item1'].id}/{self.f['test_file1'].id}/")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
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/inventory_items/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/'.format(self.f['local_user1']), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 2)
|
||||
self.assertEqual(reply.json()[0]['name'], 'test1')
|
||||
|
|
@ -170,46 +225,75 @@ class GroupOwnedFileApiTestCase(UserTestMixin, GroupTestMixin, FilesTestMixin, T
|
|||
self.f['group_item'].files.add(self.f['test_file1'])
|
||||
|
||||
def test_get_group_item_files(self):
|
||||
response = client.get(f"/api/item_files/{self.f['group_item'].id}/", self.f['local_user1'])
|
||||
response = client.get(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 1)
|
||||
|
||||
def test_other_member_can_post_file(self):
|
||||
response = client.post(f"/api/item_files/{self.f['group_item'].id}/", self.f['local_user2'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['local_user2'],
|
||||
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self.f['group_item'].files.count(), 2)
|
||||
|
||||
def test_remote_member_without_local_account_can_post_file(self):
|
||||
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||
response = client.post(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['ext_user1'],
|
||||
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self.f['group_item'].files.count(), 2)
|
||||
|
||||
def test_remote_member_without_local_account_can_get_files(self):
|
||||
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||
response = client.get(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'])
|
||||
response = client.get(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['ext_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_non_member_cannot_post_file(self):
|
||||
response = client.post(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'],
|
||||
response = client.post(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['ext_user1'],
|
||||
{'data': self.f['encoded_content4'], 'mime_type': 'text/plain'})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(self.f['group_item'].files.count(), 1)
|
||||
|
||||
def test_non_member_cannot_get_files(self):
|
||||
response = client.get(f"/api/item_files/{self.f['group_item'].id}/", self.f['ext_user1'])
|
||||
response = client.get(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['ext_user1'])
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_other_member_can_delete_file(self):
|
||||
response = client.delete(f"/api/item_files/{self.f['group_item'].id}/{self.f['test_file1'].id}/",
|
||||
response = client.delete(f"/api/v1/item_files/{self.f['group_item'].id}/{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(self):
|
||||
response = client.delete(f"/api/item_files/{self.f['group_item'].id}/{self.f['test_file1'].id}/",
|
||||
response = client.delete(f"/api/v1/item_files/{self.f['group_item'].id}/{self.f['test_file1'].id}/",
|
||||
self.f['ext_user1'])
|
||||
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)
|
||||
group2_item = InventoryItem.create_for_owner(
|
||||
owner_group=group2, owned_quantity=1, name='group2-drill', availability_policy='private')
|
||||
self.assertEqual(self.f['group_item'].id, group2_item.id)
|
||||
|
||||
response = client.get(f"/api/v1/item_files/{self.f['group_item'].id}/", self.f['local_user2'])
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_group_item_files_listed_in_all_files(self):
|
||||
response = client.get('/api/v1/files/', self.f['local_user2'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 1)
|
||||
self.assertEqual(response.json()[0]['id'], self.f['test_file1'].id)
|
||||
|
|
|
|||
|
|
@ -60,26 +60,26 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.f['ext_user1'].friends.add(self.f['local_user1'].public_identity)
|
||||
|
||||
def test_friend_list_internal1(self):
|
||||
reply = client.get('/api/friends/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/friends/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 2)
|
||||
self.assertEqual(reply.json()[0]['username'], str(self.f['local_user2']))
|
||||
self.assertEqual(reply.json()[1]['username'], str(self.f['ext_user1']))
|
||||
self.assertEqual(reply.json()[0]['handle'], str(self.f['local_user2']))
|
||||
self.assertEqual(reply.json()[1]['handle'], str(self.f['ext_user1']))
|
||||
|
||||
def test_friend_list_internal2(self):
|
||||
reply = client.get('/api/friends/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/friends/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
self.assertEqual(reply.json()[0]['username'], str(self.f['local_user1']))
|
||||
self.assertEqual(reply.json()[0]['handle'], str(self.f['local_user1']))
|
||||
|
||||
def test_friend_list_external(self):
|
||||
reply = client.get('/api/friends/', self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/friends/', self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
self.assertEqual(reply.json()[0]['username'], str(self.f['local_user1']))
|
||||
self.assertEqual(reply.json()[0]['handle'], str(self.f['local_user1']))
|
||||
|
||||
def test_friend_delete(self):
|
||||
reply = client.delete('/api/friends/{}/'.format(self.f['local_user2'].public_identity.id),
|
||||
reply = client.delete('/api/v1/friends/{}/'.format(self.f['local_user2'].public_identity.id),
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(self.f['local_user1'].friends.count(), 1)
|
||||
|
|
@ -101,29 +101,29 @@ class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.friendrequest1.save()
|
||||
|
||||
def test_friend_request_withouth_auth(self):
|
||||
reply = Client().get('/api/friendrequests/')
|
||||
reply = Client().get('/api/v1/friendrequests/')
|
||||
self.assertEqual(reply.status_code, 401)
|
||||
|
||||
def test_friend_request_empty(self):
|
||||
reply = client.get('/api/friendrequests/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/friendrequests/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json(), [])
|
||||
|
||||
def test_friend_request_list(self):
|
||||
reply = client.get('/api/friendrequests/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/friendrequests/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
self.assertEqual(reply.json()[0]['befriender'], str(self.f['ext_user2']))
|
||||
self.assertEqual(reply.json()[0]['befriender_public_key'], self.f['ext_user2'].public_key())
|
||||
|
||||
def test_delete_friend_request(self):
|
||||
reply = client.delete('/api/friendrequests/{}/'.format(self.friendrequest1.id),
|
||||
reply = client.delete('/api/v1/friendrequests/{}/'.format(self.friendrequest1.id),
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(FriendRequestIncoming.objects.count(), 0)
|
||||
|
||||
def test_delete_friend_request_not_found(self):
|
||||
reply = client.delete('/api/friendrequests/999/', self.f['local_user1'])
|
||||
reply = client.delete('/api/v1/friendrequests/999/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertEqual(FriendRequestIncoming.objects.count(), 1)
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -158,7 +158,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_local(self):
|
||||
befriender = self.f['local_user2']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
# 'secret': 'secret2'
|
||||
|
|
@ -173,14 +173,14 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
# self.assertEqual(incoming.secret, 'secret2')
|
||||
|
||||
def test_post_request_withouth_auth(self):
|
||||
reply = Client().post('/api/friendrequests/')
|
||||
reply = Client().post('/api/v1/friendrequests/')
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_post_request_broken_header(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
broken_client = SignatureAuthClient(header_prefix='broken ')
|
||||
reply = broken_client.post('/api/friendrequests/', befriender, {
|
||||
reply = broken_client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -191,7 +191,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_missing_key(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
'secret': 'secret2'
|
||||
|
|
@ -201,7 +201,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_missing_key_none(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
'befriender_key': None,
|
||||
|
|
@ -212,7 +212,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_breaking_key(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
'secret': 'secret2',
|
||||
|
|
@ -223,7 +223,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_wrong_befriender(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(self.f['local_user2']),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -235,7 +235,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
bad_signature = SignatureAuthClient(bad_signature=True)
|
||||
reply = bad_signature.post('/api/friendrequests/', befriender, {
|
||||
reply = bad_signature.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -246,7 +246,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_self(self):
|
||||
befriender = self.f['local_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -257,7 +257,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_befreindee_not_found(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': 'nonexistent@' + befriendee.domain,
|
||||
|
|
@ -268,7 +268,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_request_missing_secret(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee)
|
||||
|
|
@ -281,7 +281,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
request = FriendRequestIncoming.objects.filter(befriender_username=befriender.username,
|
||||
befriender_domain=befriender.domain,
|
||||
befriendee_user=befriendee).first()
|
||||
reply = client.post('/api/friends/', befriendee, {
|
||||
reply = client.post('/api/v1/friends/', befriendee, {
|
||||
'friend_request_id': request.id,
|
||||
'secret': request.secret
|
||||
})
|
||||
|
|
@ -294,7 +294,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
request = FriendRequestIncoming.objects.filter(befriender_username=befriender.username,
|
||||
befriender_domain=befriender.domain,
|
||||
befriendee_user=befriendee).first()
|
||||
reply = client.post('/api/friends/', befriendee, {
|
||||
reply = client.post('/api/v1/friends/', befriendee, {
|
||||
'friend_request_id': request.id,
|
||||
'secret': request.secret
|
||||
})
|
||||
|
|
@ -304,7 +304,7 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_accept_request_not_found(self):
|
||||
befriender = self.f['ext_user2']
|
||||
befriendee = self.f['local_user1']
|
||||
reply = client.post('/api/friends/', befriendee, {
|
||||
reply = client.post('/api/v1/friends/', befriendee, {
|
||||
'friend_request_id': 999,
|
||||
'secret': 'secret1'
|
||||
})
|
||||
|
|
@ -326,7 +326,7 @@ class FriendRequestOutgoingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_post_outgoing_friend_request(self):
|
||||
befriender = self.f['local_user1']
|
||||
befriendee = self.f['ext_user1']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
})
|
||||
|
|
@ -343,7 +343,7 @@ class FriendRequestOutgoingTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_accept_request(self):
|
||||
befriender = self.f['ext_user1']
|
||||
befriendee = self.f['local_user2']
|
||||
reply = client.post('/api/friendrequests/', befriender, {
|
||||
reply = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -367,12 +367,12 @@ class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
|
|||
def test_friend_request_combined(self):
|
||||
befriender = self.f['local_user1']
|
||||
befriendee = self.f['local_user2']
|
||||
reply1 = client.post('/api/friendrequests/', befriender, {
|
||||
reply1 = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
})
|
||||
secret = reply1.json()['secret']
|
||||
reply2 = client.post('/api/friendrequests/', befriender, {
|
||||
reply2 = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriender_key': befriender.public_key(),
|
||||
'befriendee': str(befriendee),
|
||||
|
|
@ -389,7 +389,7 @@ class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
|
|||
befriender = self.f['local_user1']
|
||||
befriendee = self.f['local_user2']
|
||||
befriender.friends.add(befriendee.public_identity)
|
||||
reply1 = client.post('/api/friendrequests/', befriender, {
|
||||
reply1 = client.post('/api/v1/friendrequests/', befriender, {
|
||||
'befriender': str(befriender),
|
||||
'befriendee': str(befriendee),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,34 +27,34 @@ class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
self.prepare_users()
|
||||
|
||||
def test_create_group(self):
|
||||
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||
reply = client.post('/api/v1/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
group = Group.objects.get(name='workshop')
|
||||
self.assertEqual(group.domain, self.f['local_user1'].domain)
|
||||
self.assertTrue(group.is_member(self.f['local_user1'].public_identity))
|
||||
|
||||
def test_create_group_duplicate_name(self):
|
||||
client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||
client.post('/api/v1/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||
reply = client.post('/api/v1/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||
self.assertEqual(reply.status_code, 409)
|
||||
|
||||
def test_create_group_missing_name(self):
|
||||
reply = client.post('/api/groups/', self.f['local_user1'], {})
|
||||
reply = client.post('/api/v1/groups/', self.f['local_user1'], {})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_list_groups(self):
|
||||
self.prepare_groups()
|
||||
reply = client.get('/api/groups/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/groups/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
reply2 = client.get('/api/groups/', self.f['local_user2'])
|
||||
reply2 = client.get('/api/v1/groups/', self.f['local_user2'])
|
||||
self.assertEqual(reply2.status_code, 200)
|
||||
self.assertEqual(len(reply2.json()), 0)
|
||||
|
||||
def test_group_detail_member(self):
|
||||
self.prepare_groups()
|
||||
group = self.f['group1']
|
||||
reply = client.get('/api/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user1'])
|
||||
reply = client.get('/api/v1/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['handle'], str(group))
|
||||
self.assertEqual(len(reply.json()['members']), 1)
|
||||
|
|
@ -62,18 +62,18 @@ class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
def test_group_detail_non_member(self):
|
||||
self.prepare_groups()
|
||||
group = self.f['group1']
|
||||
reply = client.get('/api/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user2'])
|
||||
reply = client.get('/api/v1/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_group_detail_no_such_group(self):
|
||||
reply = client.get('/api/groups/nonexistent@example.com/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/groups/nonexistent@example.com/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_remove_member(self):
|
||||
self.prepare_groups()
|
||||
group = self.f['group1']
|
||||
group.members.add(self.f['local_user2'].public_identity)
|
||||
reply = client.delete('/api/groups/{}@{}/members/{}/'.format(
|
||||
reply = client.delete('/api/v1/groups/{}@{}/members/{}/'.format(
|
||||
group.name, group.domain, self.f['local_user2'].public_identity.id), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(group.members.count(), 1)
|
||||
|
|
@ -81,7 +81,7 @@ class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
def test_remove_last_member_blocked(self):
|
||||
self.prepare_groups()
|
||||
group = self.f['group1']
|
||||
reply = client.delete('/api/groups/{}@{}/members/{}/'.format(
|
||||
reply = client.delete('/api/v1/groups/{}@{}/members/{}/'.format(
|
||||
group.name, group.domain, self.f['local_user1'].public_identity.id), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
self.assertEqual(group.members.count(), 1)
|
||||
|
|
@ -90,7 +90,7 @@ class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
self.prepare_groups()
|
||||
group = self.f['group1']
|
||||
group.members.add(self.f['local_user2'].public_identity)
|
||||
reply = client.delete('/api/groups/{}@{}/members/{}/'.format(
|
||||
reply = client.delete('/api/v1/groups/{}@{}/members/{}/'.format(
|
||||
group.name, group.domain, self.f['local_user1'].public_identity.id), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertEqual(group.members.count(), 2)
|
||||
|
|
@ -107,13 +107,13 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
inviter = self.f['local_user1']
|
||||
invitee = self.f['local_user2']
|
||||
|
||||
reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
|
||||
reply1 = client.post('/api/v1/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
|
||||
{'invitee': str(invitee)})
|
||||
self.assertEqual(reply1.status_code, 201)
|
||||
secret = reply1.json()['secret']
|
||||
self.assertEqual(GroupInvite.objects.count(), 1)
|
||||
|
||||
reply2 = client.post('/api/groupinvites/', inviter, {
|
||||
reply2 = client.post('/api/v1/groupinvites/', inviter, {
|
||||
'group': str(group), 'inviter': str(inviter), 'inviter_key': inviter.public_key(),
|
||||
'invitee': str(invitee), 'secret': secret
|
||||
})
|
||||
|
|
@ -121,20 +121,20 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
incoming = GroupInviteIncoming.objects.get()
|
||||
self.assertEqual(incoming.invitee_user, invitee)
|
||||
|
||||
reply3 = client.get('/api/groupinvites/', invitee)
|
||||
reply3 = client.get('/api/v1/groupinvites/', invitee)
|
||||
self.assertEqual(reply3.status_code, 200)
|
||||
self.assertEqual(len(reply3.json()), 1)
|
||||
self.assertEqual(reply3.json()[0]['group'], str(group))
|
||||
self.assertEqual(reply3.json()[0]['inviter'], str(inviter))
|
||||
|
||||
reply4 = client.post('/api/group_invites/accept/', invitee, {
|
||||
reply4 = client.post('/api/v1/group_invites/accept/', invitee, {
|
||||
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
|
||||
})
|
||||
self.assertEqual(reply4.status_code, 201)
|
||||
self.assertTrue(group.is_member(invitee.public_identity))
|
||||
self.assertEqual(GroupInvite.objects.count(), 0)
|
||||
|
||||
reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||
reply5 = client.post('/api/v1/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||
self.assertEqual(reply5.status_code, 201)
|
||||
self.assertEqual(reply5.json()['handle'], str(group))
|
||||
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
||||
|
|
@ -144,7 +144,7 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
|
||||
def test_invite_non_member_denied(self):
|
||||
group = self.f['group1']
|
||||
reply = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user2'],
|
||||
reply = client.post('/api/v1/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user2'],
|
||||
{'invitee': str(self.f['ext_user1'])})
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertEqual(GroupInvite.objects.count(), 0)
|
||||
|
|
@ -152,7 +152,7 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
def test_invite_already_member(self):
|
||||
group = self.f['group1']
|
||||
group.members.add(self.f['local_user2'].public_identity)
|
||||
reply = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user1'],
|
||||
reply = client.post('/api/v1/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user1'],
|
||||
{'invitee': str(self.f['local_user2'])})
|
||||
self.assertEqual(reply.status_code, 208)
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
group_name=self.f['group1'].name, group_domain=self.f['group1'].domain,
|
||||
inviter_username=self.f['local_user1'].username, inviter_domain=self.f['local_user1'].domain,
|
||||
invitee_user=self.f['local_user2'], secret='some-secret')
|
||||
reply = client.delete('/api/groupinvites/{}/'.format(invite.id), self.f['local_user2'])
|
||||
reply = client.delete('/api/v1/groupinvites/{}/'.format(invite.id), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
||||
|
||||
|
|
@ -169,8 +169,8 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
group = self.f['group1']
|
||||
inviter = self.f['local_user1']
|
||||
invitee = self.f['local_user2']
|
||||
client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter, {'invitee': str(invitee)})
|
||||
reply = client.post('/api/group_invites/accept/', invitee, {
|
||||
client.post('/api/v1/groups/{}@{}/invites/'.format(group.name, group.domain), inviter, {'invitee': str(invitee)})
|
||||
reply = client.post('/api/v1/group_invites/accept/', invitee, {
|
||||
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(),
|
||||
'secret': 'not-the-right-secret'
|
||||
})
|
||||
|
|
@ -181,11 +181,11 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
group = self.f['group1']
|
||||
inviter = self.f['local_user1']
|
||||
invitee = DummyExternalUser('newmember', 'remote.example', known=False)
|
||||
reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
|
||||
reply1 = client.post('/api/v1/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
|
||||
{'invitee': str(invitee)})
|
||||
secret = reply1.json()['secret']
|
||||
|
||||
reply = client.post('/api/group_invites/accept/', invitee, {
|
||||
reply = client.post('/api/v1/group_invites/accept/', invitee, {
|
||||
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
|
|
@ -199,11 +199,11 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
group = self.f['group1']
|
||||
inviter = self.f['local_user1']
|
||||
invitee = self.f['local_user2']
|
||||
reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
|
||||
reply1 = client.post('/api/v1/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
|
||||
{'invitee': str(invitee)})
|
||||
secret = reply1.json()['secret']
|
||||
bad_signature_client = SignatureAuthClient(bad_signature=True)
|
||||
reply = bad_signature_client.post('/api/group_invites/accept/', invitee, {
|
||||
reply = bad_signature_client.post('/api/v1/group_invites/accept/', invitee, {
|
||||
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
|
||||
})
|
||||
self.assertEqual(reply.status_code, 401)
|
||||
|
|
@ -230,7 +230,7 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
|
|||
inviter_username='someone', inviter_domain='other.example',
|
||||
invitee_user=invitee, secret='some-secret')
|
||||
|
||||
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||
reply = client.post('/api/v1/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
self.assertEqual(reply.json()['handle'], '#remoteworkshop@other.example')
|
||||
|
|
@ -245,7 +245,7 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
|
|||
inviter_username='someone', inviter_domain='other.example',
|
||||
invitee_user=self.f['local_user2'], secret='some-secret')
|
||||
|
||||
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), self.f['local_user1'])
|
||||
reply = client.post('/api/v1/groupinvites/{}/accept/'.format(incoming.id), self.f['local_user1'])
|
||||
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertEqual(GroupMembership.objects.count(), 0)
|
||||
|
|
@ -257,7 +257,7 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
|
|||
inviter_username='someone', inviter_domain='other.example',
|
||||
invitee_user=self.f['local_user2'], secret='some-secret')
|
||||
|
||||
reply = client.delete('/api/groupinvites/{}/'.format(incoming.id), self.f['local_user2'])
|
||||
reply = client.delete('/api/v1/groupinvites/{}/'.format(incoming.id), self.f['local_user2'])
|
||||
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(GroupMembership.objects.count(), 0)
|
||||
|
|
@ -273,7 +273,7 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
|
|||
inviter_username='someone', inviter_domain='other.example',
|
||||
invitee_user=invitee, secret='some-secret')
|
||||
|
||||
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||
reply = client.post('/api/v1/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
self.assertEqual(GroupMembership.objects.filter(user=invitee).count(), 1)
|
||||
|
|
@ -283,12 +283,12 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
|
|||
GroupMembership.objects.create(
|
||||
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example')
|
||||
|
||||
reply = client.get('/api/groupmemberships/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/groupmemberships/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
self.assertEqual(reply.json()[0]['handle'], '#remoteworkshop@other.example')
|
||||
|
||||
reply2 = client.get('/api/groupmemberships/', self.f['local_user2'])
|
||||
reply2 = client.get('/api/v1/groupmemberships/', self.f['local_user2'])
|
||||
self.assertEqual(reply2.status_code, 200)
|
||||
self.assertEqual(len(reply2.json()), 0)
|
||||
|
||||
|
|
@ -296,7 +296,7 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
|
|||
GroupMembership.objects.create(
|
||||
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example')
|
||||
|
||||
reply = client.get('/api/groupmemberships/', self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/groupmemberships/', self.f['ext_user1'])
|
||||
|
||||
# authenticate() returns bare None (not raise) for a caller with no local ToolshedUser, so
|
||||
# DRF falls through to IsAuthenticated denying an anonymous request -- 403, not 401 (same
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from django.test import Client
|
||||
|
||||
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
|
||||
from toolshed.models import InventoryItem, StorageLocation
|
||||
from toolshed.tests import InventoryTestMixin, LocationTestMixin
|
||||
|
||||
client = SignatureAuthClient()
|
||||
|
||||
|
|
@ -11,38 +13,130 @@ class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
|||
self.prepare_users()
|
||||
|
||||
def test_idmap_includes_self(self):
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
identities = reply.json()['identities']
|
||||
self.assertIn(str(self.f['local_user1']), [i['username'] for i in identities])
|
||||
self.assertIn(str(self.f['local_user1']), [i['handle'] for i in identities])
|
||||
|
||||
def test_idmap_includes_friends(self):
|
||||
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
identities = reply.json()['identities']
|
||||
self.assertIn(str(self.f['local_user2']), [i['username'] for i in identities])
|
||||
self.assertIn(str(self.f['local_user2']), [i['handle'] for i in identities])
|
||||
|
||||
def test_idmap_excludes_non_friends(self):
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
identities = reply.json()['identities']
|
||||
self.assertNotIn(str(self.f['local_user2']), [i['username'] for i in identities])
|
||||
self.assertNotIn(str(self.f['local_user2']), [i['handle'] for i in identities])
|
||||
|
||||
def test_idmap_includes_member_groups(self):
|
||||
self.prepare_groups()
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
groups = reply.json()['groups']
|
||||
self.assertIn(str(self.f['group1']), [g['handle'] for g in groups])
|
||||
|
||||
def test_idmap_excludes_non_member_groups(self):
|
||||
self.prepare_groups()
|
||||
reply = client.get('/api/idmap/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/idmap/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
groups = reply.json()['groups']
|
||||
self.assertNotIn(str(self.f['group1']), [g['handle'] for g in groups])
|
||||
|
||||
def test_idmap_unauthenticated(self):
|
||||
reply = Client().get('/api/idmap/')
|
||||
reply = Client().get('/api/v1/idmap/')
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
|
||||
class ResolveShortIdApiTestCase(UserTestMixin, InventoryTestMixin, GroupTestMixin, LocationTestMixin,
|
||||
ToolshedTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
self.prepare_categories()
|
||||
self.prepare_tags()
|
||||
self.prepare_properties()
|
||||
self.prepare_inventory()
|
||||
self.prepare_groups()
|
||||
self.prepare_locations()
|
||||
self.owner_id = self.f['local_user1'].public_identity.pk
|
||||
|
||||
def test_resolve_item_as_owner(self):
|
||||
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['item1'].id})
|
||||
|
||||
def test_resolve_item_as_friend(self):
|
||||
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_resolve_item_not_friend(self):
|
||||
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{self.f["item1"].id}/',
|
||||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_resolve_item_private_not_owner(self):
|
||||
private_item = InventoryItem.create_for_owner(
|
||||
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
|
||||
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_resolve_item_unknown_owner(self):
|
||||
reply = client.get('/api/v1/resolve_short_id/item/999999/1/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_resolve_item_unknown_local_id(self):
|
||||
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/999999/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_resolve_unsupported_kind(self):
|
||||
# workflow/category/file/group aren't wired up yet - see
|
||||
# docs/handles-and-shortids.md#domain-qualified-short-id's note on the remaining gap.
|
||||
reply = client.get(f'/api/v1/resolve_short_id/workflow/{self.owner_id}/1/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_resolve_storage_location_as_owner(self):
|
||||
reply = client.get(f'/api/v1/resolve_short_id/storage_location/{self.owner_id}/{self.f["loc1"].id}/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['loc1'].id})
|
||||
|
||||
def test_resolve_storage_location_unknown_local_id(self):
|
||||
reply = client.get(f'/api/v1/resolve_short_id/storage_location/{self.owner_id}/999999/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_resolve_group_item_as_member(self):
|
||||
item = InventoryItem.create_for_owner(
|
||||
owner_group=self.f['group1'], owned_quantity=1, name='drill', availability_policy='private')
|
||||
reply = client.get(f'/api/v1/resolve_short_id/group_item/{self.f["group1"].pk}/{item.id}/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json(), {'handle': str(self.f['group1']), 'id': item.id})
|
||||
|
||||
def test_resolve_group_item_non_member_denied(self):
|
||||
item = InventoryItem.create_for_owner(
|
||||
owner_group=self.f['group1'], owned_quantity=1, name='drill', availability_policy='private')
|
||||
reply = client.get(f'/api/v1/resolve_short_id/group_item/{self.f["group1"].pk}/{item.id}/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_resolve_group_storage_location_as_member(self):
|
||||
location = StorageLocation.create_for_owner(owner_group=self.f['group1'], name='shelf')
|
||||
reply = client.get(
|
||||
f'/api/v1/resolve_short_id/group_storage_location/{self.f["group1"].pk}/{location.id}/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json(), {'handle': str(self.f['group1']), 'id': location.id})
|
||||
|
||||
def test_resolve_unknown_group(self):
|
||||
reply = client.get('/api/v1/resolve_short_id/group_item/999999/1/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from authentication.models import Group
|
||||
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
|
||||
from files.tests import FilesTestMixin
|
||||
from toolshed.models import InventoryItem, Category
|
||||
from toolshed.models import InventoryItem, Category, StorageLocation
|
||||
from toolshed.tests import InventoryTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin, LocationTestMixin
|
||||
|
||||
client = SignatureAuthClient()
|
||||
|
|
@ -16,9 +16,10 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.prepare_tags()
|
||||
self.prepare_properties()
|
||||
self.prepare_inventory()
|
||||
self.own_handle = str(self.f['local_user1'])
|
||||
|
||||
def test_get_inventory(self):
|
||||
reply = client.get('/api/inventory_items/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 2)
|
||||
self.assertEqual(reply.json()[0]['name'], 'test1')
|
||||
|
|
@ -38,7 +39,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.json()[1]['availability_policy'], 'friends')
|
||||
|
||||
def test_post_new_item(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'rent',
|
||||
'category': 'cat2',
|
||||
'name': 'test3',
|
||||
|
|
@ -61,7 +62,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value3', 'value4'])
|
||||
|
||||
def test_post_new_item2(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'share',
|
||||
'name': 'test3',
|
||||
'description': 'test',
|
||||
|
|
@ -80,7 +81,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual([p for p in item.properties.all()], [])
|
||||
|
||||
def test_post_new_item_empty(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'rent',
|
||||
'owned_quantity': 1,
|
||||
'image': '',
|
||||
|
|
@ -89,7 +90,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(InventoryItem.objects.count(), 2)
|
||||
|
||||
def test_post_new_item3(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'private',
|
||||
'name': 'test3',
|
||||
'description': 'test',
|
||||
|
|
@ -109,7 +110,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual([p for p in item.properties.all()], [])
|
||||
|
||||
def test_put_item(self):
|
||||
reply = client.put('/api/inventory_items/1/', self.f['local_user1'], {
|
||||
reply = client.put('/api/v1/inventory_items/{}/1/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'sell',
|
||||
'name': 'test4',
|
||||
'description': 'new description',
|
||||
|
|
@ -132,7 +133,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value5', 'value6', 'value7'])
|
||||
|
||||
def test_patch_item(self):
|
||||
reply = client.patch('/api/inventory_items/1/', self.f['local_user1'], {
|
||||
reply = client.patch('/api/v1/inventory_items/{}/1/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'description': 'new description2',
|
||||
'category': 'cat1',
|
||||
'owned_quantity': 100,
|
||||
|
|
@ -152,7 +153,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value8'])
|
||||
|
||||
def test_patch_item2(self):
|
||||
reply = client.patch('/api/inventory_items/1/', self.f['local_user1'], {
|
||||
reply = client.patch('/api/v1/inventory_items/{}/1/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'description': 'new description2',
|
||||
'category': None,
|
||||
'owned_quantity': 100,
|
||||
|
|
@ -171,14 +172,14 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual([p for p in item.properties.all()], [])
|
||||
|
||||
def test_delete_item(self):
|
||||
reply = client.delete('/api/inventory_items/1/', self.f['local_user1'])
|
||||
reply = client.delete('/api/v1/inventory_items/{}/1/'.format(self.own_handle), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(InventoryItem.objects.count(), 1)
|
||||
self.assertEqual(InventoryItem.objects.get(id=2).name, 'test2')
|
||||
self.assertEqual(InventoryItem.objects.filter(name='test1').count(), 0)
|
||||
|
||||
def test_search_items(self):
|
||||
reply = client.get('/api/search/?query=test', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/search/?query=test', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 2)
|
||||
self.assertEqual(reply.json()[0]['name'], 'test1')
|
||||
|
|
@ -198,61 +199,64 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.json()[1]['availability_policy'], 'friends')
|
||||
|
||||
def test_search_items2(self):
|
||||
reply = client.get('/api/search/?query=test', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/search/?query=test', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 2)
|
||||
self.assertEqual(reply.json()[0]['name'], 'test1')
|
||||
self.assertEqual(reply.json()[1]['name'], 'test2')
|
||||
|
||||
def test_search_items_fail(self):
|
||||
reply = client.get('/api/search/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/search/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
self.assertEqual(reply.json()['error'], 'No query provided.')
|
||||
|
||||
def test_search_items_fail2(self):
|
||||
reply = client.get('/api/search/?query=test', self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/search/?query=test', self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_get_shared_item_as_friend(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['name'], 'test1')
|
||||
self.assertEqual(reply.json()['owner'], 'testuser1@example.com')
|
||||
|
||||
def test_get_shared_item_as_owner(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['name'], 'test1')
|
||||
|
||||
def test_get_shared_item_not_friend(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
# Non-visible resolves the same way a non-member's group access does now (see
|
||||
# GroupOwnedInventoryApiTestCase.test_non_member_cannot_see_or_edit) - 404, not a
|
||||
# separate 403 special case for "not a friend".
|
||||
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_get_shared_item_private(self):
|
||||
private_item = InventoryItem.create_for_owner(
|
||||
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='private')
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
|
||||
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_get_shared_item_unknown_handle(self):
|
||||
reply = client.get('/api/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/',
|
||||
reply = client.get('/api/v1/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/',
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_get_shared_item_unknown_id(self):
|
||||
reply = client.get('/api/inventory_items/testuser1@example.com/99999/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/inventory_items/testuser1@example.com/99999/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_get_shared_item_bad_handle(self):
|
||||
reply = client.get('/api/inventory_items/testuser1/' + str(self.f['item1'].id) + '/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/inventory_items/testuser1/' + str(self.f['item1'].id) + '/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
|
||||
|
|
@ -265,9 +269,10 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
|
|||
self.prepare_properties()
|
||||
self.prepare_files()
|
||||
self.prepare_inventory()
|
||||
self.own_handle = str(self.f['local_user1'])
|
||||
|
||||
def test_post_item_with_file_id(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'name': 'test4',
|
||||
'description': 'test',
|
||||
'category': 'cat1',
|
||||
|
|
@ -290,7 +295,7 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
|
|||
self.assertEqual([f for f in item.files.all()], [self.f['test_file1']])
|
||||
|
||||
def test_post_item_with_encoded_file(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'name': 'test4',
|
||||
'description': 'test',
|
||||
'category': 'cat1',
|
||||
|
|
@ -313,7 +318,7 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
|
|||
self.assertEqual([f for f in item.files.all()], [self.f['test_file3']])
|
||||
|
||||
def test_post_item_with_file_id_fail(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'name': 'test4',
|
||||
'description': 'test',
|
||||
'category': 'cat1',
|
||||
|
|
@ -325,7 +330,7 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
|
|||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_post_item_with_encoded_file_fail(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'name': 'test4',
|
||||
'description': 'test',
|
||||
'category': 'cat1',
|
||||
|
|
@ -349,11 +354,11 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.prepare_files()
|
||||
self.prepare_locations()
|
||||
self.f['group1'].members.add(self.f['local_user2'].public_identity)
|
||||
self.group_handle = '+' + str(self.f['group1'])[1:]
|
||||
|
||||
def create_group_item(self, name='drill'):
|
||||
return client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
return client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': name, 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'owner_group': str(self.f['group1'])[1:],
|
||||
})
|
||||
|
||||
def test_create_group_owned_item(self):
|
||||
|
|
@ -366,16 +371,15 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertIsNone(reply.json()['owner'])
|
||||
|
||||
def test_create_group_owned_item_non_member_denied(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['ext_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['ext_user1'], {
|
||||
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'owner_group': str(self.f['group1'])[1:],
|
||||
})
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
self.assertEqual(InventoryItem.objects.count(), 0)
|
||||
|
||||
def test_other_member_can_edit(self):
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
||||
reply = client.patch('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['local_user2'], {
|
||||
'name': 'drill-renamed'
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
|
@ -383,18 +387,33 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
|
||||
def test_other_member_can_delete(self):
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.delete('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'])
|
||||
reply = client.delete('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
|
||||
|
||||
def test_delete_group_item_when_id_collides_with_own_personal_item(self):
|
||||
own_handle = str(self.f['local_user2'])
|
||||
personal_reply = client.post('/api/v1/inventory_items/{}/'.format(own_handle), self.f['local_user2'], {
|
||||
'name': 'personal-drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
})
|
||||
self.assertEqual(personal_reply.json()['id'], 1)
|
||||
item_id = self.create_group_item().json()['id']
|
||||
self.assertEqual(item_id, 1)
|
||||
|
||||
reply = client.delete('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['local_user2'])
|
||||
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(InventoryItem.objects.filter(name='drill').count(), 0)
|
||||
self.assertEqual(InventoryItem.objects.filter(name='personal-drill').count(), 1)
|
||||
|
||||
def test_remote_member_without_local_account_can_edit(self):
|
||||
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
|
||||
# items - not unauthorized just because .user.exists() is False.
|
||||
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'], {
|
||||
reply = client.patch('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['ext_user1'], {
|
||||
'name': 'drill-renamed-by-remote-member'
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
|
@ -402,32 +421,32 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
|
||||
def test_non_member_cannot_see_or_edit(self):
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_group_items_excluded_from_personal_list(self):
|
||||
self.create_group_item()
|
||||
reply = client.get('/api/inventory_items/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/'.format(str(self.f['local_user1'])), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_group_items_listed_by_group_query_param(self):
|
||||
def test_group_items_listed_by_owner_handle(self):
|
||||
self.create_group_item()
|
||||
reply = client.get('/api/inventory_items/?group={}'.format(str(self.f['group1'])[1:]), self.f['local_user2'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
self.assertEqual(reply.json()[0]['name'], 'drill')
|
||||
|
||||
def test_group_items_not_listed_for_non_member_query_param(self):
|
||||
def test_group_items_not_listed_for_non_member(self):
|
||||
self.create_group_item()
|
||||
reply = client.get('/api/inventory_items/?group={}'.format(str(self.f['group1'])[1:]), self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_create_group_owned_item_with_full_fields(self):
|
||||
# Parity with InventoryApiTestCase.test_post_new_item: tags/properties/category attach
|
||||
# to a group-owned item the same way as a personal one.
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'rent',
|
||||
'category': 'cat2',
|
||||
'name': 'drill',
|
||||
|
|
@ -435,7 +454,6 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
'owned_quantity': 3,
|
||||
'tags': ['tag1', 'tag2'],
|
||||
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
|
||||
'owner_group': str(self.f['group1'])[1:],
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
item = InventoryItem.objects.get(name='drill')
|
||||
|
|
@ -450,22 +468,21 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
|
||||
def test_create_group_owned_item_empty_fails(self):
|
||||
# Parity with InventoryApiTestCase.test_post_new_item_empty: clean()'s name-or-files validation still applies.
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': str(self.f['group1'])[1:],
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'availability_policy': 'private', 'owned_quantity': 1,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
self.assertEqual(InventoryItem.objects.count(), 0)
|
||||
|
||||
def test_create_group_owned_item_nonexistent_group(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/+nonexistent@example.com/', self.f['local_user1'], {
|
||||
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'owner_group': 'nonexistent@example.com',
|
||||
})
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertEqual(InventoryItem.objects.count(), 0)
|
||||
|
||||
def test_group_items_listed_for_nonexistent_group_query_param(self):
|
||||
reply = client.get('/api/inventory_items/?group=nonexistent@example.com', self.f['local_user1'])
|
||||
def test_group_items_listed_for_nonexistent_group(self):
|
||||
reply = client.get('/api/v1/inventory_items/+nonexistent@example.com/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
|
|
@ -473,7 +490,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
# Parity with InventoryApiTestCase.test_put_item, but as a PUT by a different member than
|
||||
# the creator, to exercise the _is_authorized branch in perform_update for PUT too.
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
||||
reply = client.put('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['local_user2'], {
|
||||
'availability_policy': 'sell',
|
||||
'name': 'drill-4000',
|
||||
'description': 'new description',
|
||||
|
|
@ -493,13 +510,12 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
|
||||
def test_patch_group_item_clears_fields(self):
|
||||
# Parity with InventoryApiTestCase.test_patch_item2 - clearing category/tags/properties.
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'category': 'cat1', 'tags': ['tag1'],
|
||||
'owner_group': str(self.f['group1'])[1:],
|
||||
})
|
||||
item_id = reply.json()['id']
|
||||
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
||||
reply = client.patch('/api/v1/inventory_items/{}/{}/'.format(self.group_handle, item_id), self.f['local_user2'], {
|
||||
'category': None, 'tags': [], 'properties': []
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
|
@ -508,9 +524,9 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertEqual([t for t in item.tags.all()], [])
|
||||
|
||||
def test_group_item_storage_location(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'storage_location': self.f['loc1'].id, 'owner_group': str(self.f['group1'])[1:],
|
||||
'storage_location': self.f['loc1'].id,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
item = InventoryItem.objects.get(name='drill')
|
||||
|
|
@ -518,19 +534,18 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
|
||||
def test_post_group_item_with_file_id(self):
|
||||
# Parity with TestInventoryItemWithFileApiTestCase.test_post_item_with_file_id.
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'files': [self.f['test_file1'].id], 'owner_group': str(self.f['group1'])[1:],
|
||||
'files': [self.f['test_file1'].id],
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
item = InventoryItem.objects.get(name='drill')
|
||||
self.assertEqual([f for f in item.files.all()], [self.f['test_file1']])
|
||||
|
||||
def test_post_group_item_with_encoded_file(self):
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/inventory_items/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||
'files': [{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'}],
|
||||
'owner_group': str(self.f['group1'])[1:],
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
item = InventoryItem.objects.get(name='drill')
|
||||
|
|
@ -541,7 +556,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
# docs/design-in-progress/groups-mvp.md) - search must not surface them either.
|
||||
self.create_group_item(name='searchable-drill')
|
||||
InventoryItem.create_for_owner(owner=self.f['local_user1'], owned_quantity=1, name='searchable-personal')
|
||||
reply = client.get('/api/search/?query=searchable', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/search/?query=searchable', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
names = [item['name'] for item in reply.json()]
|
||||
self.assertEqual(names, ['searchable-personal'])
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.prepare_properties()
|
||||
self.prepare_locations()
|
||||
self.prepare_inventory()
|
||||
self.own_handle = str(self.f['local_user1'])
|
||||
|
||||
def test_locations(self):
|
||||
self.assertEqual("loc1", str(self.f['loc1']))
|
||||
|
|
@ -30,7 +31,7 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(self.f['loc1'], self.f['loc4'].parent)
|
||||
|
||||
def test_get_inventory(self):
|
||||
reply = client.get('/api/inventory_items/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 2)
|
||||
self.assertEqual(reply.json()[0]['name'], 'test1')
|
||||
|
|
@ -50,7 +51,7 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()[1]['availability_policy'], 'friends')
|
||||
|
||||
def test_get_inventory_item(self):
|
||||
reply = client.get('/api/storage_locations/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 4)
|
||||
self.assertEqual(reply.json()[0]['name'], 'loc1')
|
||||
|
|
@ -71,7 +72,7 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
|
||||
|
||||
def test_post_new_location(self):
|
||||
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'name': 'loc5',
|
||||
'description': 'a new location',
|
||||
})
|
||||
|
|
@ -84,7 +85,7 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()['path'], 'loc5')
|
||||
|
||||
def test_post_new_nested_location(self):
|
||||
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||
reply = client.post('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user1'], {
|
||||
'name': 'loc5',
|
||||
'parent': self.f['loc3'].id,
|
||||
})
|
||||
|
|
@ -94,7 +95,8 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5')
|
||||
|
||||
def test_patch_location(self):
|
||||
reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], {
|
||||
reply = client.patch(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc2'].id), self.f['local_user1'], {
|
||||
'name': 'loc2-renamed',
|
||||
'parent': self.f['loc1'].id,
|
||||
})
|
||||
|
|
@ -105,7 +107,8 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed')
|
||||
|
||||
def test_delete_location(self):
|
||||
reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1'])
|
||||
reply = client.delete(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc4'].id), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(StorageLocation.objects.count(), 3)
|
||||
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0)
|
||||
|
|
@ -113,20 +116,25 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
def test_delete_location_with_items_sets_null(self):
|
||||
item = InventoryItem.create_for_owner(
|
||||
owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3'])
|
||||
reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1'])
|
||||
reply = client.delete(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc3'].id), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
item.refresh_from_db()
|
||||
self.assertIsNone(item.storage_location)
|
||||
self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1)
|
||||
|
||||
def test_locations_are_owner_scoped(self):
|
||||
reply = client.get('/api/storage_locations/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/storage_locations/{}/'.format(str(self.f['local_user2'])), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_cannot_delete_other_users_location(self):
|
||||
reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
# local_user2 is a friend of local_user1 (see prepare_inventory), so local_user1's own
|
||||
# handle resolves and the location is visible - friends can read but never write, so this
|
||||
# is a 403 (found, not authorized), not a 404.
|
||||
reply = client.delete(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc1'].id), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)
|
||||
|
||||
|
||||
|
|
@ -136,10 +144,13 @@ class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestC
|
|||
self.prepare_users()
|
||||
self.prepare_groups()
|
||||
self.f['group1'].members.add(self.f['local_user2'].public_identity)
|
||||
# The '+' prefix is the '#'->'+' escape resolve_owner_handle expects for a group handle in
|
||||
# the owner_handle URL path parameter - the same escape encodeHandleForUrl uses.
|
||||
self.group_handle = '+' + str(self.f['group1'])[1:]
|
||||
|
||||
def create_group_location(self, name='shelf'):
|
||||
return client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||
'name': name, 'owner_group': str(self.f['group1'])[1:],
|
||||
return client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': name,
|
||||
})
|
||||
|
||||
def test_create_group_owned_location(self):
|
||||
|
|
@ -152,15 +163,16 @@ class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestC
|
|||
self.assertIsNone(reply.json()['owner'])
|
||||
|
||||
def test_create_group_owned_location_non_member_denied(self):
|
||||
reply = client.post('/api/storage_locations/', self.f['ext_user1'], {
|
||||
'name': 'shelf', 'owner_group': str(self.f['group1'])[1:],
|
||||
reply = client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['ext_user1'], {
|
||||
'name': 'shelf',
|
||||
})
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
self.assertEqual(StorageLocation.objects.count(), 0)
|
||||
|
||||
def test_other_member_can_edit(self):
|
||||
location_id = self.create_group_location().json()['id']
|
||||
reply = client.patch('/api/storage_locations/{}/'.format(location_id), self.f['local_user2'], {
|
||||
reply = client.patch(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['local_user2'], {
|
||||
'name': 'shelf-renamed'
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
|
@ -168,18 +180,40 @@ class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestC
|
|||
|
||||
def test_other_member_can_delete(self):
|
||||
location_id = self.create_group_location().json()['id']
|
||||
reply = client.delete('/api/storage_locations/{}/'.format(location_id), self.f['local_user2'])
|
||||
reply = client.delete(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(StorageLocation.objects.filter(id=location_id).count(), 0)
|
||||
|
||||
def test_delete_group_location_when_id_collides_with_own_personal_location(self):
|
||||
# Mirrors InventoryItemViewSet's own regression test - id is only unique within its own
|
||||
# owner/owner_group scope, so a member's personal location and their group's location can
|
||||
# land on the same id. Every route being scoped by an explicit owner_handle - never a
|
||||
# bare, unscoped id - means this can no longer raise MultipleObjectsReturned.
|
||||
own_handle = str(self.f['local_user2'])
|
||||
personal_reply = client.post('/api/v1/storage_locations/{}/'.format(own_handle), self.f['local_user2'],
|
||||
{'name': 'personal-shelf'})
|
||||
self.assertEqual(personal_reply.json()['id'], 1)
|
||||
location_id = self.create_group_location().json()['id']
|
||||
self.assertEqual(location_id, 1)
|
||||
|
||||
reply = client.delete(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['local_user2'])
|
||||
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(StorageLocation.objects.filter(name='shelf').count(), 0)
|
||||
self.assertEqual(StorageLocation.objects.filter(name='personal-shelf').count(), 1)
|
||||
|
||||
def test_remote_member_without_local_account_can_edit(self):
|
||||
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
|
||||
# locations - not unauthorized just because .user.exists() is False.
|
||||
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||
location_id = self.create_group_location().json()['id']
|
||||
reply = client.get('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'])
|
||||
reply = client.get(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
reply = client.patch('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'], {
|
||||
reply = client.patch(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['ext_user1'], {
|
||||
'name': 'shelf-renamed-by-remote-member'
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
|
@ -187,32 +221,33 @@ class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestC
|
|||
|
||||
def test_non_member_cannot_see_or_edit(self):
|
||||
location_id = self.create_group_location().json()['id']
|
||||
reply = client.get('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'])
|
||||
reply = client.get(
|
||||
'/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_group_locations_excluded_from_personal_list(self):
|
||||
self.create_group_location()
|
||||
reply = client.get('/api/storage_locations/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/storage_locations/{}/'.format(str(self.f['local_user1'])), self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_group_locations_listed_by_group_query_param(self):
|
||||
def test_group_locations_listed_by_owner_handle(self):
|
||||
self.create_group_location()
|
||||
reply = client.get('/api/storage_locations/?group={}'.format(str(self.f['group1'])[1:]), self.f['local_user2'])
|
||||
reply = client.get('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 1)
|
||||
self.assertEqual(reply.json()[0]['name'], 'shelf')
|
||||
|
||||
def test_group_locations_not_listed_for_non_member_query_param(self):
|
||||
def test_group_locations_not_listed_for_non_member(self):
|
||||
self.create_group_location()
|
||||
reply = client.get('/api/storage_locations/?group={}'.format(str(self.f['group1'])[1:]), self.f['ext_user1'])
|
||||
reply = client.get('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_group_location_can_be_parent_for_group_member(self):
|
||||
parent_id = self.create_group_location('shelf').json()['id']
|
||||
reply = client.post('/api/storage_locations/', self.f['local_user2'], {
|
||||
'name': 'bin', 'owner_group': str(self.f['group1'])[1:], 'parent': parent_id,
|
||||
reply = client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user2'], {
|
||||
'name': 'bin', 'parent': parent_id,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
self.assertEqual(reply.json()['path'], 'shelf/bin')
|
||||
|
|
@ -224,15 +259,15 @@ class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestC
|
|||
# InventoryItem.storage_location already had before group ownership existed here; not
|
||||
# something this feature narrows.
|
||||
personal = StorageLocation.create_for_owner(name='mine', owner=self.f['local_user1'])
|
||||
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||
'name': 'bin', 'owner_group': str(self.f['group1'])[1:], 'parent': personal.id,
|
||||
reply = client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user1'], {
|
||||
'name': 'bin', 'parent': personal.id,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
|
||||
def test_group_location_not_valid_parent_for_non_member(self):
|
||||
parent_id = self.create_group_location('shelf').json()['id']
|
||||
reply = client.post('/api/storage_locations/', self.f['ext_user1'], {
|
||||
reply = client.post(
|
||||
'/api/v1/storage_locations/{}/'.format(str(self.f['ext_user1'])), self.f['ext_user1'], {
|
||||
'name': 'bin', 'parent': parent_id,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin):
|
|||
class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||
|
||||
def test_delete_data_anonymous(self):
|
||||
response = anonymous_client.delete('/api/account_data/')
|
||||
response = anonymous_client.delete('/api/v1/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'])
|
||||
response = client.delete('/api/v1/account_data/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
summary = response.json()
|
||||
|
|
@ -83,14 +83,14 @@ class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
|||
class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||
|
||||
def test_delete_account_anonymous(self):
|
||||
response = anonymous_client.delete('/api/account/')
|
||||
response = anonymous_client.delete('/api/v1/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'])
|
||||
response = client.delete('/api/v1/account/', self.f['local_user1'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
summary = response.json()
|
||||
|
|
@ -219,7 +219,7 @@ class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTes
|
|||
|
||||
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."""
|
||||
"""End-to-end coverage of the /api/v1/export/ + /api/v1/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()
|
||||
|
|
@ -238,11 +238,11 @@ class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTest
|
|||
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'])
|
||||
export_reply = client.get('/api/v1/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'],
|
||||
import_reply = client.post('/api/v1/import/', self.f['local_user2'],
|
||||
{'zip': base64.b64encode(zip_bytes).decode('ascii')})
|
||||
self.assertEqual(import_reply.status_code, 200)
|
||||
summary = import_reply.json()
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class PropertyApiTestCase(PropertyTestMixin, CategoryTestMixin, UserTestMixin, T
|
|||
self.prepare_properties()
|
||||
|
||||
def test_get_properties(self):
|
||||
reply = client.get('/api/properties/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/properties/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 3)
|
||||
self.assertEqual(reply.json()[0]['name'], 'prop1')
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class TagApiTestCase(TagTestMixin, CategoryTestMixin, UserTestMixin, ToolshedTes
|
|||
self.prepare_tags()
|
||||
|
||||
def test_get_tags(self):
|
||||
reply = client.get('/api/tags/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/tags/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 3)
|
||||
self.assertEqual(reply.json()[0], 'tag1')
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
|
|||
|
||||
|
||||
def test_get_workflow_instances(self):
|
||||
reply = client.get('/api/workflows/', self.f['local_user1'])
|
||||
reply = client.get('/api/v1/workflows/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(reply.data), 3)
|
||||
self.assertEqual(reply.data[0]['name'], 'workflow1')
|
||||
|
|
@ -39,7 +39,7 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
|
|||
|
||||
|
||||
def test_get_workflow_instances_user2(self):
|
||||
reply = client.get('/api/workflows/', self.f['local_user2'])
|
||||
reply = client.get('/api/v1/workflows/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(reply.data), 1)
|
||||
self.assertEqual(reply.data[0]['name'], 'workflow2')
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from nacl.signing import SigningKey
|
||||
from json import dumps
|
||||
from json import dumps, loads
|
||||
|
||||
COMMANDS = {
|
||||
'getinventory': {'path': '/api/v1/inventory_items/{handle}/', 'method': 'get'},
|
||||
'additem': {'path': '/api/v1/inventory_items/{handle}/', 'method': 'post'},
|
||||
'delitem': {'path': '/api/v1/inventory_items/{handle}/{internal_id}/', 'method': 'delete'},
|
||||
}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""A problem talking to the backend - network/TLS failure, or a response that isn't the JSON
|
||||
we expected. Deliberately distinct from ValueError (bad input) so main() can report both with
|
||||
a plain message instead of a traceback, without conflating "you gave me something invalid"
|
||||
with "the server didn't behave"."""
|
||||
|
||||
|
||||
class ToolshedApi:
|
||||
|
|
@ -11,51 +26,271 @@ class ToolshedApi:
|
|||
host = None
|
||||
signing_key = None
|
||||
|
||||
def __init__(self, user, host, key):
|
||||
def __init__(self, user, host, key, ca_cert=None):
|
||||
if host is None:
|
||||
raise ValueError("TOOLSHED_HOST environment variable not set")
|
||||
raise ValueError("No host configured - set TOOLSHED_HOST or pass --host (e.g. a.localhost:8000)")
|
||||
|
||||
if user is None:
|
||||
raise ValueError("TOOLSHED_USER environment variable not set")
|
||||
raise ValueError("No user configured - set TOOLSHED_USER or pass --user (e.g. you@a.localhost)")
|
||||
|
||||
if key is None:
|
||||
raise ValueError("TOOLSHED_KEY environment variable not set")
|
||||
raise ValueError("No signing key configured - set TOOLSHED_KEY or pass --key")
|
||||
|
||||
if len(key) != 64:
|
||||
raise ValueError("TOOLSHED_KEY must be 64 hex characters")
|
||||
raise ValueError("TOOLSHED_KEY must be 64 hex characters, got {} characters".format(len(key)))
|
||||
|
||||
try:
|
||||
signing_key = SigningKey(bytes.fromhex(key))
|
||||
except ValueError:
|
||||
raise ValueError("TOOLSHED_KEY must be a hex-encoded Ed25519 private key")
|
||||
|
||||
if ca_cert is not None and not os.path.isfile(ca_cert):
|
||||
raise ValueError("CA cert file not found: {}".format(ca_cert))
|
||||
|
||||
self.user = user
|
||||
self.host = host
|
||||
self.signing_key = signing_key
|
||||
self.verify = ca_cert if ca_cert is not None else True
|
||||
self._spec = None
|
||||
|
||||
def _url(self, target):
|
||||
return "https://" + self.host + target
|
||||
|
||||
def _ssl_error(self, error):
|
||||
hint = "" if self.verify is not True else \
|
||||
" - if this is a dev server with a self-signed cert, pass --ca-cert/TOOLSHED_CA_CERT"
|
||||
return ApiError("TLS error talking to {}: {}{}".format(self.host, error, hint))
|
||||
|
||||
def _send(self, method, target, json_body=None):
|
||||
url = self._url(target)
|
||||
signed_body = dumps(json_body).encode('utf-8') if json_body is not None else b''
|
||||
signature = self.signing_key.sign(url.encode('utf-8') + signed_body).signature.hex()
|
||||
headers = {"Authorization": "Signature " + self.user + ":" + signature}
|
||||
try:
|
||||
return requests.request(method, url, headers=headers, json=json_body, verify=self.verify)
|
||||
except requests.exceptions.SSLError as error:
|
||||
raise self._ssl_error(error)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
raise ApiError("Could not reach {} - is the host/port correct and reachable? ({})".format(
|
||||
self.host, error))
|
||||
except requests.exceptions.Timeout:
|
||||
raise ApiError("Request to {} timed out".format(self.host))
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(response):
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
raise ApiError("Expected a JSON response from {} but got {} {}: {}".format(
|
||||
response.url, response.status_code, response.reason, response.text[:300]))
|
||||
|
||||
def get_spec(self):
|
||||
if self._spec is None:
|
||||
try:
|
||||
response = requests.get(self._url("/docs/?format=openapi"), verify=self.verify)
|
||||
except requests.exceptions.SSLError as error:
|
||||
raise self._ssl_error(error)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
raise ApiError("Could not reach {} - is the host/port correct and reachable? ({})".format(
|
||||
self.host, error))
|
||||
self._spec = self._parse_json(response)
|
||||
return self._spec
|
||||
|
||||
def get(self, target):
|
||||
url = "http://" + self.host + target
|
||||
signed = self.signing_key.sign(url.encode('utf-8'))
|
||||
signature = signed.signature.hex()
|
||||
response = requests.get(url, headers={"Authorization": "Signature " + self.user + ":" + signature})
|
||||
return response.json()
|
||||
return self._parse_json(self._send('GET', target))
|
||||
|
||||
def post(self, target, data):
|
||||
url = "http://" + self.host + target
|
||||
json = dumps(data)
|
||||
signed = self.signing_key.sign(url.encode('utf-8') + json.encode('utf-8'))
|
||||
signature = signed.signature.hex()
|
||||
response = requests.post(url, headers={"Authorization": "Signature " + self.user + ":" + signature}, json=data)
|
||||
return response.json()
|
||||
return self._parse_json(self._send('POST', target, json_body=data))
|
||||
|
||||
def delete(self, target):
|
||||
response = self._send('DELETE', target)
|
||||
if not response.content:
|
||||
return {"deleted": response.ok}
|
||||
return self._parse_json(response)
|
||||
|
||||
def get_raw(self, target):
|
||||
"""Like get(), but returns the raw response body instead of parsing it as JSON - for
|
||||
endpoints like /api/v1/export/ that hand back a zip file, not a JSON document."""
|
||||
response = self._send('GET', target)
|
||||
if not response.ok:
|
||||
raise ApiError("{} {} from {}: {}".format(
|
||||
response.status_code, response.reason, response.url, response.text[:300]))
|
||||
return response.content
|
||||
|
||||
|
||||
def resolve_schema(spec, schema):
|
||||
if '$ref' in schema:
|
||||
return spec['definitions'][schema['$ref'].split('/')[-1]]
|
||||
return schema
|
||||
|
||||
|
||||
def operation_parameters(spec, path, method):
|
||||
"""Path- and body-parameters for an operation, per the endpoint's own OpenAPI spec entry."""
|
||||
path_item = spec['paths'][path]
|
||||
params = path_item.get('parameters', []) + path_item[method].get('parameters', [])
|
||||
path_params = [p for p in params if p.get('in') == 'path']
|
||||
body_param = next((p for p in params if p.get('in') == 'body'), None)
|
||||
return path_params, body_param
|
||||
|
||||
|
||||
def parse_kv_args(cmd_args):
|
||||
"""['name=Drill', 'owned_quantity=2'] -> {'name': 'Drill', 'owned_quantity': '2'}"""
|
||||
pairs = {}
|
||||
for arg in cmd_args:
|
||||
if '=' in arg:
|
||||
key, value = arg.split('=', 1)
|
||||
pairs[key] = value
|
||||
return pairs
|
||||
|
||||
|
||||
def resolve_path_params(api, path_params, cmd_args, json_input):
|
||||
values = {}
|
||||
positional = [arg for arg in cmd_args if '=' not in arg]
|
||||
kv = parse_kv_args(cmd_args)
|
||||
for param in path_params:
|
||||
name = param['name']
|
||||
if name == 'handle':
|
||||
values[name] = api.user
|
||||
continue
|
||||
if json_input is not None:
|
||||
value = json_input.get(name, json_input.get('id'))
|
||||
if value is None:
|
||||
raise ValueError("Missing required path parameter '{}' in --json stdin input".format(name))
|
||||
elif name in kv:
|
||||
value = kv[name]
|
||||
elif 'id' in kv:
|
||||
value = kv['id']
|
||||
elif positional:
|
||||
value = positional.pop(0)
|
||||
else:
|
||||
value = input("{}: ".format(name))
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
|
||||
def resolve_body(spec, body_param, cmd_args, json_input):
|
||||
if body_param is None:
|
||||
return None
|
||||
if json_input is not None:
|
||||
return json_input
|
||||
kv = parse_kv_args(cmd_args)
|
||||
if kv:
|
||||
return kv
|
||||
schema = resolve_schema(spec, body_param['schema'])
|
||||
writable_fields = [name for name, prop in schema.get('properties', {}).items() if not prop.get('readOnly')]
|
||||
body = {}
|
||||
for field in writable_fields:
|
||||
value = input("{}: ".format(field))
|
||||
if value != '':
|
||||
body[field] = value
|
||||
return body
|
||||
|
||||
|
||||
def build_request(api, spec, command, cmd_args, json_input):
|
||||
path_params, body_param = operation_parameters(spec, command['path'], command['method'])
|
||||
values = resolve_path_params(api, path_params, cmd_args, json_input)
|
||||
url = command['path']
|
||||
for name, value in values.items():
|
||||
url = url.replace('{' + name + '}', str(value))
|
||||
body = resolve_body(spec, body_param, cmd_args, json_input)
|
||||
return url, body
|
||||
|
||||
|
||||
def run_command(api, cmd, cmd_args, json_input):
|
||||
if cmd == 'export':
|
||||
path = cmd_args[0] if cmd_args else 'toolshed-export.zip'
|
||||
data = api.get_raw("/api/v1/export/")
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
return {'exported_to': path, 'bytes': len(data)}
|
||||
elif cmd == 'import':
|
||||
if not cmd_args:
|
||||
raise ValueError("import requires the path to a previously exported zip file")
|
||||
path = cmd_args[0]
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
return api.post("/api/v1/import/", {"zip": base64.b64encode(data).decode('ascii')})
|
||||
|
||||
command = COMMANDS.get(cmd)
|
||||
if command is None:
|
||||
raise ValueError("Unknown command: " + cmd)
|
||||
spec = api.get_spec()
|
||||
url, body = build_request(api, spec, command, cmd_args, json_input)
|
||||
if command['method'] == 'get':
|
||||
return api.get(url)
|
||||
elif command['method'] == 'post':
|
||||
return api.post(url, body or {})
|
||||
elif command['method'] == 'delete':
|
||||
return api.delete(url)
|
||||
else:
|
||||
raise ValueError("Unsupported method: " + command['method'])
|
||||
|
||||
|
||||
def read_json_input():
|
||||
if sys.stdin.isatty():
|
||||
return {}
|
||||
raw = sys.stdin.read()
|
||||
return loads(raw) if raw.strip() else {}
|
||||
|
||||
|
||||
def stringify_cell(value):
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, (list, tuple)):
|
||||
return ', '.join(stringify_cell(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def format_table(rows):
|
||||
if not rows:
|
||||
return '(empty)'
|
||||
columns = []
|
||||
for row in rows:
|
||||
for key in row.keys():
|
||||
if key not in columns:
|
||||
columns.append(key)
|
||||
cells = [[stringify_cell(row.get(column)) for column in columns] for row in rows]
|
||||
widths = [max([len(columns[i])] + [len(cell[i]) for cell in cells] + [3]) for i in range(len(columns))]
|
||||
|
||||
def format_row(values):
|
||||
return '| ' + ' | '.join(value.ljust(widths[i]) for i, value in enumerate(values)) + ' |'
|
||||
|
||||
lines = [format_row(columns), '|-' + '-|-'.join('-' * width for width in widths) + '-|']
|
||||
for cell in cells:
|
||||
lines.append(format_row(cell))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def print_result(result, as_json):
|
||||
if as_json:
|
||||
print(dumps(result, indent=2))
|
||||
elif isinstance(result, list) and all(isinstance(item, dict) for item in result):
|
||||
print(format_table(result))
|
||||
elif isinstance(result, dict):
|
||||
print(format_table([result]))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
|
||||
def main():
|
||||
host = os.environ.get('TOOLSHED_HOST')
|
||||
user = os.environ.get('TOOLSHED_USER')
|
||||
key = os.environ.get('TOOLSHED_KEY')
|
||||
ca_cert = os.environ.get('TOOLSHED_CA_CERT')
|
||||
|
||||
parser = argparse.ArgumentParser(description='Toolshed API client')
|
||||
parser.add_argument('--host', help='Toolshed host')
|
||||
parser.add_argument('--user', help='Toolshed user')
|
||||
parser.add_argument('--key', help='Toolshed key')
|
||||
parser.add_argument('--ca-cert',
|
||||
help='CA cert file to verify the server against, for a dev/self-signed host '
|
||||
'(e.g. frontend/.local/RootCA.crt) - normal system CAs are used otherwise')
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Read input as JSON from stdin and print output as JSON, instead of prompting/printing')
|
||||
parser.add_argument('cmd', help='Command')
|
||||
parser.add_argument('args', nargs='*', help="Command arguments, as key=value pairs (e.g. name=Drill)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.host is not None:
|
||||
|
|
@ -67,16 +302,18 @@ def main():
|
|||
if args.key is not None:
|
||||
key = args.key
|
||||
|
||||
api = ToolshedApi(user, host, key)
|
||||
if args.ca_cert is not None:
|
||||
ca_cert = args.ca_cert
|
||||
|
||||
if args.cmd == 'getinventory':
|
||||
inv = api.get("/api/inventory_items/")
|
||||
print(inv)
|
||||
elif args.cmd == 'additem':
|
||||
inv = api.post("/api/inventory_items/", {"name": "test"})
|
||||
print(inv)
|
||||
else:
|
||||
print("Unknown command: " + args.cmd)
|
||||
try:
|
||||
api = ToolshedApi(user, host, key, ca_cert)
|
||||
json_input = read_json_input() if args.json else None
|
||||
result = run_command(api, args.cmd, args.args, json_input)
|
||||
except (ValueError, ApiError, OSError) as error:
|
||||
print("Error: {}".format(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print_result(result, args.json)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
77
docs/design-in-progress/api-classification.md
Normal file
77
docs/design-in-progress/api-classification.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# API Endpoint Versioning Scope (Design in Progress)
|
||||
|
||||
Status: decision deferred. The `/api/` prefix has already been changed to `/api/v1/` for every
|
||||
route under that prefix (inventory items, storage locations, search, friends, friend requests,
|
||||
groups, group invites, id mapping, tags/properties/categories/availability policies/info,
|
||||
files/item files/staged files, and account export/import/delete). Whether that blanket approach is
|
||||
the right scope, or whether versioning should be narrower, is the open question below.
|
||||
|
||||
## Problem
|
||||
|
||||
A versioned URL prefix matters most where two independently-deployed servers need to agree on a
|
||||
wire contract: federation. An endpoint that's only ever called by a server's own frontend against
|
||||
its own backend is deployed in lockstep with that backend, so it has no independent version to
|
||||
track. Applying a uniform prefix everywhere is simpler, but it also prefixes a large number of
|
||||
routes that never cross a domain boundary, and skips one non-`/api/` route
|
||||
(`/auth/user/<handle>/`, used to fetch a friend's profile/avatar) and the file/thumbnail serving
|
||||
routes (`/media/...`), both of which do cross a domain boundary and arguably belong in the
|
||||
versioned surface more than some of the routes currently in it.
|
||||
|
||||
One exception either way: the version/capability discovery route (`GET /api/version/`) has to stay
|
||||
unprefixed no matter which option is chosen — it's how a caller finds out which versions a server
|
||||
speaks in the first place, so it can't itself live behind a version segment.
|
||||
|
||||
## Options
|
||||
|
||||
**Option A: version every real endpoint uniformly.**
|
||||
Every route under `/api/`, `/auth/`, `/admin/`, and `/media/` gets the `/api/v1/` prefix (except
|
||||
`/api/version/`), regardless of whether it's ever called across a domain boundary. Simple to state
|
||||
and apply; doesn't require maintaining a classification of which routes are federation-facing.
|
||||
|
||||
**Option B: version only routes that actually cross a domain boundary.**
|
||||
Only routes a server's own frontend calls against a *different* server (directly, or that another
|
||||
server calls against this one) get the `/api/v1/` prefix. Everything else keeps its existing,
|
||||
unprefixed path. Smaller versioned surface, but requires keeping the classification below current
|
||||
as routes change, and means a single URL path can't ever serve both a same-domain and
|
||||
cross-domain purpose without the whole path being versioned.
|
||||
|
||||
## Current classification
|
||||
|
||||
Verified against actual call sites (which routes are dispatched against a friend's/foreign
|
||||
server rather than the caller's own home server), not just against which routes are technically
|
||||
reachable by a non-local caller — several routes accept a broader signature-based authentication
|
||||
than they actually need, and one route family enforces "local caller only" itself even though its
|
||||
authentication layer would allow a remote identity through.
|
||||
|
||||
**Crosses a domain boundary today:**
|
||||
- `POST /friendrequests/` (also called against the recipient's own server, not just the sender's)
|
||||
- `GET/POST /groupinvites/` (invite delivery is posted to the invitee's own server)
|
||||
- `POST /group_invites/accept/` (posted to the group's own home server)
|
||||
- `GET /groups/<handle>/`, `DELETE /groups/<handle>/members/<id>/`, `POST /groups/<handle>/invites/`
|
||||
- `* /inventory_items/<handle>/...`, `* /storage_locations/<handle>/...` (group-owned or
|
||||
friend-owned items/locations)
|
||||
- `GET /search/` (fans out to every known server)
|
||||
- `GET /resolve_short_id/<kind>/<owner_id>/<local_id>/`
|
||||
- `GET /auth/user/<handle>/` (friend profile/avatar lookup — not currently under `/api/`)
|
||||
- `GET /media/...`, `GET /thumbnail/<size>/...` (image bytes fetched from the owning server —
|
||||
not currently under `/api/`)
|
||||
|
||||
**Never observed leaving the caller's own home server:**
|
||||
- `GET/POST /friends/`, `DELETE /friends/<id>/`, `DELETE /friendrequests/<id>/`
|
||||
- `GET /groups/` (the collection route, as opposed to `/groups/<handle>/`)
|
||||
- `DELETE /groupinvites/<id>/`, `POST /groupinvites/<id>/accept/`, `GET /groupmemberships/`
|
||||
- `GET /idmap/` (distinct from `/resolve_short_id/...`, which does cross domains)
|
||||
- `* /workflows/...`
|
||||
- `GET /tags/`, `GET /properties/`, `GET /categories/`, `GET /availability_policies/`, `GET /info/`,
|
||||
`GET /domains/` (also not currently called from the frontend at all)
|
||||
- `POST /import/`, `GET /export/`, `DELETE /account_data/`, `DELETE /account/` — these accept the
|
||||
same broad authentication as the federation-facing routes, but reject any non-local caller
|
||||
themselves
|
||||
- `GET /files/`, `* /item_files/...`, `* /staged_files/...`
|
||||
- `GET/PATCH /auth/user/`, `POST /auth/register/`, `POST /auth/token/`, `GET /auth/preferences/`,
|
||||
`GET/PUT /auth/self/preferences/`, `DELETE /auth/self/preferences/<key>/`, `* /auth/users/...`
|
||||
(admin management)
|
||||
- everything under `/admin/` (domain/category/property/tag administration)
|
||||
|
||||
**Exception regardless of which option is chosen:**
|
||||
- `GET /api/version/`
|
||||
|
|
@ -50,16 +50,20 @@ separate "discover groups you're not in" browsing for MVP — you land in a grou
|
|||
it, the same way you become friends with someone by request/accept, not by browsing a directory of
|
||||
all users.
|
||||
|
||||
Known limitation: this list only ever queries the member's own home backend, so it only shows
|
||||
groups actually hosted there (groups you created, or joined on your own domain). Membership itself
|
||||
works regardless of which backend hosts the group — a remote member can still be invited, accept,
|
||||
and fully edit/delete the group's items (see "Owning items as a group" below) — but a group hosted
|
||||
on someone else's backend won't show up in your own "My Groups" list, because unlike friendship
|
||||
(which both sides record), group membership is only ever recorded on the group's own home backend,
|
||||
and there's no index anywhere of "which other backends has this identity been added to." Making a
|
||||
remote membership discoverable would need a small personal pointer index (written by the client at
|
||||
join time) plus a handle-based group lookup on the group's own backend; deferred as a fast-follow
|
||||
alongside group-friending.
|
||||
Listing groups (the table itself) only ever queries the member's own home backend — it's this
|
||||
backend's own view of "groups I host you in" — but every row links to the same group detail page
|
||||
regardless of where the group is actually hosted. Mirroring friendship (which both sides record),
|
||||
the member's own home backend also remembers the bare fact of a remote membership, as a pointer
|
||||
alongside the group's own home backend's real membership record, and merges the two into one table
|
||||
keyed by handle (a group present in both keeps its home-hosted row, which already carries a member
|
||||
count).
|
||||
|
||||
A group's full detail resolves from its handle the same way a friend's foreign items already do:
|
||||
the client resolves the target group's own domain from its handle and talks to that backend
|
||||
directly, rather than assuming home. `GroupDetail.vue` needs no "is this group local or foreign"
|
||||
branch anywhere, including for creating a new item/storage location — the owner selector binds
|
||||
directly to the group's handle, and a group reference is addressed by handle everywhere it appears,
|
||||
never by a bare internal id.
|
||||
|
||||
### Group detail page
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ long-lived, network-interposing piece of code — exactly the kind of thing a su
|
|||
or an XSS-planted `registration.update()` would target. If the page's message handler blindly signs
|
||||
whatever URL the request names, a compromised SW stops being "something that can read images this
|
||||
identity can already see" and becomes "something that can get a validly-signed request for *any*
|
||||
endpoint" — e.g. `POST /api/inventory/items/5/delete` or `POST /api/friends/accept` — and then just
|
||||
endpoint" — e.g. `POST /api/v1/inventory/items/5/delete` or `POST /api/v1/friends/accept` — and then just
|
||||
replay it directly against the real backend. That's a full account-takeover primitive smuggled in
|
||||
through what was supposed to be an image-caching optimization, and it's strictly worse than not
|
||||
having the bridge at all.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
# Item Handles & Physical Labels (Design in Progress)
|
||||
|
||||
Status: not implemented. This document collects the problem, goals, and open design questions for
|
||||
giving inventory items stable identifiers and physical (scannable) labels. Nothing here is
|
||||
settled.
|
||||
Status: partially implemented. This document collects the problem, goals, and open design
|
||||
questions for giving inventory items stable identifiers and physical (scannable) labels. The core
|
||||
handle/URL shape question below is settled, generalized to cover any owned kind, not just items —
|
||||
see [User-Qualified ID](../handles-and-shortids.md#user-qualified-id) and
|
||||
[Item URL](../glossary.md#item-url). The remaining open questions (unguessability, label retirement
|
||||
on deletion, route reconciliation, lending/borrowing) are not.
|
||||
|
||||
## Problem
|
||||
|
||||
|
|
@ -59,11 +62,16 @@ unguessability question below).
|
|||
Two distinct formats are needed, because "an item handle" is used in two different situations:
|
||||
|
||||
- *A compact handle, for use where context already makes clear it's a Toolshed item.* Inside the
|
||||
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data,
|
||||
the handle doesn't need to spell that out or be openable on its own. This can be as short as
|
||||
`user@domain.tld:id`, the item's owner handle with `:id` appended, mirroring how a tag/category
|
||||
handle already appends `:name` after its origin (see federation.md's Unique Handles section).
|
||||
No new delimiter concept, just the same pattern applied to items.
|
||||
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data, the
|
||||
handle doesn't need to spell that out or be openable on its own. An item's compact handle is the
|
||||
item-kind instance of the general [User-Qualified
|
||||
ID](../handles-and-shortids.md#user-qualified-id) scheme: the item's owner handle, the `i` kind
|
||||
letter, and the local id, e.g. `alice@example.com:i42`. The same scheme covers any owned kind (a
|
||||
storage location, for instance); item handles are just the motivating case. An alternative in the
|
||||
same compact-handle family pairs the owner's domain with a [short
|
||||
id](../handles-and-shortids.md#short-ids) instead, e.g. `domain.tld:~DyU` (see
|
||||
[Domain-Qualified Short ID](../handles-and-shortids.md#domain-qualified-short-id)) — shorter, at
|
||||
the cost of needing the app's short id decoder to make sense of it at all.
|
||||
|
||||
- *A self-contained URL, for use with no context at all.* A physical label, a link shared outside
|
||||
the app, has to work without the reader already knowing what it is or which server it belongs
|
||||
|
|
|
|||
|
|
@ -107,5 +107,12 @@ This will start an instance of the frontend and wiki, a limited DoH (DNS over HT
|
|||
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
|
||||
server is used to direct the frontend to the correct backend instance.
|
||||
The frontend is configured to act as if it was served from the domain `a.localhost`.
|
||||
Access the frontend at `http://localhost:8080/`, backend at `http://localhost:8080/api/`, api docs
|
||||
Access the frontend at `http://localhost:8080/`, backend at `http://localhost:8080/api/v1/`, api docs
|
||||
at `http://localhost:8080/docs/` and the wiki at `http://localhost:8080/wiki/`.
|
||||
|
||||
The dev proxies terminate TLS with `frontend/.local/localhost.crt`, signed by the dev-only CA at
|
||||
`frontend/.local/RootCA.crt` (regenerate both with `frontend/.local/make_ca.sh` and
|
||||
`make_localhost.sh` if they've expired). Import `RootCA.crt` into your OS/browser trust store once;
|
||||
without that, requests the frontend makes itself (federation calls, the DoH lookup) will fail with
|
||||
opaque network errors even though clicking through the browser's own cert warning for the page you
|
||||
navigated to by hand looked fine.
|
||||
|
|
@ -33,16 +33,6 @@ inconsistencies are listed, not every correct usage that was checked and cleared
|
|||
|
||||
## Handle / User handle
|
||||
|
||||
- ~~`backend/authentication/models.py` (`class KnownIdentity`...), `signature_auth.py`
|
||||
(`author_identity`...), `frontend/src/identity.js` (`serializeIdentityRecord`...)~~ — no longer a
|
||||
finding: the glossary now has an explicit **Identity** entry (handle + keypair, held together as
|
||||
the unit a backend trusts), and this is exactly what these already name. No renaming needed here;
|
||||
if anything, these are the parts of the codebase the new Identity entry should point to as
|
||||
reference implementations.
|
||||
- `backend/toolshed/serializers.py:49-57` (`FriendSerializer`) — API field is literally named
|
||||
`"username"` but its value is a full handle (`username + '@' + domain`). Already
|
||||
self-acknowledged in a comment at `frontend/src/store.js:404`. **Highest-value single fix** —
|
||||
it's a live API contract, not just an internal name.
|
||||
- `frontend/src/store.js` — several action params destructured as `{username}` that actually carry
|
||||
a full handle: `lookupServer` (313), `getFriendServers` (359), `fetchFriendProfile` (401-405),
|
||||
`login` (276-282).
|
||||
|
|
@ -66,23 +56,6 @@ it; Actor and Targeted sharing are unimplemented with no competing name anywhere
|
|||
"neighbor" vocabulary — refers to unreachable backend *domains* during discovery, not to
|
||||
friendship, despite reading like a synonym at a glance.
|
||||
|
||||
## Group / Group handle / Membership list
|
||||
|
||||
- `backend/backend/settings.py:36` — `django.contrib.auth` ships a built-in `Group` model, shown
|
||||
as "Groups" in the Django admin. Not unregistered anywhere. Will collide by name with the
|
||||
proposed actor-type Group once that's implemented — worth a decision now (unregister the
|
||||
built-in admin Group, or otherwise disambiguate) before the real feature lands.
|
||||
- `issues.md` (issue #3, "Group Concept", ~lines 62-156) — a standalone proposal that conflicts
|
||||
with the already-settled `docs/design-in-progress/groups.md` design on three points at once:
|
||||
- `Group.public_key`/`private_key` fields (contradicts "does a group need its own keypair? No").
|
||||
- `GroupMembership` backed by a signed `membership_certificate` rather than a plain membership
|
||||
list (contradicts the glossary's Membership list entry).
|
||||
- Bare `Group.handle` strings with no `#` prefix, e.g. `"makerspace-nord"`, and a
|
||||
`GroupInvitationIncoming.group_handle` field/API surface (`POST /api/groups/` etc., ~lines
|
||||
65-66, 117-121, 150-156) that never uses the `#groupname@domain` shape.
|
||||
This is a design-conflict issue, not a wording tweak — `issues.md` should be reconciled with (or
|
||||
explicitly marked superseded by) `groups.md` before anyone implements from it.
|
||||
|
||||
## Keypair / Private key / Public key
|
||||
|
||||
- Wire-format drift on the one field that actually crosses the network: `befriender_key` is used
|
||||
|
|
@ -100,8 +73,10 @@ it; Actor and Targeted sharing are unimplemented with no competing name anywhere
|
|||
- `backend/configure.py:130` ("Identifier set {} already imported, skipping") and the model
|
||||
`ImportedIdentifierSets` (`backend/hostadmin/models.py:13-19`) — call an imported origin dataset
|
||||
an "identifier set".
|
||||
- `issues.md:206-210` — Instance Admin TODO list: "identifier-sets" for **Origin** and bare
|
||||
"identifiers" for **Classification handle**, both alternate terms not matching glossary names.
|
||||
- `issues.md:200-202` — Instance Admin TODO list: "identifier-sets" for **Origin** (matches
|
||||
`ImportedIdentifierSets`/`configure.py`: one identifier set per `shared_data/*.json` file, named
|
||||
`git:<file>`, i.e. exactly an Origin) and bare "identifiers" for **Classifier** (not Classification
|
||||
handle — a classification handle is the `origin#type:name` pointer, not the entity it names).
|
||||
|
||||
## Alias
|
||||
|
||||
|
|
@ -118,13 +93,6 @@ it; Actor and Targeted sharing are unimplemented with no competing name anywhere
|
|||
column-auto-mapping heuristic treats `"type"` as a synonym for Category:
|
||||
`lowerColumn.includes('category') || lowerColumn.includes('type')`.
|
||||
|
||||
## Item Handle
|
||||
|
||||
- `frontend/src/views/Search.vue:30,52,110` — a field literally named `handle` is computed here
|
||||
(`e.owner==this.user ? e.id : "shared/"+e.owner+"/"+e.id`), but it's a router-path fragment, not
|
||||
an Item Handle: no domain-qualified `user@domain.tld:id` shape, and `e.owner` is a bare username.
|
||||
Whoever implements the real Item Handle later is likely to collide with this existing variable.
|
||||
|
||||
## Item Label
|
||||
|
||||
- `frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue:567-572,822` and
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ and talks to it directly, regardless of where the frontend itself was loaded fro
|
|||
A name that's unique within its own scope and carries, as part of itself, enough information to say
|
||||
where it's authoritative, without needing a central registry to look it up. The general term
|
||||
covering [user handles](#user-handle), [group handles](#group-handle) (proposed),
|
||||
[classification handles](#classification-handle), and [item handles](#item-handle) (proposed).
|
||||
[classification handles](#classification-handle), [User-Qualified IDs](#user-qualified-id), and
|
||||
[Domain-Qualified Short IDs](#domain-qualified-short-id).
|
||||
*See: [federation.md](federation.md#unique-handles)*
|
||||
|
||||
**Strict resolution** (Implemented)
|
||||
|
|
@ -79,15 +80,17 @@ is first trusted for that handle. Currently only exists between users; groups ha
|
|||
groups befriending groups, are proposed extensions of the same mechanism, not a new one.
|
||||
*See: [federation.md](federation.md#cryptography), [groups.md](design-in-progress/groups.md#should-a-group-be-able-to-grant-read-access-to-non-members-group-friends)*
|
||||
|
||||
**Group** (Proposed)
|
||||
**Group** (Implemented)
|
||||
A second kind of [actor](#actor), modeling collective ownership (a club, workshop, or company)
|
||||
rather than any one person owning something. All members hold equal edit rights over what the group
|
||||
owns; membership itself is the privilege, there's no separate owner/member distinction within a
|
||||
group. Backed by a [membership list](#membership-list) rather than a [keypair](#keypair-private-key-public-key),
|
||||
and identified by a [group handle](#group-handle).
|
||||
and identified by a [group handle](#group-handle). Groups can own items and storage locations today
|
||||
(`owner_group` on both); a group having its own friends, or befriending another group, is not
|
||||
implemented, so a group is not yet a full [actor](#actor) in every sense of that entry.
|
||||
*See: [groups.md](design-in-progress/groups.md#what-a-group-is)*
|
||||
|
||||
**Group handle** (Proposed)
|
||||
**Group handle** (Implemented)
|
||||
A [group](#group)'s handle: a name and [domain](#domain) written like a [user handle](#user-handle)
|
||||
but prefixed with `#`, e.g. `#groupname@toolsheddomain.tld`. The prefix keeps groups and users in
|
||||
disjoint namespaces on the same domain (no squatting collision between a user and a group wanting
|
||||
|
|
@ -109,14 +112,20 @@ The asymmetric keypair backing exactly one [user handle](#user-handle); together
|
|||
keypair backing it are what's called an [identity](#identity). The private key signs requests made
|
||||
as that user and never leaves their control; the public key is handed out to establish trust in the
|
||||
handle (at registration, or via a [friend](#friend-friendship) exchange) and is used to verify
|
||||
signatures. Only user handles carry a keypair, not groups, classification handles, or item handles.
|
||||
signatures. Only user handles carry a keypair, not groups, classification handles, or
|
||||
User-Qualified IDs.
|
||||
*See: [federation.md](federation.md#cryptography)*
|
||||
|
||||
**Membership list** (Proposed)
|
||||
The record of which [user handles](#user-handle) currently belong to a [group](#group), maintained
|
||||
by whichever backend is authoritative for the group's handle. What backs a group's identity in
|
||||
place of a keypair: a request "as the group" is a normal signed request from a current member, plus
|
||||
a check against this list, not a request signed by some shared group key.
|
||||
**Membership list** (Implemented)
|
||||
The authoritative record of which [user handles](#user-handle) currently belong to a
|
||||
[group](#group), maintained by whichever backend is authoritative for the group's handle
|
||||
(`Group.members` in `authentication/models.py`). What backs a group's identity in place of a
|
||||
keypair: a request "as the group" is a normal signed request from a current member, plus a check
|
||||
against this list, not a request signed by some shared group key. Distinct from
|
||||
`authentication.models.GroupMembership`, a second, member-side record kept on a *member's own* home
|
||||
backend mirroring the bare fact of belonging (analogous to how a friendship is independently
|
||||
recorded on both sides) — that pointer is not itself authoritative, it exists so a member's own
|
||||
backend can list groups it believes the member belongs to without querying every other backend.
|
||||
*See: [groups.md](design-in-progress/groups.md#does-a-group-need-its-own-keypair)*
|
||||
|
||||
**Signature / Signing** (Implemented)
|
||||
|
|
@ -157,8 +166,8 @@ a property alias specifically, also states a unit conversion (even if it's "iden
|
|||
**Classification handle** (Implemented)
|
||||
Umbrella term for a [tag](#tag-property-category), [property](#tag-property-category), or
|
||||
[category](#tag-property-category) handle, of the form `origin#type:name`, e.g.
|
||||
`git:base#property:length`. Distinct from a [user handle](#user-handle) or [item
|
||||
handle](#item-handle): it names a reusable classification concept, not an actor or an owned thing.
|
||||
`git:base#property:length`. Distinct from a [user handle](#user-handle) or [User-Qualified
|
||||
ID](#user-qualified-id): it names a reusable classification concept, not an actor or an owned thing.
|
||||
*See: [federation.md](federation.md#tags-properties-and-categories)*
|
||||
|
||||
**Definition fingerprint** (Partially implemented)
|
||||
|
|
@ -193,37 +202,57 @@ server or a domain, it can equally be a shared reference dataset (like the files
|
|||
*See: [federation.md](federation.md#tags-properties-and-categories)*
|
||||
|
||||
**Tag / Property / Category** (Implemented)
|
||||
The three kinds of classification entity an item can reference, each identified by a
|
||||
[classification handle](#classification-handle). A property additionally carries unit metadata
|
||||
(`unit_symbol`/`unit_name`), though property *values* on an item are plain, undeclared-type
|
||||
strings today.
|
||||
The three kinds of classification entity an item can reference, collectively called a
|
||||
**classifier**, each identified by a [classification handle](#classification-handle). A property
|
||||
additionally carries unit metadata (`unit_symbol`/`unit_name`), though property *values* on an item
|
||||
are plain, undeclared-type strings today. A single [origin](#origin) (e.g. one
|
||||
`backend/shared_data/*.json` file) typically defines many classifiers at once.
|
||||
*See: [federation.md](federation.md#tags-properties-and-categories), [tags.md](design-in-progress/tags.md)*
|
||||
|
||||
## Items & Physical Labels
|
||||
|
||||
**Item Handle** (Proposed)
|
||||
The compact identifier for a specific item that's meaningful outside its owner's own account:
|
||||
`user@domain.tld:id`, the owner's [user handle](#user-handle) plus a [local id](#local-id). Used
|
||||
where it's already clear from context that it's a Toolshed item, e.g. inside the app, in exports,
|
||||
in logs, so it doesn't need to spell that out or be openable on its own. Contrast with [Item
|
||||
URL](#item-url), the self-contained form for when no such context can be assumed.
|
||||
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
|
||||
**Domain-Qualified Short ID** (Implemented)
|
||||
A [domain](#domain) paired with a [short id](handles-and-shortids.md#short-ids) token, e.g.
|
||||
`toolsheddomain.tld:~DyU`. A bare short id token is opaque and scoped to whichever backend minted
|
||||
it, nothing in the token itself says which backend's id numbering to read it against, so it only
|
||||
means something inside the app instance that's currently talking to that backend. Prefixing it with
|
||||
its domain supplies the missing piece, the same way any other handle's domain half does, so the
|
||||
pair keeps resolving correctly even after being copied out of the instance it was minted on. Unlike
|
||||
a [User-Qualified ID](#user-qualified-id) or [Item URL](#item-url), it isn't limited to owned kinds
|
||||
(any kind in the short id registry can be domain-qualified) and isn't meant to be openable with zero
|
||||
context, no scheme, no path, opaque bit-packed payload, it belongs inside the app or between things
|
||||
that already speak its short-id format - including a physical label meant for the app's own scanner,
|
||||
not a link or code meant to be opened by something with no idea what a Toolshed short id is.
|
||||
*See: [handles-and-shortids.md](handles-and-shortids.md#domain-qualified-short-id)*
|
||||
|
||||
**Item Label** (Proposed)
|
||||
**Item Label** (Implemented)
|
||||
A physical, scannable encoding (QR code, barcode, or similar) of an item's [Item URL](#item-url),
|
||||
meant to be printed and stuck on the physical object it refers to.
|
||||
*See: [items-labels.md](design-in-progress/items-labels.md#goals)*
|
||||
|
||||
**Item URL** (Proposed)
|
||||
The self-contained URL form of an [Item Handle](#item-handle), for use with no context at all, e.g.
|
||||
an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`. Has to open directly
|
||||
to the right frontend and land on the right item on its own, since the reader can't be assumed to
|
||||
already know what it is or which server it belongs to. Any frontend can serve this URL, the host
|
||||
named in it isn't part of the item's identity, only the handle in its path is.
|
||||
**Item URL** (Implemented)
|
||||
The self-contained URL form of an item's [User-Qualified ID](#user-qualified-id), for use with no
|
||||
context at all, e.g. an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`.
|
||||
Has to open directly to the right frontend and land on the right item on its own, since the reader
|
||||
can't be assumed to already know what it is or which server it belongs to. Any frontend can serve
|
||||
this URL, the host named in it isn't part of the item's identity, only the handle in its path is.
|
||||
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
|
||||
|
||||
**Local id** (Implemented)
|
||||
An item's identifier as it exists today: unique only within its owner's own inventory, not
|
||||
meaningful outside that owner's account. The starting point both the [Item
|
||||
Handle](#item-handle) and [Item URL](#item-url) build on.
|
||||
meaningful outside that owner's account. The starting point both a [User-Qualified
|
||||
ID](#user-qualified-id) and [Item URL](#item-url) build on.
|
||||
*See: [federation.md](federation.md#items), [items-labels.md](design-in-progress/items-labels.md#problem)*
|
||||
|
||||
**User-Qualified ID** (Implemented)
|
||||
The compact identifier for a specific owned thing (an item, a storage location, ...) that's
|
||||
meaningful outside its owner's own account: `<owner-handle>:<kind><local-id>`, e.g.
|
||||
`alice@example.com:i42`, the owner's [user handle](#user-handle) (or [group
|
||||
handle](#group-handle) for a group-owned thing) plus a one-letter kind tag (`i` for item, `s` for
|
||||
storage location) and a [local id](#local-id). Used where it's already clear from context that it's
|
||||
Toolshed data, e.g. inside the app, in exports, in logs, so it doesn't need to spell that out or be
|
||||
openable on its own. Generalizes what used to be a bespoke, item-only "Item Handle" concept: the
|
||||
kind letter is what an item-specific `user@domain.tld:id` shape was missing, nothing in that string
|
||||
said it was specifically an item. Contrast with [Item URL](#item-url), the self-contained form for
|
||||
when no such context can be assumed.
|
||||
*See: [handles-and-shortids.md](handles-and-shortids.md#user-qualified-id)*
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ This is the syntax-level reference for two related but separate naming schemes.
|
|||
"Unique Handles" section covers *why* Toolshed hands out handles at all and what each kind (user,
|
||||
group, tag/property/category) means conceptually; this document covers the parsing rules those
|
||||
handles have to follow once they're written down or embedded somewhere - legal characters and
|
||||
escaping. It also covers short ids end to end: a separate, newer scheme for packing small integer
|
||||
id chains into a compact token, implemented in `frontend/src/short-id.js`.
|
||||
escaping. It also covers short ids end to end: a separate scheme for packing small integer id
|
||||
chains into a compact token.
|
||||
|
||||
## Handle syntax
|
||||
|
||||
|
|
@ -15,14 +15,14 @@ A username ends up embedded, unescaped, in several composite formats beyond its
|
|||
can't contain any character that already means something else in one of those: `@` (the
|
||||
user/domain separator in a user handle), `#` (the group-handle prefix, and the origin/type
|
||||
separator in a classification handle, see federation.md's Tags, Properties, and Categories
|
||||
section), `:` (the id delimiter in a proposed Item Handle, the type/name delimiter in a
|
||||
classification handle, and the delimiter in a signed request's `Authorization` header), `+`
|
||||
(reserved as the URL-embedding escape for `#`, see below), `~` (the short-id token prefix, see
|
||||
Short IDs below, and a proposed collision-disambiguation suffix delimiter on a tag's origin), and
|
||||
`/` (the path-segment delimiter every handle and id ultimately sits next to once embedded in a
|
||||
URL). This has to be enforced by an explicit validator rather than left to Django's default
|
||||
`UnicodeUsernameValidator` (`^[\w.@+-]+\Z`), which currently permits both `@` and `+` (its own
|
||||
regex doesn't happen to allow `#`, `:`, or `/`, but that's incidental, not a designed restriction).
|
||||
section), `:` (the kind/id delimiter in a User-Qualified ID (see below), the type/name delimiter in
|
||||
a classification handle, the domain/token delimiter in a Domain-Qualified Short ID (see below), and
|
||||
the delimiter in a signed request's `Authorization` header), `+` (reserved as the URL-embedding
|
||||
escape for `#`, see below), `~` (the short-id token prefix, see Short IDs below, and a
|
||||
collision-disambiguation suffix delimiter on a tag's origin), and `/` (the path-segment delimiter
|
||||
every handle and id ultimately sits next to once embedded in a URL). This has to be enforced by an
|
||||
explicit validator rather than left to a framework default, which doesn't draw the line in the same
|
||||
place.
|
||||
|
||||
### Embedding a `#`-bearing handle in a URL
|
||||
|
||||
|
|
@ -44,17 +44,45 @@ otherwise forbidden in every field a handle is built from (see Reserved characte
|
|||
group name or tag name could itself contain a literal `+`, it would be indistinguishable from an
|
||||
escaped `#` once decoded.
|
||||
|
||||
Implemented in `frontend/src/handle-url.js` (`encodeHandleForUrl`/`decodeHandleFromUrl`).
|
||||
### User-Qualified ID
|
||||
|
||||
Owned things (an item, a storage location, ...) can be referred to outside their owner's own
|
||||
account: anything whose id is only unique within one owner's own numbering needs an owner-qualified
|
||||
form to resolve globally.
|
||||
|
||||
A **User-Qualified ID** is an owner handle (a user handle, or a group handle for a group-owned
|
||||
thing) with a kind letter and a local id appended, separated by a single `:`:
|
||||
`<owner-handle>:<kind><local-id>`, e.g. `alice@example.com:i42`. This is the same `origin#type:name`
|
||||
pattern classification handles already use, built on `:` instead of `#` so it avoids the URL
|
||||
fragment-escaping problem (see Embedding a `#`-bearing handle in a URL above) — a User-Qualified ID
|
||||
isn't meant to sit directly in a URL path segment.
|
||||
|
||||
Its payload is a plain decimal local id with nothing self-describing baked in, unlike a Short ID's
|
||||
bit-packed payload, so the owner is spelled out as a full handle rather than just a domain:
|
||||
`example.com:i42` would be ambiguous between every user on that domain with local item id `42`;
|
||||
`alice@example.com:i42` isn't. An owner handle already carries its domain, so a User-Qualified ID is
|
||||
cross-domain-resolvable as written, with no separate domain-qualified wrapper needed.
|
||||
|
||||
The kind letter is a small, closed registry. A group handle already looks visibly different from a
|
||||
user handle (`#` prefix), so unlike the short id kind registry below, this one doesn't need separate
|
||||
letters for a user-owned vs. group-owned kind — the owner half already says which it is.
|
||||
|
||||
| letter | kind | notes |
|
||||
|---|---|---|
|
||||
| `i` | item | covers both a user-owned item and a group-owned item |
|
||||
| `s` | storage location | covers both a user-owned and a group-owned storage location |
|
||||
|
||||
`workflow` has no letter assigned; the registry can grow without breaking anything already printed.
|
||||
Kinds with no owner (`category`, `group`, `file`) don't fit this scheme: `category` and `group` each
|
||||
already have their own dedicated handle form. `file` has neither an owner to qualify by nor a
|
||||
dedicated handle of its own; a Domain-Qualified Short ID (below) is the fallback for it.
|
||||
|
||||
## Short IDs
|
||||
|
||||
A general encoding for turning a small, fixed-shape list of integers into a compact, URL-safe
|
||||
token, with no server-side lookup table involved: the code *is* the data, nothing is stored
|
||||
server-side to make it resolvable. Originally proposed to answer items-labels.md's open "what does
|
||||
the handle/URL actually look like" question, but the encoding itself isn't item-specific; anything
|
||||
currently addressed by a short chain of small integers is a candidate. Implemented and tested in
|
||||
`frontend/src/short-id.js`; try it live at `/~<token>` (`frontend/src/views/ShortId.vue`), which
|
||||
decodes whatever token is in the URL and also lists worked examples for every registered kind.
|
||||
server-side to make it resolvable. Not item-specific; anything currently addressed by a short chain
|
||||
of small integers is a candidate.
|
||||
|
||||
### Shape: a kind tag, then a fixed list of integers
|
||||
|
||||
|
|
@ -67,29 +95,21 @@ range" - so kind 3 is encoded as escape + chunked-int `0`, kind 4 as escape + `1
|
|||
costs nothing for a kind that already fits in the direct range, and keeps the tag itself extensible
|
||||
forever without ever having to widen it out from under codes that were already printed. A narrow
|
||||
tag only pays off if kind usage is actually skewed the way id values are (a few kinds dominate),
|
||||
which is why the registry below is ordered by expected frequency, cheapest (most-used) kind first:
|
||||
which is why the registry below is ordered by expected frequency, cheapest (most-used) kind first.
|
||||
Kind ids are scoped **per arity**, not globally: the same id can (and does) name a different kind
|
||||
depending on how many fields follow it, since the arity is always known before the kind id needs
|
||||
disambiguating:
|
||||
|
||||
| kind | name | fields | notes |
|
||||
|---|---|---|---|
|
||||
| 0 | `item` | `owner_identity_id`, `item_local_id` | dominant case - the primary physical-label use case |
|
||||
| 1 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
|
||||
| 2 | `category` | `category_id` | label-adjacent (tagging); global, no owner |
|
||||
| 3 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - first to pay the escape's cost |
|
||||
| 4 | `group` | `group_id` | shared even less often; global, no owner |
|
||||
| 5 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
|
||||
|
||||
`owner_identity_id` is `KnownIdentity.pk` (`backend/authentication/models.py`), not
|
||||
`ToolshedUser.pk`. Every local account already has exactly one stable `KnownIdentity` row
|
||||
(`ToolshedUser.public_identity`, created once at registration and never recreated), and every
|
||||
friend this backend knows about - local or remote - is represented by that same table, unique on
|
||||
`(username, domain)`. So one small integer already stands in for "this owner, as known by this
|
||||
backend" for both cases, with no separate local-vs-remote branching needed, and it's the same row
|
||||
federation.md's Cryptography section already treats as the trust anchor for a handle's public key.
|
||||
It appears on `item`, `storage_location`, and `workflow` because their backing models
|
||||
(`InventoryItem`, `StorageLocation`, `WorkflowInstance`) all FK `ToolshedUser` directly; `category`,
|
||||
`group`, and `file` skip it because their models are global/unscoped (`Group` has an unowned
|
||||
`members` M2M, `File` is deduplicated globally by content hash), so a bare row id is already
|
||||
everything needed to look them up.
|
||||
| kind id | arity | name | fields | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0 | 2 | `item` | `owner_identity_id`, `item_local_id` | dominant case - a personally-owned item, the primary physical-label use case |
|
||||
| 0 | 1 | `category` | `category_id` | label-adjacent (tagging); global, no owner - shares id 0 with `item` since the two never need the same arity |
|
||||
| 1 | 2 | `group_item` | `owner_group_id`, `item_local_id` | same use case as `item`, but for a group-owned item |
|
||||
| 1 | 1 | `group` | `group_id` | shared even less often; global, no owner |
|
||||
| 2 | 2 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
|
||||
| 2 | 1 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
|
||||
| 3 | 2 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - needs the escape range, since every other slot in the direct range (0-2) is already double-booked across the two arities in use |
|
||||
| 4 | 2 | `group_storage_location` | `owner_group_id`, `storage_location_id` | same use case as `storage_location`, but for a group-owned location (see `group_item` above for the same owner/owner_group split); the direct range is fully double-booked, so this is the second kind that needs the escape range |
|
||||
|
||||
A short id is inherently scoped to the backend that minted it (an "owner" field is a row that only
|
||||
exists in, and only means anything to, that one backend's database), not a portable replacement for
|
||||
|
|
@ -119,13 +139,12 @@ Standard base64 assumes byte-aligned (8-bit) input, grouping 3 bytes into 4 outp
|
|||
padding to a byte boundary before encoding. Since there's no byte layer here to begin with, the
|
||||
bit-packed stream is instead packed directly into 6-bit groups and mapped straight onto the
|
||||
URL-safe base64 alphabet (RFC 4648 §5: `-` and `_` in place of `+` and `/`), with the final
|
||||
character's unused low bits padded with zeros. That padding is safe by construction: the decoder
|
||||
always knows exactly how many integers a given kind calls for, and a chunk's continuation bit is
|
||||
`1 = more follows`, so a run of zero-padding at the very end can never be misread as "one more
|
||||
chunk" - it decodes as a terminated chunk, at which point every field the schema called for has
|
||||
already been produced and decoding simply stops. No `=` padding characters are needed either; those
|
||||
exist in classic base64 purely to communicate trailing-byte padding, and there is no byte layer
|
||||
here to need that.
|
||||
character's unused low bits padded with zeros. That padding is safe by construction: it's always
|
||||
0-5 zero bits (just enough to reach the next multiple of 6), and the decoder stops reading fields
|
||||
the moment a chunk decodes to 0 with nothing left after it - by the last-field-nonzero rule above,
|
||||
that can only be the padding, never a genuine field, so it's discarded rather than counted. No `=`
|
||||
padding characters are needed either; those exist in classic base64 purely to communicate
|
||||
trailing-byte padding, and there is no byte layer here to need that.
|
||||
|
||||
### The leading `~`
|
||||
|
||||
|
|
@ -140,6 +159,36 @@ short id can be handed to any part of the stack without first checking which enc
|
|||
there.
|
||||
|
||||
|
||||
### Domain-Qualified Short ID
|
||||
|
||||
A short id token is deliberately opaque and scoped to whichever backend minted it: handed a bare
|
||||
`~DyU`, nothing in the token itself says which backend's numbering it should be read against.
|
||||
That's fine as long as the token stays inside a context that already knows the answer (the app the
|
||||
user is currently looking at), but not once a token needs to travel outside that context, e.g.
|
||||
pasted into a message to a friend on a different domain, or logged somewhere not tied to one
|
||||
backend.
|
||||
|
||||
The fix is the same one every other cross-domain handle in this project already uses: pair the
|
||||
opaque part with the domain that's authoritative for it. A **Domain-Qualified Short ID** is a short
|
||||
id token prefixed with a domain and a literal `:`, e.g. `toolsheddomain.tld:~DyU`. The domain half
|
||||
is exactly a handle's domain half (see federation.md's Unique Handles section): not a location,
|
||||
just a statement of which backend to resolve the token against. Decoding still means handing the
|
||||
`~token` half to that backend's decoder, same as ever; it's just no longer ambiguous which
|
||||
backend's decoder to hand it to.
|
||||
|
||||
This is deliberately not the same thing as a User-Qualified ID (`owner-handle:i42`) or an Item URL.
|
||||
It isn't a URL and isn't meant to be openable by something that doesn't already know what a
|
||||
Toolshed short id is: there's no scheme, no path, and the payload after `:~` is bit-packed base64,
|
||||
opaque to a human. It belongs to the same "context already makes clear it's Toolshed data" class as
|
||||
a compact User-Qualified ID, meant for use inside the app (or between things that already speak the
|
||||
short-id format, including a physical label meant for the app's own scanner) - not a link or code
|
||||
meant to be opened by something with no idea what a Toolshed short id is. What it adds over a bare
|
||||
`~token` is that it no longer depends on "whichever backend I currently happen to be talking to" —
|
||||
the domain travels with it, so it keeps resolving to the same entity once copied elsewhere. Unlike
|
||||
a User-Qualified ID, it isn't limited to owned kinds: every kind in the short id registry can be
|
||||
domain-qualified the same way, since the domain only ever names which backend's token namespace
|
||||
applies, not which kind the token decodes to or whether that kind has an owner.
|
||||
|
||||
### Worked examples
|
||||
|
||||
Encoding `kind = item` (0), `owner_identity_id = 7`, `item_local_id = 42`:
|
||||
|
|
@ -152,17 +201,19 @@ Total: 17 meaningful bits, padded to the next multiple of 6 (18) with one zero b
|
|||
base64 characters: **`~DyU`**.
|
||||
|
||||
The same worked-out form for one example of every registered kind - `Bits` is the same
|
||||
space-separated segmentation (kind tag, escape offset if present, each field, then padding) the
|
||||
Examples table on `/~<token>` (`frontend/src/views/ShortId.vue`) shows for every registered kind:
|
||||
space-separated segmentation: kind tag, escape offset if present, each field, then padding. Kinds
|
||||
are grouped by shared id below to make the per-arity reuse visible:
|
||||
|
||||
| Kind | Fields | Serialized | Bits | Token |
|
||||
|---|---|---|---|---|
|
||||
| `item` | `owner_identity_id: 7`, `item_local_id: 42` | `[0, 7, 42]` | `00 00111 1001001010 0` | `~DyU` |
|
||||
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[1, 3, 1000]` | `01 00011 100111111001000 00` | `~Rz8g` |
|
||||
| `category` | `category_id: 5` | `[2, 5]` | `10 00101 00000` | `~ig` |
|
||||
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wCS` |
|
||||
| `group` | `group_id: 11` | `[4, 11]` | `11 00001 01011` | `~wr` |
|
||||
| `file` | `file_id: 123` | `[5, 123]` | `11 00010 1011101011 0` | `~xXW` |
|
||||
| `category` | `category_id: 5` | `[0, 5]` | `00 00101 00000` | `~Cg` |
|
||||
| `group_item` | `owner_group_id: 5`, `item_local_id: 42` | `[1, 5, 42]` | `01 00101 1001001010 0` | `~SyU` |
|
||||
| `group` | `group_id: 11` | `[1, 11]` | `01 01011 00000` | `~Vg` |
|
||||
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[2, 3, 1000]` | `10 00011 100111111001000 00` | `~hz8g` |
|
||||
| `file` | `file_id: 123` | `[2, 123]` | `10 1011101011` | `~rr` |
|
||||
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wiS` |
|
||||
| `group_storage_location` | `owner_group_id: 5`, `storage_location_id: 1000` | `[4, 5, 1000]` | `11 00001 00101 100111111001000 000` | `~wln5A` |
|
||||
|
||||
`workflow`, `group`, and `file` are kinds 3-5, so their `Bits` column shows the escape tag (`11`)
|
||||
followed by its own offset segment payload fields.
|
||||
273
docs/implementation.md
Normal file
273
docs/implementation.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# Implementation Notes
|
||||
|
||||
Design rationale and non-obvious invariants that used to live as long inline comments, moved here so the code stays skimmable while the reasoning stays discoverable. Code comments reference these sections by anchor.
|
||||
|
||||
## Content-Addressed Files & Caching
|
||||
|
||||
### FileCache Design Rationale
|
||||
`fileCache` (`frontend/src/fileCache.js`) is a shared, session-lifetime cache for the bytes behind `AuthenticatedImage`'s `src` (a `/media/...` hash-addressed path; see `backend/files/media_urls.py`). Content there is immutable and hash-addressed (SHA-256), so a cache hit never needs revalidation — the same `src` string can only ever resolve to the same bytes, for any owner or requester.
|
||||
|
||||
It is deliberately NOT Vuex state: it holds `Blob`/object-URL data that nothing needs reactive access to (components only bind the resulting object-URL string, which they hold in their own local state), so a plain module-level `Map` avoids Vue's reactivity overhead entirely and sidesteps proxying `Blob` instances for no benefit.
|
||||
|
||||
### SHA-256 File Hashing Must Match The Backend
|
||||
Before uploading, the frontend computes a SHA-256 hash of a file's raw bytes via the Web Crypto API (`crypto.subtle.digest('SHA-256', ...)`). This hash must match the one the backend computes over the same bytes (`backend/files/models.py`, `hashlib.sha256`), so the hash computed client-side can later be used to identify the same `File` row server-side without a mismatch. This same computation appears in `CameraFileSource.vue`, `DragDropFileSource.vue`, `FsFileSource.vue`, and `WebcamFileSource.vue`.
|
||||
|
||||
### Stale Orphan Cleanup At The Canonical Hash Path
|
||||
`FileManager.create()` (`backend/files/models.py`) derives each file's upload path entirely from its content hash (`hash_upload`), and `hash` is unique in the database. That means if no `File` row currently owns a given hash, anything already sitting at that hash's computed storage path is necessarily a stale orphan — for example, left behind by a bug in cleanup code that deleted a `File` row without removing its stored bytes, or by a crashed upload.
|
||||
|
||||
Before saving a new `File` row, `create()` proactively deletes anything already at the expected path rather than letting Django's storage layer invent an alternate filename to avoid the apparent "collision". A suffixed name would silently break every part of the app that derives a file's URL purely from its hash (media serving, thumbnail generation, item/avatar attachment), and no future caller could ever discover the file again. `backend/files/tests.py`'s `test_file_upload_reclaims_stale_orphan_at_canonical_path` is a regression test for this exact scenario.
|
||||
|
||||
### RGBA Flattening Avoids Revealing Black Under Transparent Pixels
|
||||
When generating thumbnails (`backend/files/media_urls.py`, `thumbnail_urls`), the source image is first converted to RGBA and composited onto an opaque white background before being flattened to RGB and saved as JPEG. This matters because some source modes (grayscale+alpha, palette-with-transparency, RGBA) store meaningless color or luminance data underneath fully transparent pixels, which is often zeroed out (i.e. black). Converting straight to RGB without compositing reveals that zeroed data as solid black instead of showing nothing, producing a thumbnail that looks broken even though the visible, opaque content is fine.
|
||||
|
||||
`backend/files/tests.py`'s `test_thumbnail_flattens_transparency_instead_of_going_black` is a regression test built around an `'LA'` (grayscale + alpha) source image whose transparent half has zeroed-out luminance underneath, reproducing exactly this failure mode.
|
||||
|
||||
## Label Rendering Engine
|
||||
|
||||
### QR Encoder Loading
|
||||
`anyd-qr.js`'s own `loadAnyDCode()` memoizes the wasm instantiation itself, so calling it more than once (each of `Print.vue` and `LabelLayoutPreview.vue` does, on mount) is free — `label.js`'s module-level `anyd` variable just mirrors its resolved value so `buildRenderTree` can use it synchronously. Until it resolves, a QR-family leaf (any `QR_LEAF_TYPES` entry) throws (see `encodeQr`) the same way an oversized value already does — callers already have to handle `layoutContent` throwing, so this reuses that path rather than adding a second failure mode.
|
||||
|
||||
### QR Leaf Type Mapping
|
||||
`QR_LEAF_TYPES` maps each of `label-layouts.js`'s `LABEL_TEMPLATES` leaf types that draw a code to the `anyd-qr.js` symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's `EncodeOptions` — `ecc`/`size` — and its per-symbology `EcLevel` enums, `wasm.rs`'s `qr_ec`/`micro_ec`/`rmqr_ec`/`rmqr_size`). Which combination a given label uses is baked into its layout tree (see `label-layouts.js`'s `"qr"`-prefixed templates), rather than a single choice applied to every code leaf alike, so there's no longer a global selector for any of them (see `Print.vue`).
|
||||
|
||||
A plain symbology id (no suffix) always means anyd's own defaults — ecc `"M"`, rMQR size `"balanced"` — every other value gets a `"-<name>"` suffix naming it:
|
||||
- **ecc**: the same letter anyd itself uses (`qr_ec`/`micro_ec`'s L/M/Q/H), except micro-qr's `"Detection"` (`MicroEcLevel::Detection`, an M1-only error-*detection*-but-not-correction mode with no plain single-letter grade of its own). Coverage isn't uniform across symbologies (see `qr_ec`/`micro_ec`/`rmqr_ec`) — full QR takes all four grades, Micro QR swaps `"L"` (QR's actual lowest) for `"Detection"` (lower still, but M1-only) and has no `"H"` at all, and rMQR only ever supports `"M"` or `"H"`.
|
||||
- **size** (rMQR only, see `rmqr_size`/`SizeStrategy`): `"min"`/`"max"` prefer the shortest (flattest, widest) or tallest (narrowest) symbol that fits the text, over the default `"balanced"` (smallest total module area) — which shape to prefer depends on which of the tape's two axes (across vs. along the feed) is more constrained.
|
||||
|
||||
rMQR's matrix isn't square (see `encodeQr`'s width/height), unlike qr/micro-qr, which always are.
|
||||
|
||||
### QR Module Matrix Shim
|
||||
`encodeQr` wraps anyd's row-major `Uint8Array` matrix in a BitMatrix-alike shim (`{width, height, get(row, col)}`), matching the shape `drawQrLeaf`/`snapQrToCrispSize` expect — they predate this and were written against the old `"qrcode"` package's own `modules.size`/`get()` shape. anyd's matrix already excludes the quiet zone from width/height (see its `ModuleMatrix` type), same as the old library's BitMatrix. width/height are kept separate rather than a single `size` (the old library's own shape, always square) since rMQR symbols are rectangular.
|
||||
|
||||
### Layout Tree Structure
|
||||
A layout is a tree built from two shapes, alternating orientation by nesting depth:
|
||||
|
||||
- An array is a "split" node: its children sit side by side (a *row*) at even depth (the root, depth 0, is always a row), or stacked (a *column*) at odd depth. To turn a row into a column, wrap it in an extra one-element array — that array is one depth deeper, so its lone child (the original row) is now read at odd depth.
|
||||
- An object is a leaf: `{type, content}` where `type` is one of `QR_LEAF_TYPES`' keys draws a QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR Leaf Type Mapping above), `{type: "text", content}` draws a text block — either way `content` is a function from the resolved field values to the string (or, for `"text"`, an array of strings — one per line) to render. `{type: "empty", "min-width": "2mm"}` / `{type: "empty", "min-height": "2mm"}` is a spacer with no ink of its own — the *only* way padding/gaps enter a layout, since nothing here draws a border, margin or gap on its own. An "empty" leaf's dimension always names the axis its enclosing split flows along: `"min-width"` inside a row, `"min-height"` inside a column.
|
||||
|
||||
See `label-layouts.js`'s `LABEL_TEMPLATES` for concrete trees.
|
||||
|
||||
### Pixel Font Selection
|
||||
Below 33px, a general-purpose sans-serif gets blurry/illegible, so `drawTextLeaf` switches to one of `fontTierFor`'s bitmap-style fonts instead (see `../assets/fonts/pixel/LICENSE.md`) — Silkscreen/Chava/Pixelon/Terminus across increasingly larger size bands, snapping to whichever fixed/hinted size each reads best at rather than whatever `fontPx` literally asks for. Unlike the top (Inter) tier, these are rendered through `pixel-font.js`'s `drawPixelText` rather than canvas `fillText` — see Freetype Production Rendering below for why. At 33px and up, `fontTierFor` names Inter explicitly (see `../scss/_label-fonts.scss`) rather than falling back to the CSS generic `sans-serif` keyword — that keyword resolves to a different real font per browser/OS, which would make the same label print differently depending on where it was rendered from.
|
||||
|
||||
Every tier was chosen only after rendering through `prototypes/freetype-wasm/ladder.html` — FreeType's own monochrome rasterizer at real canvas pixel sizes — and inspecting the actual pixels; checking that `fillText` was merely *called* doesn't confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the number actually handed to the rasterizer. Two earlier candidates were tried and dropped: Tom Thumb's declared ascent/descent (0 / ~fontPx, backwards from a normal font) turned out not to be a centering quirk — its actual visible ink was only ~1/3.2 of its own nominal font-size — and PICO-8 rendered cleanly but has no lowercase glyphs at all (silently draws lowercase input as uppercase), ruling it out for real label content (item handles, URLs) that isn't reliably all-caps; see `frontend/src/assets/fonts/pixel/LICENSE.md`. Chava carries the same uppercase-only limitation but was kept anyway, restricted to a narrow size band, since PICO-8's dealbreaker didn't get applied consistently — see that same LICENSE.md.
|
||||
|
||||
Each tier's `size` (the actual px handed to the rasterizer) replaces the old Tom Thumb-only `scale` correction, generalized: `fontPx` (the layout-derived logical size, compared against each branch's threshold) still drives box centering/stacking regardless of what `size` a tier resolves to. Those thresholds and `MIN_TEXT_PX` are both compared against that same logical, unscaled `fontPx`, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever sees a raw pixel count, with no notion of "physical size" at all, so that's what determines whether a glyph's fine detail survives — confirmed by real-Chromium testing, where a raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would have implied.
|
||||
|
||||
A tier's optional `tracking` retunes glyph spacing (`drawPixelText` adds it to every glyph's advance) without ever touching the rendered glyph size.
|
||||
|
||||
### Freetype Production Rendering
|
||||
Canvas 2D's `fillText` is always anti-aliased — there is no monochrome/hinted-bitmap text mode in the API, however carefully a glyph's size/position are snapped to the pixel grid — and `weblabel.js`'s `bitmap.js` hard-thresholds the whole canvas at 50% luminance before it ever reaches a printer (a pin either fires or it doesn't). Anti-aliased glyph edges surviving into that threshold read as stray or missing pixels, not a clean bitmap-font shape. `pixel-font.js`'s `drawPixelText` sidesteps this for every `fontTierFor` tier below Inter by rendering through the same FreeType-wasm rasterizer `ladder.html` uses to *pick* those fonts in the first place (`FT_LOAD_TARGET_MONO`, true 1-bit output) and blitting the result at integer pixel offsets — `measureLine`'s pass computes a line's real ink extent (advance width, max ascent/descent actually reached by *these* glyphs) the same way `drawTextLeaf`'s old `ctx.measureText(...).actualBoundingBox*` did, so centering math is unchanged; only where the pixels themselves come from is different. Inter (the one non-`pixel` tier) still uses `fillText` — anti-aliasing is expected/fine at that size, the same reasoning `weblabel.js`'s own text renderer relies on ("what stops small text turning to mush is not the threshold but the size").
|
||||
|
||||
`preloadPixelFontRenderer()` (re-exported from `label.js`, defined in `pixel-font.js`) loads `freetype.wasm` and fetches every bitmap tier's font bytes up front, the same pattern as `preloadQrEncoder`/`anyd` (see QR Encoder Loading) — `drawTree`/`drawTextLeaf` stay fully synchronous, so both `Print.vue` and `LabelLayoutPreview.vue` await it (alongside `preloadQrEncoder`) before their first `redraw()`, and `drawPixelText` throws the same "still loading" shape of error `encodeQr` does if a draw is ever attempted before it resolves. The wasm module holds exactly one font face loaded at a time (`ft_load_font` replaces whatever was there); `ensureFace` skips the reload when consecutive leaves already share a family.
|
||||
|
||||
### Affine Width Height Relations
|
||||
Every node's width/height relate to each other affinely — `width = A*height + B` for a node read in row context, `height = A*width + B` in column context — because a leaf is either scale-free (a text block, whose aspect ratio holds at any size: `A = aspect` or `1/aspect`, `B = 0`) or a fixed physical size (an "empty" spacer, or a QR code once its crisp pixel size is known — see Crisp QR Sizing: `A = 0`, `B` = the size in px). Splits combine their children's relations by addition (a row's total width is the sum of each child's width for the shared height, and symmetrically for a column), which stays affine, so the same two numbers describe a whole subtree no matter how deeply it nests.
|
||||
|
||||
`ownAxis` is true if the split directly containing `node` is a row, false if a column — for a leaf, that's what "empty" measures itself against; for a split, its own axis (and thus how it combines its children) is always the opposite, per the alternating-depth rule. `wantWidth` is true to ask for `{A, B}` such that `width = A*height + B`, false for `height = A*width + B`; requesting the direction a split doesn't naturally combine in just inverts its own relation.
|
||||
|
||||
### Fixed Size Relation Edge Case
|
||||
In `relation()`, when every child is a fixed size (`a === 0`) in the combining direction — e.g. a row that's just one crisp QR leaf, with no scale-free (text) sibling to invert against — inverting "width = b" for an `a` of 0 would divide by zero: a constant width genuinely doesn't determine a height, since nothing here actually scales with it. Instead, ask each child directly for its own size in the wanted direction (every one of them must be similarly fixed, since only a fixed leaf ever contributes `a === 0`), and take the largest — the shared dimension has to fit whichever child needs the most room, with any child that ends up with room to spare centered within it (see `drawQrLeaf`).
|
||||
|
||||
### Crisp Qr Sizing
|
||||
A QR code needs an integer number of pixels per module to render crisply rather than blurring at a fractional scale, so its true size is whatever that rounds down to — almost never the scale-free box its aspect ratio alone would suggest. `snapQrToCrispSize`, called once every QR-family leaf has a provisional (scale-free) box from a first `layoutTree` pass, pins each one's real `box.width`/`box.height` as `crispWidth`/`crispHeight`, so `relation()` starts treating it as a fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's offered. A second `relation()`/`layoutTree()` pass (see Label Content Layout) then resizes everything else around that real footprint, so nothing downstream reserves — and leaves unfilled — room for a squarer/differently-shaped code than what actually gets drawn. Kept as two independent dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR symbol isn't square — see `encodeQr`.
|
||||
|
||||
### Render Tree Construction
|
||||
`buildRenderTree` turns a resolved content tree (see `templateContent` — leaf objects carry a `value` rather than a `content` function) into one ready for layout: a QR-family leaf gets its actual encoded modules (see `encodeQr`, keyed off the leaf's own type via `QR_LEAF_TYPES`) and an aspect ratio taken from their real width/height — 1 (square) for qr/micro-qr, but not for rmqr, whose symbols are rectangular — a text leaf gets its measured natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf, not one per line — splitting it into a column of independently-sized leaves would let each line grow to its own full width, ending up at a different font size than its neighbors, which is legible but not what "one text field" should look like.
|
||||
|
||||
### Canvas Font Loading
|
||||
Applies only to Inter, the one `fontTierFor` tier still drawn with `fillText` (see Freetype Production Rendering above — every other tier goes through `pixel-font.js` instead, which fetches its own font bytes directly and has no `@font-face`/`document.fonts` involvement at all). A `@font-face` family already in use elsewhere on the page loads in time for `drawTextLeaf`'s use, but canvas text silently falls back to the next font in the stack (there isn't one here, so the browser default) if drawn before its first-ever load finishes — unlike DOM text, a canvas `fillText` never waits or repaints on its own once the real font arrives. Kicking off `document.fonts.load()` there means only that very first draw at a given size risks the fallback; every redraw after it (`Print.vue`'s live preview redraws on every keystroke) picks up the real font.
|
||||
|
||||
### Label Content Layout
|
||||
`layoutContent` builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web `printAreaPx`, or the fallback preview's reference height) — the one dimension every layout scales from, plus `pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content fully determine its overall size along the other, growing axis (the one that runs along the tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that doesn't fit rather than shrinking it.
|
||||
|
||||
`orientation` picks which axis `fixedSize` binds to: `"along"` (the default) fixes the tree's height — the tape's cross-web width — and grows its width along the feed direction, same as a plain read top-to-bottom design. `"across"` fixes the tree's width instead and grows its height, so the design is built turned 90deg from how it'd read `"along"` — `drawLabel`/`drawFallbackLabel` are what actually rotate the drawing back into the physical raster's fixed orientation; nothing here needs to know about that rotation, since `relation()`/`layoutTree()` already solve the tree in either direction symmetrically.
|
||||
|
||||
Sizing runs twice: a first pass treats every QR-family leaf as the scale-free box its real width/height ratio suggests, purely to find out how much room each one would actually be offered; from that, `snapQrToCrispSize` pins each one's real (smaller, crisp-pixel) size. The second pass then resolves the whole tree again with that real size fixed in, so every sibling and the overall size reflect what's actually drawn rather than the idealized box no code ever quite fills.
|
||||
|
||||
### Tape Fed Label Drawing
|
||||
`drawLabel` is the tape-fed layout — it draws a fully resolved content tree (see `templateContent`) at the tape's real pixel dimensions. `orientation` is `"along"` (the default) to lay the design out reading along the tape's feed direction, or `"across"` to turn it 90deg so it reads across the tape instead — either way the physical raster this returns is still exactly `printedLength x tape.printAreaPx` (that's fixed by the tape/print head, not a choice this makes); `"across"` just draws the (now width-fixed, see Label Content Layout) tree through a rotated canvas transform so it lands correctly in that same raster, rather than transposing every box the tree itself computed. See `DEBUG_LEAF_BORDERS` to outline every leaf's box.
|
||||
|
||||
### Across Orientation Rotation
|
||||
When `drawLabel` draws with `orientation === "across"`, the tree was solved width-fixed (see Label Content Layout) — its width already exactly fills `tape.printAreaPx`, so only its (growing) height needs the same along-the-feed centering `origin` got earlier; `translate`+`rotate` then carries that tree-local `(x, y)` box straight into the physical (`printedLength x printAreaPx`) raster, a quarter turn at a time.
|
||||
|
||||
### Fallback Label Preview
|
||||
`drawFallbackLabel` is the no-webusb preview/PNG — same layout tree and renderer as `drawLabel`, just scaled from a fixed reference height instead of a real tape's, and with no `maxLength` (there's no physical tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's no real print head here to keep clear of.
|
||||
|
||||
## Label Templates, Preview & Pixel Fonts
|
||||
|
||||
### Template Layout Tree
|
||||
A template's `layout` is a tree as described in label.js, with leaves whose `type` is one of label.js's `QR_LEAF_TYPES` keys or `"text"`, and whose `content` is a function from the resolved field values (see label.js's `buildLabelFields`) to what they render. `null`/`undefined` returned from that function means the field isn't available yet (see `templateIsAvailable`). A template is only selectable once every leaf's `content` resolves to a value.
|
||||
|
||||
### Generated QR-Only Template Matrix
|
||||
`QR_ONLY_TEMPLATES` is the full "just the code" matrix: every `{symbology, error-correction level, [rMQR] size strategy}` combination label.js's `QR_LEAF_TYPES` supports, one template each. A plain `id` (no suffix) is always anyd's own defaults: ecc `"M"` and, for rMQR, size `"balanced"`. Coverage isn't uniform (see `QR_LEAF_TYPES`): full QR gets all four ecc grades L/M/Q/H; Micro QR swaps `"L"` (QR's actual lowest) for the even-lower, M1-only, detection-only `"Detection"`, and has no `"H"` at all; rMQR only ever supports ecc `"M"` or `"H"`, each crossed with all three size strategies (balanced/min/max). The matrix is generated (rather than hand-writing every near-duplicate entry) so that a symbology/level/size combination it's missing is one new row here, not a new block to keep in sync with its neighbors. `id` doubles as the layout's leaf `type`, since that's exactly what `QR_LEAF_TYPES` is keyed by.
|
||||
|
||||
### Content Kinds Registry
|
||||
`CONTENT_KINDS`/`CONTENT_KINDS_BY_ID` (`label-layouts.js`) is the single registry for everything that varies per content kind (currently `item`/`storage-location`): the handles-and-shortids.md kind letter (`typedPrefix`), the short-id.js schema name to use for an individually- vs. group-owned thing of that kind (`shortIdKind`/`groupShortIdKind`), the field name short-id.js's `serializeShortId` expects for its local id (`localIdField`), and a builder for the kind's long-form URL (`buildUrl`, `undefined` for a future kind added with no such route - see the `url` `DERIVED_VARS` entry). Every place that used to special-case "item vs. storage location" - `label.js`'s prefill builders, `label-layouts.js`'s `DERIVED_VARS`, Print.vue's `shortId()` - reads this one table instead, keyed by the Content card's `kind` field (a `<select>`, see Print.vue) rather than inferring which kind is meant from which id-shaped field happens to be populated.
|
||||
|
||||
### Derived Vars Shape And Ordering
|
||||
A derived var (`DERIVED_VARS`) is a format string calculated from other vars rather than typed directly - it doesn't get its own input, just a read-only, live-recalculated display next to the ones that do (see Print.vue and `withDerivedVars`). Its `inputs` name every var (base or, in principle, another derived one - e.g. `qualifiedHandle`/`url`, which both read the derived `userHandle`, and `qualifiedHandle` also reads the derived `typedPrefix`) that `calc` reads. `inputs` is declared up front rather than inferred from `calc`'s body so `BASE_VARS` can include a var like `"webdomain"` that only feeds a calculation and that no template ever references directly. Declaration order matters here: `withDerivedVars` runs these in a single pass, so a derived var must be declared after every other derived var it depends on - `typedPrefix` before `qualifiedHandle`, in particular.
|
||||
|
||||
### Kind And Id Known Vars
|
||||
`BASE_VARS` is computed purely from `KNOWN_VARS` (every template's `required_vars`, see `templateIsAvailable`) plus whatever `DERIVED_VARS` reads - there's no separate registry of "fields a prefill might supply". `kind` and `id` reach `BASE_VARS` (and so the Content card's form, and `fields()`) the same way any other var does: named directly in several templates' `required_vars`, and `kind` also as an input of the derived `typedPrefix`. A content var meant to be prefillable/typeable needs that same anchor - at least one template naming it in `required_vars`, or some `DERIVED_VARS` entry reading it - or `fields()` (which only copies `BASE_VARS` keys out of `varValues`) silently drops it.
|
||||
|
||||
## Print View
|
||||
|
||||
### Tape-Full Print Margin
|
||||
The tape-fed preview draws two representations of the tape: `.tape-full` (in the template, wrapping `.label-preview`/the canvas) spans the tape's entire physical width (`tape.mediaWidthMm`), while the canvas inside it is only `printAreaPx` wide. A label printer's print head can't mark all the way to a tape's outer edges, so the true printable area is narrower than the tape itself, by an amount that isn't a fixed or predictable fraction of the tape width. `.tape-full` centers the canvas within itself (equal margin on both sides) and applies a faint background tint to that margin, so it reads as real, if unprintable, tape rather than empty page space. Because of this gap, the vertical ruler (`verticalRulerTicks`) is deliberately built from `tape.mediaWidthMm`, not from `printAreaPx`/`dpi` — ticking off only the printable width would run past the edge of the visible tape. The ruler's container is sized from `mediaWidthMm` too (see the template's inline `height`), so ticks can never run past what's actually drawn.
|
||||
|
||||
### Calculated Short Link Fields
|
||||
`SHORT_ID_VAR` ("shortId"), `DOMAIN_SHORT_ID_VAR` ("domainShortId") and `SHORT_URL_VAR` ("shortUrl") are Print.vue-local additions on top of label-layouts.js's own `DERIVED_VARS`. Resolving any of them needs the current `identityIdByHandle` map (populated by store.js's `fetchIdMap`) to turn a handle into the numeric `owner_identity_id` that short-id.js encodes — that dependency on Vuex state means none can be a pure fields-to-value calculation like the rest of `DERIVED_VARS`, so all three are computed here instead (see the `shortId` method and the `fields` computed). They're excluded from `baseVars`, unlike every other entry `KNOWN_VARS` normally picks up from a template's `required_vars`, because none is ever typed directly into the form - `SHORT_URL_VAR`/`DOMAIN_SHORT_ID_VAR` are filtered out again in the `baseVars`/`derivedVars` computed even though some template's `required_vars` puts them in `BASE_VARS`, since neither is actually a label-layouts.js `DERIVED_VARS` entry.
|
||||
|
||||
These target three scopes, by how much context a reader needs before the value is useful at all: `SHORT_ID_VAR` is the bare short-id.js token (e.g. "~AbCd12") with no domain or leading "/" - **Home-Instance** scope, only meaningful to a reader already logged in as the identity it was minted for (printed by "internal"-tagged templates); `DOMAIN_SHORT_ID_VAR` prepends the printing user's own home domain (`homeDomain + ":" + shortId`, e.g. "a.example.com:~AbCd12") with still no URL wrapper - **Any-Instance** scope, resolvable by any Toolshed frontend's own scanner/resolver via a cross-domain lookup (short-id.js's `isDomainQualifiedShortId`/`decodeDomainQualifiedShortId`, see Domain-Qualified Short ID Resolution below) regardless of who's logged in there; `SHORT_URL_VAR` wraps that same bare `shortId` in `webdomain` as a clickable URL. `webdomain` only picks which frontend loads - it says nothing about which backend the data lives on, since that's decided entirely by whoever ends up logged in once it does - so `SHORT_URL_VAR` shares `SHORT_ID_VAR`'s Home-Instance trust requirement (same identity, just a tap instead of typed/scanned into an already-open session), not a step up to a universally-openable link. The actual zero-context scope is served elsewhere, by the long-form Item/Location URL (`url`, see Content Kinds Registry above), which resolves by handle rather than by the opener's own idmap.
|
||||
|
||||
### Libweblabel Served Unbundled
|
||||
`libweblabel.js` is served verbatim from `public/vendor/` rather than bundled by Vite. Its own emscripten glue resolves its `.wasm` sibling relative to *its own* `import.meta.url` at runtime, so both files need to keep sitting together, unhashed, at a stable URL rather than a Vite-fingerprinted asset path.
|
||||
|
||||
### Concurrent Wasm Loading
|
||||
`preloadQrEncoder()` and `preloadPixelFontRenderer()` (see Freetype Production Rendering) are both kicked off in `mounted()` without an immediate `await`, so they load concurrently with each other and with `MultiPrinterBlob.load()` instead of serializing three independent wasm fetches. Every `redraw()`/`redrawFallback()` call still waits on both (via `fontsReady`, itself `Promise.all([qrReady, preloadPixelFontRenderer()])`) before drawing, since a qr/mqr/rmqr leaf throws (see label.js's `encodeQr`) until the encoder has finished loading, and a bitmap-tier text leaf throws the same way (see `drawPixelText`) until the font renderer has.
|
||||
|
||||
### Ruler Tier Selection
|
||||
`RULER_TIERS` controls how far apart plain ticks and labeled/major ticks sit, both getting coarser the longer the ruler runs — tightly spaced ticks (and their labels) get too cramped to read or render once there are enough of them. The list is ordered smallest threshold first; `rulerTier` picks the last entry whose `aboveMm` the ruler's own length clears, so a finer or coarser tier is added there rather than growing a pile of separate constants. Every tier's `majorEveryMm` is a multiple of its own `tickMm`, so major ticks always land on a tick that's actually drawn. `rulerTier` itself is a single shared value keyed off whichever axis (`horizontalTotalMm`/`verticalTotalMm`) is physically longer, so a long label's ruler never ends up coarser or finer than the tape-width ruler right next to it just because the other axis happens to be shorter — both rulers coarsen together once *either* axis needs it.
|
||||
|
||||
### Fields Computed: Dropping Blank Values
|
||||
The `fields` computed builds the named content fields templates draw from: the form's own base vars, plus every `DERIVED_VARS` format string calculated live from those, so typing a `userHandle`/`kind`/`id` (by hand or via prefill) recalculates `url`/`qualifiedHandle` the same way either way. A blank or uncalculated value is dropped entirely rather than passed through as an empty string, so it reads as *absent* to `templateIsAvailable`/`templateContent` the same way a prefill that never supplied it would — that's what LabelLayoutPreview.vue greys a template's thumbnail out on.
|
||||
|
||||
### ShortId Resolution
|
||||
The `shortId` method builds the same shortened link Inventory.vue's and StorageLocation.vue's own `shortIdLink` build for one of their rows, but returns the bare token (see short-id.js's `encodeShortId`) rather than a router target or full URL — `shortenedRoute`'s leading "/" is stripped since this is plain display/label content, not something this view itself navigates to. Which short-id.js kind applies depends directly on the Content card's `kind` field (via `CONTENT_KINDS_BY_ID`, see Content Kinds Registry above), not on which id-shaped field happens to be populated — so hand-typing a `userHandle`+`kind`+`id` with no prefill at all resolves exactly the same way a prefilled print link does. It falls back to no value, the same as an unresolved `DERIVED_VARS` entry, until `identityIdByHandle` has loaded (see `mounted`'s `fetchIdMap`) or if the handle isn't in it.
|
||||
|
||||
`DOMAIN_SHORT_ID_VAR` reattaches the `homeDomain` computed specifically — the printing user's own home domain (`state.user`'s domain half), not `derived.domain` (the printed thing's own owner handle domain, which for a group-owned print is the group's domain, not necessarily the printer's). This matters because `owner_identity_id`/`owner_group_id` are resolved via `identityIdByHandle`/`groupIdByHandle`, both sourced from `state.idmap`, which always loads from the printing user's own home server (`getHomeServers`) regardless of whose item is being printed — so the domain a Domain-Qualified Short ID needs to carry is always the printer's home domain, the one whose KnownIdentity/Group numbering the encoded ints are actually scoped to (see docs/handles-and-shortids.md#domain-qualified-short-id). Getting this wrong (e.g. using `derived.domain` for a group print) would silently mint a token that resolves to the wrong backend, or to nothing, once scanned by someone else.
|
||||
|
||||
### Domain-Qualified Short ID Resolution
|
||||
A Domain-Qualified Short ID (`<domain>:~<token>`) resolving to something other than "the app I'm currently in" needs a real network round-trip, since the token's ints are only meaningful to whichever backend minted them (see docs/handles-and-shortids.md#domain-qualified-short-id). `router.js`'s `domainQualifiedRoute(domain, ints)` is the single chokepoint every entry surface (the `/:short_id` route's `beforeEnter` guard, `ShortId.vue`'s `resolveIfQualified`, `Scan.vue`'s `describeForeignShortId`) funnels through: if `domain` matches the caller's own home domain (`store.state.user`'s domain, read via `isLoggedIn` first to force its lazy hydration — see `getHomeServers`'s own comment on the same ordering issue) it resolves locally via the existing idmap-based `expandedRoute`, exactly like a bare token; otherwise it dispatches `resolveShortId` (`store.js`), which SRV-discovers `domain`'s backend (`getFriendServers`) and calls its new `GET /api/v1/resolve_short_id/<kind>/<owner_id>/<local_id>/` (`backend/toolshed/api/inventory.py`). That endpoint answers "what does this owner/local id pair mean" against its own KnownIdentity/Group numbering, applying the exact same friend-or-self/membership and `availability_policy` checks `InventoryItemViewSet.get_queryset()` already does (see Owner-Handle Scoped Routes below) — it only differs in being keyed by numeric owner id instead of a handle string, since the caller doesn't have a handle yet, that's exactly what it returns (`{handle, id}`). Only the four owned short-id kinds (`item`/`group_item`/`storage_location`/`group_storage_location`) are wired up; `group`/`category`/`file`/`workflow` return 400 and the frontend falls through to `ShortId.vue`'s debug view.
|
||||
|
||||
### Fit-Zoom Preview Scaling
|
||||
`fitZoom` fits the preview to its card without ever needing a horizontal scrollbar for a label this small: it magnifies short labels up to `MAX_ZOOM` rather than showing them at native (tiny) size, and never lets the preview grow past `MAX_PREVIEW_HEIGHT_PX` tall however long or wide the label itself runs. It returns the zoom actually used, so callers that care (`redraw`'s ruler bookkeeping) don't have to re-derive it. When magnifying, the zoom is rounded *down* to a whole number rather than rounded or ceiled: `image-rendering: pixelated` only looks crisp when every source pixel maps to the same number of screen pixels, and at a fractional zoom (the common case, since the raw zoom is just whatever ratio the tape/card happen to produce) some source pixels get rounded up to one extra screen pixel and others don't, unevenly warping fine, already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph. Flooring keeps the same "never bigger than available space" guarantee the raw zoom already had. Shrinking (zoom < 1) has no equivalent whole-factor snap to make, since downsampling always blends source pixels, so it's left as-is.
|
||||
|
||||
### Mm Ruler Layout
|
||||
The tape-fed preview's mm ruler is a horizontal track above the canvas and a vertical one to its left, both ticked in real physical millimeters rather than preview pixels, since what they're measuring is the actual label.
|
||||
|
||||
### Preview Track No-Scroll Invariant
|
||||
`.preview-track` holds the horizontal ruler and the canvas, and is deliberately never scrollable (no `overflow-x: auto`): `fitZoom`'s zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never actually be wider than this has room for, and a scrollbar here would let the ruler and canvas drift apart (or just look broken) for no reason. `min-width: 0` only lets this flex item shrink to the card's real available width — it doesn't enable scrolling.
|
||||
|
||||
### Label-Preview Override In Preview Track
|
||||
`.preview-track .label-preview` overrides the standalone `.label-preview` rule: nested here, it must neither scroll nor center its canvas. `overflow-x: visible` (never `auto`) rules out a second, inner scrollbar, and `text-align: left` keeps the canvas flush with the ruler's zero tick instead of drifting to the middle of whatever spare width the card has. `padding: 0` makes the canvas's own edges exactly this box's edges too, so the ruler's ticks line up with those same edges — any padding here would leave the ticks and the actual canvas misaligned.
|
||||
|
||||
## Routing, Handles & Short IDs
|
||||
|
||||
### Escaping Hash in Handles for URL Path Segments
|
||||
`router.js`'s `encodeHandleForUrl`/`decodeHandleFromUrl` embed/extract a handle in a URL path segment. `#` starts a URI's fragment component, so a group handle (`#name@domain`) or classification handle (`origin#type:name`) can't appear unescaped in a path segment. `+` stands in for `#` there instead of the usual `%23` - safe to reverse unambiguously because every field a handle is built from is already required to exclude `+` (see docs/federation.md's "Reserved characters" and its "Embedding a `#`-bearing handle in a URL" section). The canonical handle itself never changes; this only affects how one gets embedded in, or read back out of, a URL path segment.
|
||||
|
||||
### Owner-Filtered Overview Routes Use Path Segments, Not Query Strings
|
||||
`/inventory/:owner?` and `/storage-location/:owner?` (`Inventory.vue`/`StorageLocation.vue`'s `owner` prop) filter the overview to one owner - the caller, a group, or a friend - using the same handle shape/escaping as `/groups/:handle` (see Escaping Hash in Handles above). `/inventory/new/:group?` and `/storage-locations/new/:group?` (`InventoryNew.vue`/`StorageLocationNew.vue`'s `group` prop) prefill the owner picker the same way when arriving from a filtered overview. All four are optional trailing path params (`:name?`), the same style as `/workflows/:id/:phase?` - deliberately not a query string: vue-router's query parser treats a bare `+` as an encoded space (the x-www-form-urlencoded convention), so the `#`->`+` swap that works fine in a path segment would silently corrupt a group handle passed as `?owner=...` instead.
|
||||
|
||||
These coexist safely with their more specific neighbors (`/inventory/new/:group?` next to `/inventory/:owner?` and `/inventory/:handle/:id`; `/storage-location/:owner?` next to the plural `/storage-locations/...` routes) because vue-router's matcher always ranks a route's more literal/static segments above a dynamic one at the same depth, regardless of declaration order - e.g. `/inventory/new` resolves to the static `new` segment, never `:owner` on `/inventory/:owner?`.
|
||||
|
||||
`ownerOverviewRoute`/`ownerLocationOverviewRoute` (`router.js`) only ever pick between the caller and their own group, never a friend, since a friend's items/locations are read-only (see Perform Update Rejects Non-Owned Items Explicitly below).
|
||||
|
||||
### Item Short-Link Redirect Route
|
||||
`/i/:handle/:id` and `/s/:handle/:id` are the self-contained label/short-link entry points for an item and a storage location respectively (see `label.js`'s `buildLabelContent`/`CONTENT_KINDS_BY_ID`, whose `buildUrl` for each `CONTENT_KINDS` entry builds exactly this shape, and docs/design-in-progress/items-labels.md). `:handle` is already URL-escaped the same way `/inventory/:handle/:id`/`/storage-locations/:handle/:id` expect it, so each is just a shorter alias for its own detail route, with no owner-is-the-viewer special case: `InventoryItemViewSet.get_queryset()`/`StorageLocationViewSet.get_queryset()` (see Owner-Handle Scoped Routes below) already treat "it's the viewer's own item/location" as one case of "the viewer may see this owner's item/location," not a separate path.
|
||||
|
||||
### `beforeEnter` Guard vs `redirect` for `/:short_id`
|
||||
This route uses a `beforeEnter` guard, not `redirect`: `redirect` is called synchronously and its return value is used as-is (never awaited), and it also *must* resolve to a valid location on every match (an unresolvable one throws, see vue-router's `handleRedirectRecord`) - it can't itself wait on `fetchIdMap` (see `NEEDS_IDMAP`) for the `item`/`group_item` kinds whose owner handle isn't resolvable from the token alone. A guard can return `null`/undefined to mean "proceed to the component instead," which is exactly what's needed here: when `expandedRoute` can't resolve yet (or ever - an unrecognized kind), stay on this same URL and mount `ShortId.vue` in place, which has full component-lifecycle async support and takes it from there - fetch idmap, retry, redirect once resolved, or keep showing the decode view. Vue Router guards support returning a Promise, not just a value, which is what makes this the right chokepoint for a Domain-Qualified Short ID too (see Domain-Qualified Short ID Resolution above): the guard is `async`, and for a foreign domain it `await`s `domainQualifiedRoute` (a real network round-trip) before deciding whether to redirect or fall through to `ShortId.vue`, the exact same "resolve or fall through" shape as the synchronous idmap case.
|
||||
|
||||
## Federation, Identity, Friends & Groups
|
||||
|
||||
### Group Invite And Accept Self-Certifying Verification
|
||||
`verify_incoming_group_invite(request, raw_request_body, handle_field, key_field)` is a self-certifying verifier for the two legs of the group invite/accept dance that land on a backend which doesn't have the caller cached as a `KnownIdentity` yet (see `docs/design-in-progress/groups-mvp.md`): the inviter delivering an invite to the invitee's own backend (`handle_field='inviter'`, `key_field='inviter_key'`), and the invitee accepting on the group's home backend (`handle_field='invitee'`, `key_field='invitee_key'`). It mirrors `verify_incoming_friend_request` exactly, just with configurable field names so the same logic serves both legs.
|
||||
|
||||
### Group Membership Is Recorded On Both Sides, Like Friendship
|
||||
Accepting a group invite used to only ever write membership on the group's own home backend (`Group.members`, via `acceptGroupInvite`); the invitee's own home backend just deleted its now-obsolete `GroupInviteIncoming` and forgot the invite ever happened. That meant a member's own backend had no way to answer "which groups is this identity in" for a group hosted elsewhere. `recordGroupMembership` (`POST /api/v1/groupinvites/<pk>/accept/`, local-auth) closes that gap: it's the second leg of the accept dance, called by the client right after the group-home-backend accept succeeds, and it converts the `GroupInviteIncoming` into a durable `GroupMembership` pointer (just `user`, `group_name`, `group_domain` — no roster, since this backend isn't authoritative for the group) instead of discarding it. `GET /api/v1/groupmemberships/` (also local-auth) lists a user's own pointers; `Groups.vue`'s "Other groups you're a member of" section is this list, filtered against the locally-hosted `groups` to avoid double-listing. This is deliberately the same shape as the friend-accept flow's second POST (see below): each side's own home backend independently records the relationship in its own local table, so neither side depends on the other's backend being reachable later to know it happened.
|
||||
|
||||
### Owner-Handle Scoped Routes
|
||||
`InventoryItemViewSet`/`StorageLocationViewSet` (`toolshed/api/inventory.py`) address every route — list/create/retrieve/update/destroy — by an owner handle URL parameter, resolved by `resolve_owner_handle(handle)` into `(owner_user, owner_group)`. There's deliberately no bare, handle-less route: `id` is only unique within its own owner/owner_group scope, so a route spanning more than one such scope could match two different objects sharing a local id, raising `MultipleObjectsReturned`.
|
||||
|
||||
Detail routes (retrieve/update/destroy) still address the object by its owner-scoped `id`, not the internal row id. The router names the URL capture group `pk`, so `lookup_url_kwarg` stays `'pk'` — only `lookup_field` changes, to `'id'` — this is not a mismatch to "fix"; renaming `lookup_url_kwarg` to match would break the URL conf, which still names the capture group `pk`.
|
||||
|
||||
### Perform Update Rejects Non-Owned Items Explicitly
|
||||
Since `get_queryset()` (see Owner-Handle Scoped Routes above) can now return a friend's items/locations (read-only) on the same route, an object found there isn't necessarily writable. `perform_update` on both `InventoryItemViewSet` and `StorageLocationViewSet` checks `_is_authorized` and raises `PermissionDenied` explicitly, rather than silently no-op, which returning without saving would otherwise do.
|
||||
|
||||
### Friend Request and Accept Protocol Flow
|
||||
The friend request/accept flow spans two backends (A hosts `x@A`, B hosts `y@B`):
|
||||
1. `x@A` sends a friend request to `y@B`:
|
||||
1. `x@A`'s client POSTs to `A/api/v1/friendrequests/` with `{from: x@A, to: y@B}`.
|
||||
2. A's backend creates a `FriendRequestOutgoing`, containing `x@A`'s identity and `y@B`'s name.
|
||||
3. `x@A`'s client POSTs to `B/api/v1/friendrequests/` with `{from: x@A, to: y@B, public_key: x@A's public key}`.
|
||||
4. B's backend creates a `FriendRequestIncoming`, containing `y@B`'s and `x@A`'s identities.
|
||||
2. `y@B` accepts the friend request:
|
||||
1. `y@B`'s client POSTs to `A/api/v1/friendrequests/` with `{from: x@A, to: y@B, public_key: y@B's public key}`.
|
||||
2. A's backend matches the data to the `FriendRequestOutgoing` object, deletes both, and creates a `Friend` object containing `x@A`'s and `y@B`'s identities.
|
||||
3. `y@B`'s client POSTs to `B/api/v1/friends/` containing the id of the `FriendRequestIncoming` object.
|
||||
4. B's backend creates its own `Friend` object, using the identities from the `FriendRequestIncoming` object.
|
||||
|
||||
### Fetch Item By Handle: Group vs Personal Handles
|
||||
`fetchItemByHandle` in `store.js` branches on whether the handle is a group handle (leading `#`) or a personal/friend handle. A personal/friend handle goes through `fetchForeignItem`, which GETs the item directly by owner handle (see Owner-Handle Scoped Routes above). A group handle has no equivalent single-item GET route, so it resolves via `fetchGroup` (domain-routed by the handle, see `GroupDetail` in `toolshed/api/group.py`, addressed by handle directly) followed by the already-correct, already-authenticated group listing (`fetchGroupInventoryItems`), picking the matching item out of that list — which only ever contains this one group's own items, so an id collision with anything else can't happen.
|
||||
|
||||
## Workflows Architecture
|
||||
|
||||
### Workflow Catalog
|
||||
`frontend/src/workflows.js` is the single source of truth for every workflow type known to the frontend: what it's called, what category/description/icons it has, how many steps it has and what they're called, what its initial payload looks like, and which Vue component renders it.
|
||||
|
||||
Each workflow has a fully co-located component + metadata as a static `meta` option on the component (`Component.meta`, right next to `name`/`props`/etc.) in `@/components/workflow/workflows/*.vue` - `workflows.js` simply imports those components and reads `.meta` off of them to build the catalog.
|
||||
|
||||
This replaces a previous design of a parallel `BaseWorkflow` class hierarchy (metadata) plus a separate per-step `ComponentRegistry.js` (components) - both concerns now live in one flat array with each component responsible for its own metadata and UI implementation.
|
||||
|
||||
### Workflow Payload Is An Opaque String
|
||||
The backend stores `WorkflowInstance.payload` as an opaque string - it never parses or understands it as JSON. The frontend is fully responsible for serializing it before sending and deserializing it after receiving.
|
||||
|
||||
### Workflow `meta` Co-location
|
||||
Workflow metadata (title, category, description, icons, step definitions, initial payload) is co-located with its implementation as a static `meta` property on the workflow's Vue component, so there is a single source of truth per workflow type. It's consumed by `@/workflows.js` (via `Component.meta`) to assemble the catalog used by the `Workflows` and `WorkflowDetail` views.
|
||||
|
||||
### Bulk Item Import Workflow
|
||||
`BulkItemImportWorkflow.vue` implements every step of the `import-items` workflow in a single component. Steps 1 (File Upload) and 6 (Import Items) have fully custom UI; the remaining steps (2-5, 7) currently fall back to a generic "in progress" placeholder driven by this workflow's own step metadata, and can be fleshed out later without touching any other file.
|
||||
|
||||
### Staged Photos Are The Durable State
|
||||
In `FotoFirstBulkImportWorkflow.vue`, `photos`' durable state is the `WorkflowInstance.staged_files` relation itself, kept in sync directly by `stageFile()`/`unstageFile()` rather than by writing to `payload`. On load, `photos` is seeded from the `workflowInstance` prop, not from `payload`. Entries restored this way have no local bytes yet (this session never uploaded them), so `dataUrl` stays null and the gallery falls back to fetching a thumbnail by hash via `AuthenticatedImage`. Symmetrically, when a photo is newly staged, it's persisted server-side right away (keyed to the workflow instance) so it survives a reload or a switch to another device - nothing about photos needs to go into `payload` too.
|
||||
|
||||
### Thumbnail Lookup By Hash
|
||||
`thumbnailPathForHash()` in `FotoFirstBulkImportWorkflow.vue` builds a derived storage path mirroring `hash_upload()` (`files/models.py`), matching how `FileSerializer.name` already builds file URLs elsewhere in the app (e.g. `AuthenticatedImage`'s `src` for item files). `files/media_urls.py`'s `thumbnail_urls` generates and disk-caches a resized JPEG at that path on first request, since a gallery card only needs a small image, not the full-size original.
|
||||
|
||||
Once a photo finishes uploading, the gallery switches it to the server-fetched thumbnail by re-looking it up by hash rather than mutating the closed-over `photo` object from the upload closure - that reference predates the `this.photos.push()` call, so it's the raw object, not the reactive proxy Vue tracks, and writing to it wouldn't trigger a re-render.
|
||||
|
||||
### Foto First Bulk Import Workflow
|
||||
`FotoFirstBulkImportWorkflow2.vue` implements every step of the `foto-first-bulk-import` workflow (photo capture, image processing, item detail entry, import completion) in a single component. Keeping the whole workflow in one file avoids splitting closely related state (photos, processed images, completed items) across many small step components and their prop/emit boundaries.
|
||||
|
||||
### Staged Files Are Identified By Hash Alone
|
||||
A staged file's SHA-256 hash is enough to identify it, since the client computes content hashes the same way the backend does and can fetch bytes from a hash-derived storage path. This underpins several places in the workflow file-staging API:
|
||||
|
||||
- `WorkflowInstanceSerializer.staged_files` (`backend/toolshed/serializers.py`) exposes only hashes, not a fuller `FileSerializer` representation like `InventoryItemSerializer.files` uses - for a file staged by *this* session there's nothing more to say, and for one staged elsewhere (another device/tab), the hash is what lets this session recognize and fetch it.
|
||||
- `get_staged_files()` (`backend/toolshed/api/files.py`) returns the same bare hash list, useful mainly for discovering what another session/device already staged on a workflow.
|
||||
- `post_item_file()`'s `file_hash` branch (`backend/toolshed/api/files.py`) lets a caller attach a file already staged on one of their own workflows by content hash, instead of re-uploading bytes already stored server-side. Workflows are always personally owned, so this only applies to a caller with a local account.
|
||||
|
||||
## Offline Data Export & Import
|
||||
|
||||
### File Naming Convention In Exports
|
||||
Files attached to a user's inventory items are yielded by `inventory_files()` as `(arcname, data)` pairs, deduplicated by content hash and placed under a `files/` subfolder in the export zip, e.g. `files/<hash><ext>`. The extension is guessed from each `File`'s `mime_type` so attachments and images remain viewable once extracted, rather than sitting as extension-less blobs. This same `files/<hash><ext>` convention is used for the profile picture and is what `available_files` (passed into `import_profile()` and `import_inventory()`) is keyed by.
|
||||
|
||||
### Profile Import Semantics
|
||||
`import_profile()` is a fault-tolerant importer for `profile.json`. Only `first_name`, `last_name`, `email`, and `profile_picture` are applied to the account; `profile_picture` is only set when its value is a `files/...` path present in `available_files` (see File Naming Convention above). `username` and `domain` are ignored even if present in the payload, since they identify the account itself and cannot be changed by an import.
|
||||
|
||||
### Account & Data Deletion
|
||||
`delete_user_data()` permanently deletes everything that the data export covers, while keeping the account itself intact: inventory items (hard delete, bypassing soft-delete), storage locations, account preferences, the friends relation on the user's public identity, the profile picture, and any `File` blobs that become orphaned as a result. A `File` is only deleted by `_delete_orphaned_files()` once nothing else references it - no `InventoryItem`, no `ToolshedUser` (as a profile picture), and no `WorkflowInstance` (as a staged file) - since files are deduplicated by content hash and may be shared with other items or users. The `ToolshedUser` account (and its underlying `KnownIdentity`) is deliberately *not* deleted by `delete_user_data()`; see `delete_account()` in `toolshed/api/offlinedata.py` for that.
|
||||
|
||||
`delete_user_account()` builds on this: it calls `delete_user_data()` first and then deletes the `ToolshedUser` row itself, closing the local account. The `KnownIdentity` is still kept so that remote friends/history relating to this identity remain intact for other users - only the local account is closed. Both functions return a summary dict describing what was removed; `delete_user_account()`'s summary additionally sets `account: True`.
|
||||
|
||||
### Location Import Ordering And Savepoints
|
||||
`import_locations()` fault-tolerantly imports `locations.csv` into `StorageLocation` rows. Rows are read by header label; a row missing the required `name` column is skipped, and optional columns (`description`, `category`) are simply omitted if absent. Rows are processed in path-depth order (shallowest first) so that a child location's parent has already been created by the time it's needed. Each row runs inside its own transaction savepoint (`transaction.atomic()`), 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 - it only skips that one row.
|
||||
|
||||
### Handle Resolution Semantics
|
||||
A fully qualified handle (e.g. `git:base#tag:drill`) identifies one *specific* entity from one *specific* origin. `_resolve_handle()` therefore only ever resolves such a handle to an *existing* model instance and never creates one: silently creating a new local entity named after the raw handle string would be incorrect, since that's not the entity the handle actually points to. It raises `_HandleNotFound` (defined for exactly this purpose) when the handle's entity type doesn't match the expected one, or when no such object exists locally - which lets the caller (`import_inventory()`) skip just that row and report a helpful error instead of guessing.
|
||||
|
||||
### Properties CSV Encoding
|
||||
An inventory item's properties are encoded into the `properties` CSV cell as a comma-separated `handle=value` list by `_encode_properties_cell()`, and decoded back into `(Property, value)` tuples by `_parse_properties_cell()`. Because both the list separator (`,`) and the key/value separator (`=`) could otherwise appear inside a value, `_quote_value_if_needed()` wraps any value containing a comma or a quote character in CSV-style double quotes (doubling embedded quotes), and `_split_quoted_comma_list()` is the matching reader-side routine: it splits on commas while honouring quoted substrings so a quoted value's own commas aren't mistaken for separators, unescapes doubled quotes (`""`) back to a single literal quote, and strips only the single space that follows each `", "` separator (as written by the encoder) rather than doing a blanket `.strip()` - so genuine leading/trailing whitespace inside a quoted value survives the round trip.
|
||||
|
||||
### Inventory Import Semantics
|
||||
`import_inventory()` fault-tolerantly imports `inventory.csv` into `InventoryItem` rows owned by the target user. The `files` column's paths are looked up in `available_files` (see File Naming Convention above); a reference to a file that wasn't successfully extracted from the zip is simply ignored rather than failing the row. If a row references a fully qualified tag/property/category handle that doesn't resolve locally (see Handle Resolution Semantics above), the whole item is skipped - rather than creating a bogus local entity - and a message is appended to the returned `errors` list. As with `import_locations()`, each row runs inside its own transaction savepoint so one row's DB-level failure can't break the rest of the import.
|
||||
|
||||
## Camera Scanning & Inventory UI
|
||||
|
||||
### Video Stream Attach And Resize Sync
|
||||
`setupVideoStream` in `Scan.vue` attaches a `MediaStream` to the `<video>` element and is shared by `startCamera` and the camera-switch/reconnect paths, so the `CameraScanner` (which just keeps reading frames off the same `<video>` element) never needs to be recreated when the camera changes. Right after `video.play()`, `video.videoWidth`/`videoHeight` are not populated yet - the browser only reports them once the video's own `resize` (and, on first load, `loadedmetadata`) event fires, confirming the intrinsic dimensions actually took effect. `onVideoResize` listens for both events and is the one place that resizes the overlay `<canvas>` to match the video's current rendered size, replacing whatever stale size it had from before a switch to a camera with a different native resolution/aspect ratio.
|
||||
|
||||
### Print Link Shape
|
||||
`printLinkFor` in `Inventory.vue` and `StorageLocation.vue` (and the print buttons in `InventoryDetail.vue`/`StorageLocationDetail.vue`) all route to `Print.vue` with the same `{kind, userHandle, id}` query shape - the thing's raw identity - rather than any pre-built link. `kind` is one of `CONTENT_KINDS`' ids (`"item"`/`"storage-location"`, see Content Kinds Registry above); `label.js`'s `buildLabelFields` reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - `short-id.js`'s `group_item` kind resolves them via `owner_group` instead (see `shortIdLink`) - so there's no `{userHandle, id}` to build yet; `Inventory.vue`'s `printLinkFor` returns `null` for them until group print support exists. Storage locations are always individually owned (see `StorageLocationViewSet.get_queryset`), so `StorageLocation.vue`'s version never has that fallback to make.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
|
||||
|
||||
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
|
||||
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
|
||||
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
|
||||
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
|
||||
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
|
||||
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
|
||||
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.
|
||||
|
|
@ -33,7 +33,7 @@ export default {
|
|||
},
|
||||
fallbackSrc: {
|
||||
type: String,
|
||||
default: '/assets/img/avatars/avatar.png'
|
||||
default: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEyIiBmaWxsPSIjY2VkNGRhIi8+CjxjaXJjbGUgY3g9IjEyIiBjeT0iOS41IiByPSI0IiBmaWxsPSIjZmZmZmZmIi8+CjxwYXRoIGQ9Ik00IDIwLjJDNCAxNi4yIDcuNiAxMy41IDEyIDEzLjVzOCAyLjcgOCA2Ljd2MC4zSDR2LTAuM3oiIGZpbGw9IiNmZmZmZmYiLz4KPC9zdmc+Cg=='
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
|
|
|
|||
|
|
@ -4,17 +4,14 @@
|
|||
<h5 class="card-title mb-0">Label layout</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3 btn-group" role="group">
|
||||
<input type="radio" class="btn-check" id="layout-filter-all"
|
||||
autocomplete="off" value="all" v-model="activeTag">
|
||||
<label class="btn btn-outline-secondary" for="layout-filter-all">All</label>
|
||||
<template v-for="tag in labelTags" :key="tag">
|
||||
<input type="radio" class="btn-check" :id="'layout-filter-' + tag"
|
||||
autocomplete="off" :value="tag" v-model="activeTag">
|
||||
<label class="btn btn-outline-secondary" :for="'layout-filter-' + tag">
|
||||
{{ tagLabel(tag) }}
|
||||
<div class="mb-3 btn-group btn-group-toggle" role="group">
|
||||
<label class="btn btn-outline-secondary" :class="{active: activeTag === 'all'}">
|
||||
<input type="radio" autocomplete="off" value="all" v-model="activeTag"> All
|
||||
</label>
|
||||
<label v-for="tag in labelTags" :key="tag" class="btn btn-outline-secondary"
|
||||
:class="{active: activeTag === tag}">
|
||||
<input type="radio" autocomplete="off" :value="tag" v-model="activeTag"> {{ tagLabel(tag) }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="template-grid d-flex flex-wrap align-items-start">
|
||||
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
|
||||
|
|
@ -39,16 +36,7 @@
|
|||
<span v-for="tag in t.tags" :key="tag" class="badge bg-secondary">{{ tagLabel(tag) }}</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ t.description }}</div>
|
||||
<!-- Debugging aid: every QR-family/text leaf's raw size numbers, shown regardless
|
||||
of whether either tripped a warning above - see label.js's qrInfo/textInfo. -->
|
||||
<div v-if="t.id in qrInfo || t.id in textInfo" class="small text-secondary">
|
||||
<div v-for="(info, i) in qrInfo[t.id]" :key="'qr' + i">
|
||||
mm-per-mod: {{ info.moduleMm.toFixed(2) }}, px-per-mod: {{ info.scale }}
|
||||
</div>
|
||||
<div v-for="(info, i) in textInfo[t.id]" :key="'text' + i">
|
||||
text-mm: {{ info.fontMm.toFixed(2) }}, text-px: {{ info.fontPx.toFixed(1) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -91,7 +79,7 @@
|
|||
}
|
||||
|
||||
/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
|
||||
code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent
|
||||
code than the tape allows"/"doesn't fit on this tape") or label.js's templateContent
|
||||
throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
|
||||
Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
|
||||
reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
|
||||
|
|
@ -125,8 +113,15 @@
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import {drawLabel, drawFallbackLabel, preloadQrEncoder, preloadPixelFontRenderer} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, LABEL_TAGS, templateIsAvailable, templateContent} from "@/label-layouts.js";
|
||||
import {
|
||||
drawLabel,
|
||||
drawFallbackLabel,
|
||||
preloadQrEncoder,
|
||||
preloadPixelFontRenderer,
|
||||
templateIsAvailable,
|
||||
templateContent
|
||||
} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, LABEL_TAGS} from "@/label-layouts.js";
|
||||
|
||||
export default {
|
||||
name: "LabelLayoutPreview",
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default {
|
|||
computed: {
|
||||
...mapGetters(['allUserProfiles']),
|
||||
displayUsername() {
|
||||
return this.user.username || this.user.owner || '';
|
||||
return this.user.handle || this.user.owner || '';
|
||||
},
|
||||
profilePictureUrl() {
|
||||
// Check for explicitly set profile picture from allUserProfiles getter
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
@ -579,7 +584,6 @@ export default {
|
|||
this.importError = null;
|
||||
const created_item_ids = [];
|
||||
const errors = [];
|
||||
// Each row is created via the same generic POST /api/inventory_items/ endpoint any client would use.
|
||||
for (const [index, row] of this.items.entries()) {
|
||||
if (!row.name) {
|
||||
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="d-flex align-items-center">
|
||||
<div v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<template v-else-if="item">
|
||||
<authenticated-image v-if="thumbnailFile" :src="thumbnailPathForHash(thumbnailFile.hash)"
|
||||
:owner="thumbnailFile.owner" img-class="completed-item-thumb me-2"/>
|
||||
<b-icon-image v-else class="me-2"></b-icon-image>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold small">{{ item.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-muted small">
|
||||
<b-icon-exclamation-triangle class="me-1"></b-icon-exclamation-triangle>
|
||||
Item #{{ id }} (unavailable)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstCompletedItemCard',
|
||||
components: {AuthenticatedImage, ...BIcons},
|
||||
props: {
|
||||
id: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
owner: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
ownerGroup: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
item: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
thumbnailFile() {
|
||||
return this.item?.files?.find(file => file.mime_type?.startsWith('image/')) || null;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// Always live: this card exists specifically so the summary reflects the item's current
|
||||
// name/photo, not whatever it looked like at the moment this workflow created it.
|
||||
try {
|
||||
this.item = await this.fetchItemByHandle({handle: this.ownerGroup || this.owner, id: this.id});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchItemByHandle']),
|
||||
// Requests a small server-resized/cached thumbnail instead of the original upload - this
|
||||
// renders in a fixed 40px box (see .completed-item-thumb), and the source photos here can
|
||||
// be full camera resolution, so fetching the original would be a lot of wasted bandwidth
|
||||
// and decode work for a summary list. Mirrors FotoFirstBulkImportWorkflow.vue's own
|
||||
// thumbnailPathForHash(); see docs/implementation.md#thumbnail-lookup-by-hash.
|
||||
thumbnailPathForHash(hash, size = 64) {
|
||||
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<textarea v-if="type === 'textarea'" class="form-control" rows="2" v-model="localValue"></textarea>
|
||||
|
||||
<input v-else-if="type === 'number'" type="number" class="form-control" min="0" v-model.number="localValue">
|
||||
|
||||
<!-- Tags/properties mutate the passed-in array in place (same convention as
|
||||
InventoryNew.vue's own `:value="item.tags"`), so no v-model wiring is needed here. -->
|
||||
<tag-field v-else-if="type === 'tags'" :value="modelValue"></tag-field>
|
||||
|
||||
<property-field v-else-if="type === 'properties'" :value="modelValue"></property-field>
|
||||
|
||||
<select v-else-if="type === 'owner_group'" class="form-select" v-model="localValue">
|
||||
<option :value="null">Myself</option>
|
||||
<option v-for="group in ownerGroups" :key="group.handle" :value="group.handle">{{ group.handle }}</option>
|
||||
</select>
|
||||
|
||||
<select v-else-if="type === 'availability_policy'" class="form-select" v-model="localValue">
|
||||
<option v-for="policy in availabilityPolicies" :key="policy.slug" :value="policy.slug">
|
||||
{{ policy.text }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-else-if="type === 'storage_location'" class="form-select" v-model="localValue">
|
||||
<option :value="null">No storage location</option>
|
||||
<option v-for="location in storageLocations" :key="location.id" :value="location.id">
|
||||
{{ location.path }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input v-else type="text" class="form-control" v-model="localValue">
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TagField from "@/components/TagField.vue";
|
||||
import PropertyField from "@/components/PropertyField.vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstFieldInput',
|
||||
components: {TagField, PropertyField},
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
modelValue: {
|
||||
default: null
|
||||
},
|
||||
ownerGroups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availabilityPolicies: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
storageLocations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
localValue: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
// Escapes `#` as `+` for use in a URL path segment. See docs/implementation.md#escaping-hash-in-handles-for-url-path-segments.
|
||||
|
||||
|
||||
|
|
@ -4,160 +4,108 @@ import {encodeHandleForUrl} from "@/router";
|
|||
// docs/implementation.md#template-layout-tree.
|
||||
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
||||
|
||||
// Generated matrix of "just the code" templates covering every QR_LEAF_TYPES combo. See
|
||||
// docs/implementation.md#generated-qr-only-template-matrix.
|
||||
const QR_ONLY_TEMPLATES = [
|
||||
// Single registry for everything that varies per content kind. See docs/implementation.md#content-kinds-registry.
|
||||
export const CONTENT_KINDS = [
|
||||
{
|
||||
id: "qr-l", name: "QR code only (low error correction)",
|
||||
description: "Just the code, at QR's lowest error-correction level - fits more data (or a "
|
||||
+ "smaller code) for the same text, but less tolerant of damage.",
|
||||
},
|
||||
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest."},
|
||||
{
|
||||
id: "qr-q", name: "QR code only (quartile error correction)",
|
||||
description: "Just the code, at QR's second-highest (quartile) error-correction level - a "
|
||||
+ "middle ground between code size and damage tolerance.",
|
||||
id: "item", label: "Item", typedPrefix: "i",
|
||||
shortIdKind: "item", groupShortIdKind: "group_item", localIdField: "item_local_id",
|
||||
buildUrl: ({origin, userHandle, id}) => `${origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
|
||||
},
|
||||
{
|
||||
id: "qr-h", name: "QR code only (high error correction)",
|
||||
description: "Just the code, at QR's highest error-correction level - still scans if scuffed "
|
||||
+ "or partly obscured, at the cost of a bigger code for the same text.",
|
||||
id: "storage-location", label: "Storage location", typedPrefix: "s",
|
||||
shortIdKind: "storage_location", groupShortIdKind: "group_storage_location",
|
||||
localIdField: "storage_location_id",
|
||||
buildUrl: ({origin, userHandle, id}) => `${origin}/s/${encodeHandleForUrl(userHandle)}/${id}`,
|
||||
},
|
||||
{
|
||||
id: "mqr-d", name: "Micro QR code only (detection only)",
|
||||
description: "Just the code, in the more compact Micro QR format at its lowest, M1-only level - "
|
||||
+ "the smallest QR-family code there is, but can only tell a scan is corrupted, not "
|
||||
+ "recover from it.",
|
||||
},
|
||||
{
|
||||
id: "mqr-l", name: "Micro QR code only (low error correction)",
|
||||
description: "Just the code, in the more compact Micro QR format at its low error-correction level.",
|
||||
},
|
||||
{
|
||||
id: "mqr", name: "Micro QR code only",
|
||||
description: "Just the code, in the more compact Micro QR format - smallest label, prints fastest.",
|
||||
},
|
||||
{
|
||||
id: "mqr-q", name: "Micro QR code only (quartile error correction)",
|
||||
description: "Just the code, in the more compact Micro QR format at its highest (quartile) "
|
||||
+ "error-correction level.",
|
||||
},
|
||||
{
|
||||
id: "rmqr", name: "rMQR code only",
|
||||
description: "Just the code, in the rectangular rMQR format, sized for the smallest total area "
|
||||
+ "that fits the text - smallest label, prints fastest.",
|
||||
},
|
||||
{
|
||||
id: "rmqr-min", name: "rMQR code only (shortest, widest)",
|
||||
description: "Just the code, in the rectangular rMQR format, preferring the flattest/widest "
|
||||
+ "symbol that fits the text - shortest across the tape, longest along it.",
|
||||
},
|
||||
{
|
||||
id: "rmqr-max", name: "rMQR code only (tallest, narrowest)",
|
||||
description: "Just the code, in the rectangular rMQR format, preferring the tallest/narrowest "
|
||||
+ "symbol that fits the text - tallest across the tape, shortest along it.",
|
||||
},
|
||||
{
|
||||
id: "rmqr-h", name: "rMQR code only (high error correction)",
|
||||
description: "Just the code, in the rectangular rMQR format at its high error-correction level, "
|
||||
+ "sized for the smallest total area that fits the text - still scans if scuffed or partly "
|
||||
+ "obscured, at the cost of a bigger code for the same text.",
|
||||
},
|
||||
{
|
||||
id: "rmqr-h-min", name: "rMQR code only (high error correction, shortest/widest)",
|
||||
description: "Just the code, in the rectangular rMQR format at its high error-correction level, "
|
||||
+ "preferring the flattest/widest symbol that fits the text.",
|
||||
},
|
||||
{
|
||||
id: "rmqr-h-max", name: "rMQR code only (high error correction, tallest/narrowest)",
|
||||
description: "Just the code, in the rectangular rMQR format at its high error-correction level, "
|
||||
+ "preferring the tallest/narrowest symbol that fits the text.",
|
||||
},
|
||||
].map(t => ({...t, required_vars: ["text"], layout: [{type: t.id, content: c => c.text}]}));
|
||||
];
|
||||
|
||||
export const CONTENT_KINDS_BY_ID = Object.fromEntries(CONTENT_KINDS.map(k => [k.id, k]));
|
||||
|
||||
|
||||
// QR-types
|
||||
// qr-l, qr, qr-q, qr-h, mqr-d, mqr-l, mqr, mqr-q, rmqr, rmqr-min, rmqr-max, rmqr-h, rmqr-h-min, rmqr-h-max
|
||||
|
||||
export const LABEL_TEMPLATES = [
|
||||
{
|
||||
id: "mqr-token", name: "MQR Token", description: "The code with the encoded text printed next to it.",
|
||||
id: "mqr-token", name: "MQR Token",
|
||||
description: "Just the code, encoding the compact internal short id - no other text on the label.",
|
||||
required_vars: ["shortId"], tags: ["internal"],
|
||||
layout: [{type: "mqr", content: c => c.shortId}]
|
||||
},
|
||||
{
|
||||
id: "qr-url", name: "MQR Token", description: "The code with the encoded text printed next to it.",
|
||||
id: "qr-url", name: "QR Token",
|
||||
description: "Just the code, encoding the short link - no other text on the label.",
|
||||
required_vars: ["shortUrl"], tags: ["external"],
|
||||
layout: [{type: "qr-h", content: c => c.shortUrl}]
|
||||
},
|
||||
{
|
||||
id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["shortUrl", "itemId"], tags: ["external"],
|
||||
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
|
||||
id: "rmqr-url", name: "rMQR Token + ID",
|
||||
description: "The code encoding the short link, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortUrl", "id"], tags: ["external"],
|
||||
layout: [{type: "rmqr", content: c => c.shortUrl}, {
|
||||
type: "text",
|
||||
content: c => c.itemId?.toString().padStart(4, "0")
|
||||
content: c => c.id?.toString().padStart(4, "0")
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["shortId", "itemId"], tags: ["internal"],
|
||||
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
|
||||
id: "rmqr-url-url", name: "rMQR Token + ID + URL",
|
||||
description: "The code encoding the short link, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortUrl", "id", "typedPrefix", "url"], tags: ["external"],
|
||||
layout: [[[{type: "rmqr", content: c => c.shortUrl}, {
|
||||
type: "text",
|
||||
content: c => c.itemId?.toString().padStart(4, "0")
|
||||
content: c => c.typedPrefix?.toUpperCase() + c.id?.toString().padStart(4, "0")
|
||||
}],{type: "text", content: c => c.url}]]
|
||||
},
|
||||
{
|
||||
id: "mqr-token-id", name: "MQR Token + ID",
|
||||
description: "The code encoding the internal short id, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortId", "id"], tags: ["internal"],
|
||||
layout: [{type: "mqr", content: c => c.shortId}, {
|
||||
type: "text",
|
||||
content: c => c.id?.toString().padStart(4, "0")
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["shortUrl", "locationId"], tags: ["external"],
|
||||
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
|
||||
type: "text",
|
||||
content: c => c.locationId?.toString().padStart(4, "0")
|
||||
}]
|
||||
id: "domain-short-id-qr", name: "Any-Instance Token (QR)",
|
||||
description: "The code encoding the domain-qualified short id (domain:~token) - resolvable "
|
||||
+ "from any Toolshed instance's own scanner, not just this one, with no full URL needed.",
|
||||
required_vars: ["domainShortId"], tags: ["cross-instance"],
|
||||
layout: [{type: "qr", content: c => c.domainShortId}]
|
||||
},
|
||||
{
|
||||
id: "location-mqr-tokem-id",
|
||||
name: "rMQR Token",
|
||||
description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["shortId", "locationId"],
|
||||
tags: ["internal"],
|
||||
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
|
||||
type: "text",
|
||||
content: c => c.locationId?.toString().padStart(4, "0")
|
||||
}]
|
||||
}, ...QR_ONLY_TEMPLATES,
|
||||
id: "domain-short-id-text", name: "Any-Instance Token",
|
||||
description: "Just the domain-qualified short id (domain:~token) as text, not a URL - meant "
|
||||
+ "to be typed or scanned into any Toolshed instance's own resolver.",
|
||||
required_vars: ["domainShortId"], tags: ["cross-instance"],
|
||||
layout: [{type: "text", content: c => c.domainShortId}]
|
||||
},
|
||||
|
||||
{
|
||||
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "qr", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]
|
||||
layout: [{type: "qr", content: c => c.text}, {type: "text", content: c => c.text?.split("\n")}]
|
||||
},
|
||||
{
|
||||
id: "qr-text-below", name: "QR code + text below",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["text"],
|
||||
layout: [[{type: "qr", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]]
|
||||
layout: [[{type: "qr", content: c => c.text}, {type: "text", content: c => c.text?.split("\n")}]]
|
||||
},
|
||||
{
|
||||
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["itemId", "text", "userHandle"],
|
||||
layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, {
|
||||
required_vars: ["kind", "id", "text", "userHandle"],
|
||||
layout: [[{type: "text", content: c => c.kind + ": " + c.id}, {
|
||||
type: "qr",
|
||||
content: c => c.text
|
||||
}, GAP, {type: "text", content: c => c.userHandle}]]
|
||||
}, {type: "text", content: c => c.userHandle}]]
|
||||
},
|
||||
{
|
||||
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "text", content: c => c.text?.split("\n")}]
|
||||
},
|
||||
{
|
||||
id: "item-handle", name: "Item handle",
|
||||
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
|
||||
required_vars: ["itemHandle"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.itemHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url", name: "Item URL",
|
||||
description: "The full item URL as text, with no code - for copying rather than scanning.",
|
||||
required_vars: ["itemUrl"], tags: ["external"],
|
||||
layout: [{type: "text", content: c => c.itemUrl}]
|
||||
id: "qualified-handle", name: "Qualified handle",
|
||||
description: "The compact owner@domain:i<id> / owner@domain:s<id> User-Qualified ID - meaningful "
|
||||
+ "in-app, not scannable on its own.",
|
||||
required_vars: ["qualifiedHandle"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.qualifiedHandle}]
|
||||
},
|
||||
{
|
||||
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
|
||||
|
|
@ -165,50 +113,45 @@ export const LABEL_TEMPLATES = [
|
|||
layout: [{type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.",
|
||||
required_vars: ["itemId"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.itemId}]
|
||||
id: "id", name: "ID", description: "Just the bare id, as text only.",
|
||||
required_vars: ["id"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.id}]
|
||||
},
|
||||
{
|
||||
id: "owner-id-text", name: "Owner + item ID",
|
||||
description: "The owner's handle and the item id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "itemId"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
|
||||
id: "owner-id-text-multiline", name: "Owner + ID (same size)",
|
||||
description: "The owner's handle and the id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "id"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => [c.userHandle, c.id]}]
|
||||
},
|
||||
{
|
||||
id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.",
|
||||
required_vars: ["locationId"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.locationId}]
|
||||
id: "owner-id-text", name: "Owner + ID",
|
||||
description: "The owner's handle and the id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "id"], tags: ["internal"],
|
||||
layout: [[{type: "text", content: c => [c.userHandle]},{type: "text", content: c => [c.id]}]]
|
||||
},
|
||||
{
|
||||
id: "owner-id-text-location", name: "Owner + location ID",
|
||||
description: "The owner's handle and the location id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "locationId"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => [c.userHandle, c.locationId]}]
|
||||
id: "url-qr-handle", name: "URL + handle",
|
||||
description: "Scannable URL, with the compact handle printed alongside.",
|
||||
required_vars: ["url", "qualifiedHandle"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => c.qualifiedHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-handle", name: "Item URL + handle",
|
||||
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
||||
required_vars: ["itemUrl", "itemHandle"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
|
||||
id: "url-qr-owner", name: "URL + owner",
|
||||
description: "Scannable URL, with the owner's handle printed alongside.",
|
||||
required_vars: ["url", "userHandle"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner", name: "Item URL + owner",
|
||||
description: "Scannable item URL, with the owner's handle printed alongside.",
|
||||
required_vars: ["itemUrl", "userHandle"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
|
||||
id: "url-qr-id", name: "URL + ID",
|
||||
description: "Scannable URL, with the bare id printed alongside.",
|
||||
required_vars: ["url", "id"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => c.id}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-id", name: "Item URL + item ID",
|
||||
description: "Scannable item URL, with the bare item id printed alongside.",
|
||||
required_vars: ["itemUrl", "itemId"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner-id", name: "Item URL + owner + ID",
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||
required_vars: ["itemUrl", "userHandle", "itemId"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
|
||||
id: "url-qr-owner-id", name: "URL + owner + ID",
|
||||
description: "Scannable URL, with the owner's handle and the id on two lines alongside.",
|
||||
required_vars: ["url", "userHandle", "id"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => [c.userHandle, c.id]}]
|
||||
},
|
||||
{
|
||||
id: "short-url-qr", name: "Short link (QR code)",
|
||||
|
|
@ -218,102 +161,42 @@ export const LABEL_TEMPLATES = [
|
|||
layout: [{type: "qr", content: c => c.shortUrl}]
|
||||
},
|
||||
{
|
||||
id: "item-url-qr-owner-id2", name: "Item URL + owner + ID",
|
||||
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
|
||||
required_vars: ["itemUrl", "userHandle", "itemId"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.itemUrl}, GAP, [{
|
||||
id: "url-qr-owner-id2", name: "URL + owner + ID",
|
||||
description: "Scannable URL, with the owner's handle and the id on two lines alongside.",
|
||||
required_vars: ["url", "userHandle", "id"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, [{
|
||||
type: "text",
|
||||
content: c => c.userHandle
|
||||
}, GAP, {type: "text", content: c => c.itemId}]]
|
||||
}, {type: "text", content: c => c.id}]]
|
||||
},
|
||||
];
|
||||
|
||||
// Every distinct tag value used across LABEL_TEMPLATES' tags, in first-seen order - drives
|
||||
// LabelLayoutPreview.vue's filter buttons without hand-listing "internal"/"external" there.
|
||||
export const LABEL_TAGS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.tags ?? []))];
|
||||
|
||||
// Every field name any template's required_vars names, in first-seen order.
|
||||
export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))];
|
||||
|
||||
// DERIVED_VARS shape and declaration-order invariant. See
|
||||
// docs/implementation.md#derived-vars-shape-and-ordering.
|
||||
export const DERIVED_VARS = {
|
||||
// Full user@domain handle, kept as separate user/domain base vars since that's how the
|
||||
// account is actually shaped (see federation.md's Unique Handles); this is just the
|
||||
// display/URL form.
|
||||
userHandle: {
|
||||
inputs: ["user", "domain"],
|
||||
calc: (f) => `${f.user}@${f.domain}`,
|
||||
},
|
||||
// Self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
|
||||
// label encodes, since scanning it must resolve everything with no other context. `webdomain`
|
||||
// defaults to this origin but is editable, since any frontend can resolve any handle.
|
||||
itemUrl: {
|
||||
inputs: ["webdomain", "userHandle", "itemId"],
|
||||
calc: (f) => `${f.webdomain}/i/${encodeHandleForUrl(f.userHandle)}/${f.itemId}`,
|
||||
// handles-and-shortids.md's kind letter (i/s) for f.kind - must stay declared before qualifiedHandle, which depends on it.
|
||||
typedPrefix: {
|
||||
inputs: ["kind"],
|
||||
calc: (f) => CONTENT_KINDS_BY_ID[f.kind]?.typedPrefix,
|
||||
},
|
||||
// Compact "owner handle + id" form (see docs/design-in-progress/items-labels.md) - meaningful
|
||||
// only where context already makes clear it's a Toolshed item, unlike itemUrl.
|
||||
itemHandle: {
|
||||
inputs: ["userHandle", "itemId"],
|
||||
calc: (f) => `${f.userHandle}:${f.itemId}`,
|
||||
qualifiedHandle: {
|
||||
inputs: ["userHandle", "typedPrefix", "id"],
|
||||
calc: (f) => `${f.userHandle}:${f.typedPrefix}${f.id}`,
|
||||
},
|
||||
// Undefined for a kind whose CONTENT_KINDS entry has no buildUrl - same "unresolved" signal templateIsAvailable already treats every other missing var as.
|
||||
url: {
|
||||
inputs: ["webdomain", "userHandle", "kind", "id"],
|
||||
calc: (f) => CONTENT_KINDS_BY_ID[f.kind]?.buildUrl?.({origin: f.webdomain, userHandle: f.userHandle, id: f.id}),
|
||||
},
|
||||
};
|
||||
|
||||
// Text-input vars = template KNOWN_VARS minus derived ones, plus any var a DERIVED_VARS calc
|
||||
// needs (e.g. "webdomain") even if no template names it directly.
|
||||
export const BASE_VARS = [...new Set([
|
||||
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
|
||||
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
|
||||
])];
|
||||
|
||||
// Runs each DERIVED_VARS calc against `fields`, adding the result wherever its inputs are
|
||||
// present, so callers never need to know DERIVED_VARS' {inputs, calc} shape.
|
||||
export function withDerivedVars(fields) {
|
||||
const result = {...fields};
|
||||
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
|
||||
if (inputs.every(v => result[v])) {
|
||||
result[name] = calc(result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function walkLeaves(node, fn) {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(child => walkLeaves(child, fn));
|
||||
} else {
|
||||
fn(node);
|
||||
}
|
||||
}
|
||||
|
||||
function mapTree(node, fn) {
|
||||
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
|
||||
}
|
||||
|
||||
// Resolved only if every part is - a single string for a QR-family/"text" leaf, every line for a
|
||||
// multi-line "text" (see "owner-id-text" and "item-url-qr-owner-id" above).
|
||||
function isResolved(value) {
|
||||
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
// Shared by LabelLayoutPreview.vue's grid and Print.vue's preview, so neither reimplements
|
||||
// leaf-walking/field-resolving itself.
|
||||
export function templateIsAvailable(t, fields) {
|
||||
let available = true;
|
||||
walkLeaves(t.layout, leaf => {
|
||||
if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) {
|
||||
available = false;
|
||||
}
|
||||
});
|
||||
return available;
|
||||
}
|
||||
|
||||
export function templateContent(t, fields) {
|
||||
if (!templateIsAvailable(t, fields)) {
|
||||
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
||||
err.short = "missing data";
|
||||
throw err;
|
||||
}
|
||||
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {loadAnyDCode} from "../vendor/anyd-qr.js";
|
||||
import {encodeHandleForUrl} from "@/router"
|
||||
import {drawPixelText, preloadPixelFontRenderer} from "@/pixel-font.js";
|
||||
import {DERIVED_VARS, CONTENT_KINDS_BY_ID} from "@/label-layouts.js";
|
||||
|
||||
// Re-exported so every caller can preload both the QR encoder and the bitmap-font rasterizer
|
||||
// (see fontTierFor's `pixel` tiers below) the same way, alongside this file's own preloadQrEncoder.
|
||||
|
|
@ -249,8 +249,8 @@ function positionTree(node, ownAxis, x, y) {
|
|||
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still
|
||||
// printable, just flagged as a scan-reliability risk), and unconditionally appends its raw
|
||||
// {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether
|
||||
// they tripped a threshold (see LabelLayoutPreview.vue's debug line) shouldn't have to re-derive
|
||||
// them. See docs/implementation.md#crisp-qr-sizing.
|
||||
// they tripped a threshold shouldn't have to re-derive them. See
|
||||
// docs/implementation.md#crisp-qr-sizing.
|
||||
function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) {
|
||||
if (isSplit(node)) {
|
||||
node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo));
|
||||
|
|
@ -349,7 +349,7 @@ function measureTextBlock(ctx, lines, referencePx) {
|
|||
return {width, height};
|
||||
}
|
||||
|
||||
// Converts a resolved content tree (see templateContent) into one ready for layout. See
|
||||
// Converts a resolved content tree (see templateContent below) into one ready for layout. See
|
||||
// docs/implementation.md#render-tree-construction.
|
||||
function buildRenderTree(ctx, node, referencePx) {
|
||||
if (isSplit(node)) {
|
||||
|
|
@ -483,7 +483,7 @@ function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm,
|
|||
return {tree, length, warnings, qrInfo, textInfo};
|
||||
}
|
||||
|
||||
// The tape-fed layout: draws a fully resolved content tree (see templateContent) at the tape's
|
||||
// The tape-fed layout: draws a fully resolved content tree (see templateContent below) at the tape's
|
||||
// real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns
|
||||
// {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
||||
// top-to-bottom order; {warnings}: {short, message} scan-reliability entries from
|
||||
|
|
@ -567,28 +567,17 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
|
|||
return {textSizesPx, warnings, qrInfo, textInfo};
|
||||
}
|
||||
|
||||
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
|
||||
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
|
||||
export const LABEL_CONTENT_BUILDERS = {
|
||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
|
||||
// the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
|
||||
// lookup, so it stays a separate field/template rather than being baked in here.
|
||||
"item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
|
||||
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
|
||||
// the short link and any future location template still work via the base vars below.
|
||||
};
|
||||
|
||||
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a print label should show/encode, via that kind's CONTENT_KINDS_BY_ID entry.
|
||||
export function buildLabelContent(prefill) {
|
||||
if (!prefill) {
|
||||
const entry = prefill && CONTENT_KINDS_BY_ID[prefill.kind];
|
||||
const {userHandle, id} = prefill?.components ?? {};
|
||||
if (!entry?.buildUrl || !userHandle || !id) {
|
||||
return "";
|
||||
}
|
||||
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
|
||||
return build ? build(prefill.components) : "";
|
||||
return entry.buildUrl({origin: window.location.origin, userHandle, id});
|
||||
}
|
||||
|
||||
// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
|
||||
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
|
||||
// required_vars by.
|
||||
// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way store.js's lookupServer does).
|
||||
function splitUserHandle(userHandle) {
|
||||
if (!userHandle) {
|
||||
return null;
|
||||
|
|
@ -600,30 +589,66 @@ function splitUserHandle(userHandle) {
|
|||
};
|
||||
}
|
||||
|
||||
// Seeds label-layouts.js's BASE_VARS, keyed by `kind`; derived vars (userHandle, itemUrl, …) are
|
||||
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
|
||||
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
|
||||
const LABEL_FIELD_BUILDERS = {
|
||||
"item": ({userHandle, item}) => {
|
||||
const split = splitUserHandle(userHandle);
|
||||
if (!split || !item) {
|
||||
return {};
|
||||
}
|
||||
return {...split, itemId: String(item)};
|
||||
},
|
||||
"storage-location": ({userHandle, location}) => {
|
||||
const split = splitUserHandle(userHandle);
|
||||
if (!split || !location) {
|
||||
return {};
|
||||
}
|
||||
return {...split, locationId: String(location)};
|
||||
},
|
||||
};
|
||||
|
||||
// Seeds label-layouts.js's BASE_VARS from a {kind, components: {userHandle, id}} prefill - one generic function suffices now that every kind's prefill shares this shape (see CONTENT_KINDS_BY_ID).
|
||||
export function buildLabelFields(prefill) {
|
||||
if (!prefill) {
|
||||
if (!prefill || !(prefill.kind in CONTENT_KINDS_BY_ID)) {
|
||||
return {};
|
||||
}
|
||||
const build = LABEL_FIELD_BUILDERS[prefill.kind];
|
||||
return build ? build(prefill.components) : {};
|
||||
const {userHandle, id} = prefill.components ?? {};
|
||||
const split = splitUserHandle(userHandle);
|
||||
if (!split || !id) {
|
||||
return {};
|
||||
}
|
||||
return {...split, kind: prefill.kind, id: String(id)};
|
||||
}
|
||||
|
||||
// Runs each DERIVED_VARS calc against `fields`, adding the result wherever its inputs are
|
||||
// present, so callers never need to know DERIVED_VARS' {inputs, calc} shape.
|
||||
export function withDerivedVars(fields) {
|
||||
const result = {...fields};
|
||||
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
|
||||
if (inputs.every(v => result[v])) {
|
||||
result[name] = calc(result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function walkLeaves(node, fn) {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(child => walkLeaves(child, fn));
|
||||
} else {
|
||||
fn(node);
|
||||
}
|
||||
}
|
||||
|
||||
function mapTree(node, fn) {
|
||||
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
|
||||
}
|
||||
|
||||
// Resolved only if every part is - a single string for a QR-family/"text" leaf, every line for a
|
||||
// multi-line "text" (see label-layouts.js's "owner-id-text" and "item-url-qr-owner-id" templates).
|
||||
function isResolved(value) {
|
||||
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
// Shared by LabelLayoutPreview.vue's grid and Print.vue's preview, so neither reimplements
|
||||
// leaf-walking/field-resolving itself.
|
||||
export function templateIsAvailable(t, fields) {
|
||||
let available = true;
|
||||
walkLeaves(t.layout, leaf => {
|
||||
if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) {
|
||||
available = false;
|
||||
}
|
||||
});
|
||||
return available;
|
||||
}
|
||||
|
||||
export function templateContent(t, fields) {
|
||||
if (!templateIsAvailable(t, fields)) {
|
||||
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
||||
err.short = "missing data";
|
||||
throw err;
|
||||
}
|
||||
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
||||
}
|
||||
|
|
|
|||
89
frontend/src/preferences.js
Normal file
89
frontend/src/preferences.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Preference schema catalog, device/account resolution, and device-local (localStorage)
|
||||
// persistence. See docs/implementation.md#preferences for account vs device precedence.
|
||||
|
||||
export const DEVICE_STORAGE_KEY = 'toolshed.preferences.device'
|
||||
|
||||
export const defaultPreferenceDefinitions = [
|
||||
{
|
||||
key: 'ui.compact_mode',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
label: 'Compact mode',
|
||||
description: 'Show denser item rows and reduce spacing in lists.'
|
||||
},
|
||||
{
|
||||
key: 'ui.default_search_scope',
|
||||
type: 'enum',
|
||||
options: ['inventory', 'friends', 'all'],
|
||||
default: 'inventory',
|
||||
label: 'Default search scope',
|
||||
description: 'Choose where the global search starts.'
|
||||
},
|
||||
{
|
||||
key: 'notifications.desktop_enabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
label: 'Desktop notifications',
|
||||
description: 'Enable in-browser notifications for important updates.'
|
||||
},
|
||||
{
|
||||
key: 'files.max_upload_mb',
|
||||
type: 'integer',
|
||||
default: 25,
|
||||
label: 'Default upload size limit (MB)',
|
||||
description: 'Used as a prefill hint in upload dialogs.'
|
||||
},
|
||||
{
|
||||
key: 'ui.experimental_flags',
|
||||
type: 'json',
|
||||
default: {},
|
||||
label: 'Experimental flags',
|
||||
description: 'Optional JSON toggles for feature previews.'
|
||||
},
|
||||
]
|
||||
|
||||
export function loadDevicePreferences(storageKey = DEVICE_STORAGE_KEY) {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (_error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort - a disabled/full localStorage shouldn't break whatever triggered the save (see
|
||||
// e.g. Print.vue's rememberLayout).
|
||||
export function saveDevicePreferences(preferences, storageKey = DEVICE_STORAGE_KEY) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(preferences))
|
||||
} catch (error) {
|
||||
console.error('Failed to save device preferences:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution order: device -> account -> schema default -> fallbackDefault.
|
||||
export function resolvePreference(key, {devicePreferences = {}, accountPreferences = {}, definitions = []} = {}, fallbackDefault = null) {
|
||||
if (Object.prototype.hasOwnProperty.call(devicePreferences, key) && devicePreferences[key] !== null) {
|
||||
return devicePreferences[key]
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(accountPreferences, key) && accountPreferences[key] !== null) {
|
||||
return accountPreferences[key]
|
||||
}
|
||||
const definition = definitions.find((pref) => pref.key === key)
|
||||
if (definition) {
|
||||
return definition.default
|
||||
}
|
||||
return fallbackDefault
|
||||
}
|
||||
|
||||
export default {
|
||||
DEVICE_STORAGE_KEY,
|
||||
defaultPreferenceDefinitions,
|
||||
loadDevicePreferences,
|
||||
saveDevicePreferences,
|
||||
resolvePreference,
|
||||
};
|
||||
|
|
@ -27,7 +27,10 @@ import Scan from "@/views/Scan.vue";
|
|||
import Workflows from '@/views/Workflows.vue';
|
||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||
import ShortId from '@/views/ShortId.vue';
|
||||
import {encodeShortId, serializeShortId,decodeShortId, deserializeShortId} from '@/short-id';
|
||||
import {
|
||||
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
|
||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||
} from '@/short-id';
|
||||
|
||||
import Account from '@/views/settings/Account.vue';
|
||||
import Password from '@/views/settings/Password.vue';
|
||||
|
|
@ -46,33 +49,36 @@ export function decodeHandleFromUrl(segment) {
|
|||
return segment.replace(/\+/g, "#");
|
||||
}
|
||||
|
||||
// item/group_item share this /inventory/:handle/:id shape; only owner-handle resolution (identity vs. group, see store.js) differs.
|
||||
function itemDetailRoute(handle, item_local_id) {
|
||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||
}
|
||||
|
||||
// storage_location/group_storage_location share this /storage-locations/:handle/:id shape, same idea as itemDetailRoute above.
|
||||
function storageLocationDetailRoute(handle, storage_location_id) {
|
||||
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||
}
|
||||
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
export function ownerOverviewRoute(item) {
|
||||
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
|
||||
return item.owner_group ? `/inventory/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
|
||||
}
|
||||
|
||||
export function ownerLocationOverviewRoute(location) {
|
||||
return location.owner_group ? `/storage-location/${encodeHandleForUrl(location.owner_group)}` : '/storage-location';
|
||||
}
|
||||
|
||||
const EXPANDED_ROUTE_BUILDERS = {
|
||||
item: ({owner_identity_id, item_local_id}) =>
|
||||
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
|
||||
group_item: ({owner_group_id, item_local_id}) =>
|
||||
itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id),
|
||||
item: ({owner_identity_id, item_local_id}) => {
|
||||
const handle = store.getters.identityHandleById[owner_identity_id];
|
||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||
},
|
||||
group_item: ({owner_group_id, item_local_id}) => {
|
||||
const handle = store.getters.groupHandleById[owner_group_id];
|
||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||
},
|
||||
group: ({group_id}) => {
|
||||
const handle = store.getters.groupHandleById[group_id];
|
||||
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
|
||||
},
|
||||
storage_location: ({owner_identity_id, storage_location_id}) =>
|
||||
storageLocationDetailRoute(store.getters.identityHandleById[owner_identity_id], storage_location_id),
|
||||
group_storage_location: ({owner_group_id, storage_location_id}) =>
|
||||
storageLocationDetailRoute(store.getters.groupHandleById[owner_group_id], storage_location_id),
|
||||
storage_location: ({owner_identity_id, storage_location_id}) => {
|
||||
const handle = store.getters.identityHandleById[owner_identity_id];
|
||||
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||
},
|
||||
group_storage_location: ({owner_group_id, storage_location_id}) => {
|
||||
const handle = store.getters.groupHandleById[owner_group_id];
|
||||
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||
},
|
||||
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||
};
|
||||
|
||||
|
|
@ -88,10 +94,43 @@ export function shortenedRoute(named) {
|
|||
return '/' + encodeShortId(serializeShortId(named));
|
||||
}
|
||||
|
||||
// Owned kinds only - matches handles-and-shortids.md's User-Qualified ID split (group/category/file have no owner id to hand a foreign backend).
|
||||
export const OWNED_KIND_FIELDS = {
|
||||
item: {ownerField: 'owner_identity_id', localField: 'item_local_id', isLocation: false},
|
||||
group_item: {ownerField: 'owner_group_id', localField: 'item_local_id', isLocation: false},
|
||||
storage_location: {ownerField: 'owner_identity_id', localField: 'storage_location_id', isLocation: true},
|
||||
group_storage_location: {ownerField: 'owner_group_id', localField: 'storage_location_id', isLocation: true},
|
||||
};
|
||||
|
||||
// Resolves a Domain-Qualified Short ID to a route, locally or via a network round-trip. See docs/implementation.md#domain-qualified-short-id-resolution.
|
||||
export async function domainQualifiedRoute(domain, ints) {
|
||||
const homeDomain = store.getters.isLoggedIn && store.state.user ? store.state.user.split('@')[1] : null;
|
||||
const deserialized = deserializeShortId(ints);
|
||||
if (domain === homeDomain) {
|
||||
return expandedRoute(deserialized) || undefined;
|
||||
}
|
||||
const owned = OWNED_KIND_FIELDS[deserialized.kind];
|
||||
if (!owned) {
|
||||
return undefined; // no owner id to resolve a foreign backend against yet (group/category/file/workflow)
|
||||
}
|
||||
const resolved = await store.dispatch('resolveShortId', {
|
||||
domain, kind: deserialized.kind,
|
||||
ownerId: deserialized[owned.ownerField], localId: deserialized[owned.localField],
|
||||
});
|
||||
if (!resolved || !resolved.handle) {
|
||||
return undefined;
|
||||
}
|
||||
return owned.isLocation
|
||||
? `/storage-locations/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`
|
||||
: `/inventory/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`;
|
||||
}
|
||||
|
||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||
path: '/inventory',
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/inventory/:owner?',
|
||||
component: Inventory,
|
||||
meta: {requiresAuth: true}
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/inventory/:handle/:id',
|
||||
component: InventoryDetail,
|
||||
|
|
@ -111,15 +150,20 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
path: '/:short_id',
|
||||
component: ShortId,
|
||||
props: true,
|
||||
beforeEnter: to => {
|
||||
console.log(to);
|
||||
const p = deserializeShortId(decodeShortId(to.params.short_id))
|
||||
console.log(p)
|
||||
const url = expandedRoute(p);
|
||||
console.log(url);
|
||||
return url;
|
||||
beforeEnter: async to => {
|
||||
if (isDomainQualifiedShortId(to.params.short_id)) {
|
||||
const {domain, ints} = decodeDomainQualifiedShortId(to.params.short_id);
|
||||
return await domainQualifiedRoute(domain, ints);
|
||||
}
|
||||
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||
return expandedRoute(deserializeShortId(decodeShortId(to.params.short_id)));
|
||||
}
|
||||
}, {
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/inventory/new/:group?',
|
||||
component: InventoryNew,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/friends',
|
||||
component: Friends,
|
||||
meta: {requiresAuth: true}
|
||||
|
|
@ -140,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},
|
||||
|
|
@ -192,9 +236,11 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true}
|
||||
}]
|
||||
}, {
|
||||
path: '/storage-location',
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/storage-location/:owner?',
|
||||
component: StorageLocation,
|
||||
meta: {requiresAuth: true}
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/storage-locations/:handle/:id',
|
||||
component: StorageLocationDetail,
|
||||
|
|
@ -206,12 +252,14 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/storage-locations/new',
|
||||
component: StorageLocationNew,
|
||||
meta: {requiresAuth: true}
|
||||
// Label/short-link entry point for storage locations, the counterpart of /i/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
|
||||
path: '/s/:handle/:id',
|
||||
redirect: to => `/storage-locations/${to.params.handle}/${to.params.id}`
|
||||
}, {
|
||||
path: '/debug/:short_id',
|
||||
component: ShortId,
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/storage-locations/new/:group?',
|
||||
component: StorageLocationNew,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,22 @@ export function deserializeShortId(ints) {
|
|||
return schema.interpret(fieldValues)
|
||||
}
|
||||
|
||||
export function encodeDomainQualifiedShortId(domain, ints) {
|
||||
return domain + ':' + encodeShortId(ints)
|
||||
}
|
||||
|
||||
export function decodeDomainQualifiedShortId(text) {
|
||||
const i = text.indexOf(':~')
|
||||
if (i === -1) {
|
||||
throw new Error("not a domain-qualified short id: expected '<domain>:~<token>'")
|
||||
}
|
||||
return {domain: text.slice(0, i), token: text.slice(i + 1), ints: decodeShortId(text.slice(i + 1))}
|
||||
}
|
||||
|
||||
export function isDomainQualifiedShortId(text) {
|
||||
return typeof text === 'string' && /^[^\s~]+:~/.test(text)
|
||||
}
|
||||
|
||||
export function serializeShortId({kind, ...fieldValues}) {
|
||||
const entry = Object.entries(SCHEMAS).find(([, s]) => s.name === kind)
|
||||
if (!entry) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import {createStore} from 'vuex';
|
||||
import router from '@/router';
|
||||
import router, {encodeHandleForUrl} from '@/router';
|
||||
import FallBackResolver from "@/dns";
|
||||
import NeighborsCache from "@/neigbors";
|
||||
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
|
||||
import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
|
||||
import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js";
|
||||
import {defaultPreferenceDefinitions, loadDevicePreferences, saveDevicePreferences, resolvePreference} from "@/preferences.js";
|
||||
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
||||
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
||||
|
||||
|
|
@ -13,59 +14,6 @@ function splitGroupHandle(handle) {
|
|||
return {name, domain}
|
||||
}
|
||||
|
||||
const defaultPreferenceDefinitions = [
|
||||
{
|
||||
key: 'ui.compact_mode',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
label: 'Compact mode',
|
||||
description: 'Show denser item rows and reduce spacing in lists.'
|
||||
},
|
||||
{
|
||||
key: 'ui.default_search_scope',
|
||||
type: 'enum',
|
||||
options: ['inventory', 'friends', 'all'],
|
||||
default: 'inventory',
|
||||
label: 'Default search scope',
|
||||
description: 'Choose where the global search starts.'
|
||||
},
|
||||
{
|
||||
key: 'notifications.desktop_enabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
label: 'Desktop notifications',
|
||||
description: 'Enable in-browser notifications for important updates.'
|
||||
},
|
||||
{
|
||||
key: 'files.max_upload_mb',
|
||||
type: 'integer',
|
||||
default: 25,
|
||||
label: 'Default upload size limit (MB)',
|
||||
description: 'Used as a prefill hint in upload dialogs.'
|
||||
},
|
||||
{
|
||||
key: 'ui.experimental_flags',
|
||||
type: 'json',
|
||||
default: {},
|
||||
label: 'Experimental flags',
|
||||
description: 'Optional JSON toggles for feature previews.'
|
||||
},
|
||||
]
|
||||
|
||||
const parseStoredPreferences = (storageKey) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (_error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
local_loaded: false,
|
||||
|
|
@ -97,9 +45,12 @@ export default createStore({
|
|||
domains: [],
|
||||
storage_locations: [],
|
||||
active_workflows: [],
|
||||
preferenceDefinitions: [],
|
||||
// Static schema and device-local values are available synchronously - see
|
||||
// docs/implementation.md#preferences - unlike accountPreferences, which needs a network
|
||||
// round trip and is gated by preferencesLoaded.
|
||||
preferenceDefinitions: defaultPreferenceDefinitions,
|
||||
accountPreferences: {},
|
||||
devicePreferences: {},
|
||||
devicePreferences: loadDevicePreferences(),
|
||||
preferencesLoaded: false,
|
||||
},
|
||||
mutations: {
|
||||
|
|
@ -161,15 +112,9 @@ export default createStore({
|
|||
setActiveWorkflows(state, workflows) {
|
||||
state.active_workflows = workflows;
|
||||
},
|
||||
setPreferenceDefinitions(state, definitions) {
|
||||
state.preferenceDefinitions = definitions;
|
||||
},
|
||||
setAccountPreferences(state, preferences) {
|
||||
state.accountPreferences = preferences;
|
||||
},
|
||||
setDevicePreferences(state, preferences) {
|
||||
state.devicePreferences = preferences;
|
||||
},
|
||||
setAccountPreference(state, {key, value}) {
|
||||
state.accountPreferences = {...state.accountPreferences, [key]: value};
|
||||
},
|
||||
|
|
@ -237,9 +182,8 @@ export default createStore({
|
|||
state.token = null;
|
||||
state.keypair = null;
|
||||
state.accountPreferences = {};
|
||||
state.preferenceDefinitions = [];
|
||||
state.preferencesLoaded = false;
|
||||
// Note: devicePreferences are NOT cleared on logout (device-specific)
|
||||
// Note: devicePreferences/preferenceDefinitions are NOT cleared on logout (device-specific / static)
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('keypair');
|
||||
|
|
@ -266,19 +210,13 @@ export default createStore({
|
|||
actions: {
|
||||
async loadUserPreferences({state, commit, dispatch, getters}) {
|
||||
if (state.preferencesLoaded) return
|
||||
const deviceKey = 'toolshed.preferences.device'
|
||||
commit('setDevicePreferences', parseStoredPreferences(deviceKey))
|
||||
|
||||
let definitions = defaultPreferenceDefinitions
|
||||
let accountPreferences = {}
|
||||
try {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
definitions = await servers.get(getters.nullAuth, '/auth/preferences/') || defaultPreferenceDefinitions
|
||||
accountPreferences = await servers.get(getters.signAuth, '/auth/self/preferences/') || {}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user preferences:', error)
|
||||
}
|
||||
commit('setPreferenceDefinitions', definitions)
|
||||
commit('setAccountPreferences', accountPreferences)
|
||||
commit('setPreferencesLoaded', true)
|
||||
},
|
||||
|
|
@ -294,11 +232,11 @@ export default createStore({
|
|||
},
|
||||
async setDevicePreference({state, commit}, {key, value}) {
|
||||
commit('setDevicePreference', {key, value})
|
||||
localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences))
|
||||
saveDevicePreferences(state.devicePreferences)
|
||||
},
|
||||
async resetDevicePreference({state, commit}, key) {
|
||||
commit('deleteDevicePreference', key)
|
||||
localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences))
|
||||
saveDevicePreferences(state.devicePreferences)
|
||||
},
|
||||
async login({commit, dispatch, state, getters}, {username, password, remember}) {
|
||||
commit('setRemember', remember);
|
||||
|
|
@ -370,11 +308,11 @@ export default createStore({
|
|||
servers.add(home)
|
||||
for (const friend of friends) {
|
||||
try {
|
||||
const s = await dispatch('lookupServer', {username: friend.username})
|
||||
const s = await dispatch('lookupServer', {username: friend.handle})
|
||||
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
||||
} catch (e) {
|
||||
// Skip an unresolvable/unreachable friend rather than aborting the whole lookup.
|
||||
console.error('could not resolve server for friend', friend.username, e)
|
||||
console.error('could not resolve server for friend', friend.handle, e)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
|
|
@ -393,9 +331,9 @@ export default createStore({
|
|||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.nullAuth, '/api/version/')
|
||||
},
|
||||
async fetchInventoryItems({commit, dispatch, getters}) {
|
||||
async fetchInventoryItems({state, commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const items = await servers.get(getters.signAuth, '/api/inventory_items/')
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + state.user + '/')
|
||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||
commit('setInventoryItems', {url: '/', items})
|
||||
return items
|
||||
|
|
@ -404,11 +342,10 @@ export default createStore({
|
|||
const servers = item.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const data = {
|
||||
availability_policy: 'private', ...item,
|
||||
owner_group: item.owner_group ? item.owner_group.slice(1) : null
|
||||
}
|
||||
const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data)
|
||||
const owner = item.owner_group ? encodeHandleForUrl(item.owner_group) : state.user
|
||||
const data = {availability_policy: 'private', ...item}
|
||||
delete data.owner_group
|
||||
const reply = await servers.post(getters.signAuth, '/api/v1/inventory_items/' + owner + '/', data)
|
||||
state.last_load.files = 0
|
||||
return reply
|
||||
},
|
||||
|
|
@ -418,26 +355,29 @@ export default createStore({
|
|||
: await dispatch('getHomeServers')
|
||||
const data = {availability_policy: 'friends', ...item}
|
||||
data.files = data.files.map(file => file.id)
|
||||
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
|
||||
// Path is scoped by the owner's handle, not the item's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
|
||||
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
|
||||
return await servers.patch(getters.signAuth, path, data)
|
||||
},
|
||||
async deleteInventoryItem({state, dispatch, getters}, item) {
|
||||
const servers = item.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/')
|
||||
dispatch('fetchInventoryItems')
|
||||
return ret
|
||||
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
|
||||
// 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')
|
||||
const items = await servers.get(getters.signAuth, '/api/search/?query=' + query)
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/search/?query=' + query)
|
||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||
commit('setInventoryItems', {url: '/search/' + query, items})
|
||||
return items
|
||||
},
|
||||
async fetchFriends({commit, dispatch, getters, state}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/friends/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/friends/')
|
||||
commit('setFriends', data)
|
||||
return data
|
||||
},
|
||||
|
|
@ -458,9 +398,8 @@ export default createStore({
|
|||
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username: owner});
|
||||
// owner is a full handle (username@domain); this endpoint looks the item up by
|
||||
// owner, not requester (see toolshed/api/inventory.py get_shared_item).
|
||||
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
|
||||
// owner is a full handle, looked up by owner not requester. See docs/implementation.md#owner-handle-scoped-routes.
|
||||
const item = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + owner + '/' + id + '/');
|
||||
if (item && item.files) {
|
||||
item.files.forEach(file => file.owner = item.owner)
|
||||
}
|
||||
|
|
@ -484,14 +423,14 @@ export default createStore({
|
|||
},
|
||||
async fetchFriendRequests({state, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/friendrequests/')
|
||||
return await servers.get(getters.signAuth, '/api/v1/friendrequests/')
|
||||
},
|
||||
async requestFriend({state, dispatch, getters}, {username}) {
|
||||
if (username in state.friends) {
|
||||
return true;
|
||||
}
|
||||
const home_servers = await dispatch('getHomeServers')
|
||||
const home_reply = await home_servers.post(getters.signAuth, '/api/friendrequests/', {
|
||||
const home_reply = await home_servers.post(getters.signAuth, '/api/v1/friendrequests/', {
|
||||
befriender: state.user,
|
||||
befriendee: username
|
||||
})
|
||||
|
|
@ -499,7 +438,7 @@ export default createStore({
|
|||
return false;
|
||||
|
||||
const befriendee_servers = await dispatch('getFriendServers', {username})
|
||||
const ext_reply = await befriendee_servers.post(getters.signAuth, '/api/friendrequests/', {
|
||||
const ext_reply = await befriendee_servers.post(getters.signAuth, '/api/v1/friendrequests/', {
|
||||
befriender: state.user,
|
||||
befriendee: username,
|
||||
befriender_key: nacl.to_hex(state.keypair.signPk),
|
||||
|
|
@ -509,11 +448,11 @@ export default createStore({
|
|||
},
|
||||
async acceptFriend({state, dispatch, getters}, {id, secret, befriender}) {
|
||||
const home_servers = await dispatch('getHomeServers')
|
||||
const home_reply = await home_servers.post(getters.signAuth, '/api/friends/', {
|
||||
const home_reply = await home_servers.post(getters.signAuth, '/api/v1/friends/', {
|
||||
friend_request_id: id, secret: secret
|
||||
})
|
||||
const ext_servers = await dispatch('getFriendServers', {username: befriender})
|
||||
const ext_reply = await ext_servers.post(getters.signAuth, '/api/friendrequests/', {
|
||||
const ext_reply = await ext_servers.post(getters.signAuth, '/api/v1/friendrequests/', {
|
||||
befriender: state.user,
|
||||
befriendee: befriender,
|
||||
befriender_key: nacl.to_hex(state.keypair.signPk),
|
||||
|
|
@ -523,49 +462,59 @@ export default createStore({
|
|||
},
|
||||
async declineFriend({state, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.delete(getters.signAuth, '/api/friendrequests/' + id + '/')
|
||||
return await servers.delete(getters.signAuth, '/api/v1/friendrequests/' + id + '/')
|
||||
},
|
||||
async dropFriend({state, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
||||
return await servers.delete(getters.signAuth, '/api/v1/friends/' + id + '/')
|
||||
},
|
||||
async fetchGroups({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/groups/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/groups/')
|
||||
commit('setGroups', data)
|
||||
return data
|
||||
},
|
||||
async fetchIdMap({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const idmap = await servers.get(getters.signAuth, '/api/idmap/')
|
||||
const idmap = await servers.get(getters.signAuth, '/api/v1/idmap/')
|
||||
commit('setIdMap', idmap)
|
||||
return idmap
|
||||
},
|
||||
// See docs/implementation.md#domain-qualified-short-id-resolution.
|
||||
async resolveShortId({dispatch, getters}, {domain, kind, ownerId, localId}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
return await servers.get(getters.signAuth, `/api/v1/resolve_short_id/${kind}/${ownerId}/${localId}/`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve short id (${kind}, ${ownerId}, ${localId}) on ${domain}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// handle is "#name@domain".
|
||||
async fetchGroup({dispatch, getters}, {handle}) {
|
||||
const {domain} = splitGroupHandle(handle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
return await servers.get(getters.signAuth, `/api/groups/${handle.slice(1)}/`)
|
||||
return await servers.get(getters.signAuth, `/api/v1/groups/${handle.slice(1)}/`)
|
||||
},
|
||||
async createGroup({dispatch, getters}, {name}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.post(getters.signAuth, '/api/groups/', {name})
|
||||
return await servers.post(getters.signAuth, '/api/v1/groups/', {name})
|
||||
},
|
||||
async removeGroupMember({dispatch, getters}, {groupHandle, identityId}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
return await servers.delete(
|
||||
getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/')
|
||||
getters.signAuth, '/api/v1/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/')
|
||||
},
|
||||
async fetchGroupInvites({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/groupinvites/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/groupinvites/')
|
||||
commit('setGroupInvites', data)
|
||||
return data
|
||||
},
|
||||
async fetchGroupMemberships({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/groupmemberships/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/groupmemberships/')
|
||||
commit('setGroupMemberships', data)
|
||||
return data
|
||||
},
|
||||
|
|
@ -573,12 +522,12 @@ export default createStore({
|
|||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
const group_reply = await group_servers.post(
|
||||
getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/invites/', {invitee})
|
||||
getters.signAuth, '/api/v1/groups/' + groupHandle.slice(1) + '/invites/', {invitee})
|
||||
if (!group_reply.secret) {
|
||||
return false
|
||||
}
|
||||
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
|
||||
await invitee_servers.post(getters.signAuth, '/api/groupinvites/', {
|
||||
await invitee_servers.post(getters.signAuth, '/api/v1/groupinvites/', {
|
||||
group: groupHandle,
|
||||
inviter: state.user,
|
||||
inviter_key: nacl.to_hex(state.keypair.signPk),
|
||||
|
|
@ -590,28 +539,32 @@ export default createStore({
|
|||
async acceptGroupInvite({state, dispatch, getters}, invite) {
|
||||
const group_domain = invite.group.split('@')[1]
|
||||
const group_servers = await dispatch('getFriendServers', {username: 'x@' + group_domain})
|
||||
await group_servers.post(getters.signAuth, '/api/group_invites/accept/', {
|
||||
await group_servers.post(getters.signAuth, '/api/v1/group_invites/accept/', {
|
||||
group: invite.group,
|
||||
invitee: state.user,
|
||||
invitee_key: nacl.to_hex(state.keypair.signPk),
|
||||
secret: invite.secret
|
||||
})
|
||||
const home_servers = await dispatch('getHomeServers')
|
||||
await home_servers.post(getters.signAuth, '/api/groupinvites/' + invite.id + '/accept/', {})
|
||||
await home_servers.post(getters.signAuth, '/api/v1/groupinvites/' + invite.id + '/accept/', {})
|
||||
return true
|
||||
},
|
||||
async declineGroupInvite({dispatch, getters}, invite) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
||||
return await servers.delete(getters.signAuth, '/api/v1/groupinvites/' + invite.id + '/')
|
||||
},
|
||||
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1))
|
||||
// Keyed by the full handle, not a bare id: a group pk is only unique within its own
|
||||
// backend's database, so two different domains could otherwise collide on the same
|
||||
// item_map key - the handle already carries the domain, so no separate namespacing is needed.
|
||||
commit('setInventoryItems', {url: '/group/' + groupHandle, items})
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + encodeHandleForUrl(groupHandle) + '/')
|
||||
commit('setInventoryItems', {url: '/' + groupHandle, items})
|
||||
return items
|
||||
},
|
||||
async fetchFriendInventoryItems({commit, dispatch, getters}, {friendHandle}) {
|
||||
const servers = await dispatch('getFriendServers', {username: friendHandle})
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + encodeHandleForUrl(friendHandle) + '/')
|
||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||
commit('setInventoryItems', {url: '/' + friendHandle, items})
|
||||
return items
|
||||
},
|
||||
async fetchFiles({state, commit, dispatch, getters}) {
|
||||
|
|
@ -619,7 +572,7 @@ export default createStore({
|
|||
return state.files
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/files/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/files/')
|
||||
data.map(file => file.owner = state.user)
|
||||
commit('setFiles', data)
|
||||
state.last_load.files = Date.now()
|
||||
|
|
@ -627,11 +580,11 @@ export default createStore({
|
|||
},
|
||||
async fetchFilesByItem({state, commit, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/item_files/' + id + '/')
|
||||
return await servers.get(getters.signAuth, '/api/v1/item_files/' + id + '/')
|
||||
},
|
||||
async pushFile({state, dispatch, getters}, {item_id, file}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', file)
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/item_files/' + item_id + '/', file)
|
||||
if (data.name) {
|
||||
data.owner = state.user
|
||||
state.files.push(data)
|
||||
|
|
@ -640,28 +593,28 @@ export default createStore({
|
|||
},
|
||||
async deleteFile({state, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/files/' + id + '/')
|
||||
await servers.delete(getters.signAuth, '/api/v1/files/' + id + '/')
|
||||
state.files = state.files.filter(file => file.id !== id)
|
||||
},
|
||||
async deleteItemFile({state, dispatch, getters}, {item_id, file_id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/')
|
||||
await servers.delete(getters.signAuth, '/api/v1/item_files/' + item_id + '/' + file_id + '/')
|
||||
state.files = state.files.filter(file => file.id !== file_id)
|
||||
},
|
||||
async stageFile({state, dispatch, getters}, {lifetime_id, file}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file)
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/staged_files/' + lifetime_id + '/', file)
|
||||
if (data.hash) {
|
||||
return data.hash
|
||||
}
|
||||
},
|
||||
async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/')
|
||||
await servers.delete(getters.signAuth, '/api/v1/staged_files/' + lifetime_id + '/' + file_hash + '/')
|
||||
},
|
||||
async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash})
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/item_files/' + item_id + '/', {file_hash})
|
||||
if (data.name) {
|
||||
data.owner = state.user
|
||||
state.files.push(data)
|
||||
|
|
@ -673,7 +626,7 @@ export default createStore({
|
|||
return state.tags
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/tags/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/tags/')
|
||||
commit('setTags', data)
|
||||
state.last_load.tags = Date.now()
|
||||
return data
|
||||
|
|
@ -683,7 +636,7 @@ export default createStore({
|
|||
return state.properties
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/properties/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/properties/')
|
||||
commit('setProperties', data)
|
||||
state.last_load.properties = Date.now()
|
||||
return data
|
||||
|
|
@ -693,7 +646,7 @@ export default createStore({
|
|||
return state.categories
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/categories/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/categories/')
|
||||
commit('setCategories', data)
|
||||
state.last_load.categories = Date.now()
|
||||
return data
|
||||
|
|
@ -703,7 +656,7 @@ export default createStore({
|
|||
return state.availability_policies
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/availability_policies/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/availability_policies/')
|
||||
commit('setAvailabilityPolicies', data.map(policy => ({slug: policy[0], text: policy[1]})))
|
||||
state.last_load.availability_policies = Date.now()
|
||||
return data
|
||||
|
|
@ -713,15 +666,17 @@ export default createStore({
|
|||
return state.storage_locations
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/storage_locations/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/storage_locations/' + state.user + '/')
|
||||
commit('setStorageLocations', data)
|
||||
state.last_load.storage_locations = Date.now()
|
||||
return data
|
||||
},async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
|
||||
},
|
||||
async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
const locations = await servers.get(getters.signAuth, '/api/storage_locations/?group=' + groupHandle.slice(1))
|
||||
commit('setLocationsForKey', {url: '/group/' + groupHandle, locations})
|
||||
const locations = await servers.get(
|
||||
getters.signAuth, '/api/v1/storage_locations/' + encodeHandleForUrl(groupHandle) + '/')
|
||||
commit('setLocationsForKey', {url: '/' + groupHandle, locations})
|
||||
return locations
|
||||
},
|
||||
async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) {
|
||||
|
|
@ -741,7 +696,10 @@ export default createStore({
|
|||
const servers = location.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/')
|
||||
// Path is scoped by the owner's handle, not the location's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
|
||||
const path = '/api/v1/storage_locations/' + encodeHandleForUrl(location.owner_group || location.owner) + '/' + location.id + '/'
|
||||
const ret = await servers.delete(getters.signAuth, path)
|
||||
state.last_load.storage_locations = 0
|
||||
dispatch('fetchStorageLocations')
|
||||
return ret
|
||||
},
|
||||
|
|
@ -751,9 +709,11 @@ export default createStore({
|
|||
const servers = location.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const data = {...location, owner_group: location.owner_group ? location.owner_group.slice(1) : null}
|
||||
const owner = location.owner_group ? encodeHandleForUrl(location.owner_group) : state.user
|
||||
const data = {...location}
|
||||
delete data.owner_group
|
||||
if (data.parent === '') data.parent = null
|
||||
const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data)
|
||||
const reply = await servers.post(getters.signAuth, '/api/v1/storage_locations/' + owner + '/', data)
|
||||
state.last_load.storage_locations = 0
|
||||
return reply
|
||||
},
|
||||
|
|
@ -763,7 +723,9 @@ export default createStore({
|
|||
: await dispatch('getHomeServers')
|
||||
const data = {...location}
|
||||
if (data.parent === '') data.parent = null
|
||||
const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data)
|
||||
const path = '/api/v1/storage_locations/' + encodeHandleForUrl(location.owner_group || location.owner) + '/' + location.id + '/'
|
||||
const reply = await servers.patch(getters.signAuth, path, data)
|
||||
state.last_load.storage_locations = 0
|
||||
dispatch('fetchStorageLocations')
|
||||
return reply
|
||||
},
|
||||
|
|
@ -777,7 +739,7 @@ export default createStore({
|
|||
return state.info
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/info/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/info/')
|
||||
commit('setTags', data.tags)
|
||||
commit('setProperties', data.properties)
|
||||
commit('setCategories', data.categories)
|
||||
|
|
@ -800,7 +762,7 @@ export default createStore({
|
|||
return state.active_workflows
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/workflows/')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/workflows/')
|
||||
const workflows = data.map(deserializeWorkflowPayload)
|
||||
commit('setActiveWorkflows', workflows)
|
||||
state.last_load.active_workflows = Date.now()
|
||||
|
|
@ -810,19 +772,19 @@ export default createStore({
|
|||
const servers = await dispatch('getHomeServers')
|
||||
// The backend stores payload as an opaque string; the frontend (de)serializes it.
|
||||
// See docs/implementation.md#workflow-payload-is-an-opaque-string.
|
||||
const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData))
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/workflows/', serializeWorkflowPayload(workflowData))
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return deserializeWorkflowPayload(data)
|
||||
},
|
||||
async updateWorkflow({state, commit, dispatch, getters}, workflow) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow))
|
||||
const data = await servers.patch(getters.signAuth, '/api/v1/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow))
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return deserializeWorkflowPayload(data)
|
||||
},
|
||||
async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', {
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/workflows/' + workflowId + '/update_step/', {
|
||||
current_step: currentStep,
|
||||
payload: JSON.stringify(payload)
|
||||
})
|
||||
|
|
@ -831,7 +793,7 @@ export default createStore({
|
|||
},
|
||||
async deleteWorkflow({state, commit, dispatch, getters}, workflowId) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/workflows/' + workflowId + '/')
|
||||
await servers.delete(getters.signAuth, '/api/v1/workflows/' + workflowId + '/')
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return true
|
||||
}
|
||||
|
|
@ -870,16 +832,16 @@ export default createStore({
|
|||
inventory_items(state) {
|
||||
return state.item_map['/'] || []
|
||||
},
|
||||
groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [],
|
||||
groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [],
|
||||
groupInventoryItems: (state) => (groupHandle) => state.item_map['/' + groupHandle] || [],
|
||||
groupStorageLocations: (state) => (groupHandle) => state.location_map['/' + groupHandle] || [],
|
||||
identityIdByHandle(state) {
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.handle, i.id]))
|
||||
},
|
||||
groupIdByHandle(state) {
|
||||
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
|
||||
},
|
||||
identityHandleById(state) {
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username]))
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.handle]))
|
||||
},
|
||||
groupHandleById(state) {
|
||||
return Object.fromEntries(state.idmap.groups.map(g => [g.id, g.handle]))
|
||||
|
|
@ -925,19 +887,11 @@ export default createStore({
|
|||
time: Date.now() - 1000 * 60 * 60 * 24
|
||||
}]
|
||||
},
|
||||
getPreference: (state) => (key, fallbackDefault = null) => {
|
||||
if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) {
|
||||
return state.devicePreferences[key]
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) {
|
||||
return state.accountPreferences[key]
|
||||
}
|
||||
const definition = state.preferenceDefinitions.find((pref) => pref.key === key)
|
||||
if (definition) {
|
||||
return definition.default
|
||||
}
|
||||
return fallbackDefault
|
||||
},
|
||||
getPreference: (state) => (key, fallbackDefault = null) => resolvePreference(key, {
|
||||
devicePreferences: state.devicePreferences,
|
||||
accountPreferences: state.accountPreferences,
|
||||
definitions: state.preferenceDefinitions,
|
||||
}, fallbackDefault),
|
||||
/** Extracts the name from a handle like "git:tools#tag:drill"; returns non-handles unchanged. */
|
||||
getNameFromHandle: () => (handle) => {
|
||||
if (typeof handle !== 'string') {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import {encodeShortId, decodeShortId, deserializeShortId, serializeShortId} from '../short-id.js'
|
||||
import {
|
||||
encodeShortId, decodeShortId, deserializeShortId, serializeShortId,
|
||||
encodeDomainQualifiedShortId, decodeDomainQualifiedShortId, isDomainQualifiedShortId
|
||||
} from '../short-id.js'
|
||||
|
||||
test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
||||
const token = encodeShortId([0, 7, 42])
|
||||
|
|
@ -108,3 +111,34 @@ test('rejects a missing field', () => {
|
|||
test('rejects a negative field value', () => {
|
||||
expect(() => encodeShortId([0, -1, 42])).toThrow()
|
||||
})
|
||||
|
||||
test('encodes a Domain-Qualified Short ID as "<domain>:<token>"', () => {
|
||||
expect(encodeDomainQualifiedShortId('toolsheddomain.tld', [0, 7, 42])).toBe('toolsheddomain.tld:~DyU')
|
||||
})
|
||||
|
||||
test('decodes a Domain-Qualified Short ID back into its domain and ints', () => {
|
||||
const decoded = decodeDomainQualifiedShortId('toolsheddomain.tld:~DyU')
|
||||
expect(decoded.domain).toBe('toolsheddomain.tld')
|
||||
expect(decoded.token).toBe('~DyU')
|
||||
expect(decoded.ints).toEqual([0, 7, 42])
|
||||
})
|
||||
|
||||
test('round-trips a Domain-Qualified Short ID through encode/decode', () => {
|
||||
const named = {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}
|
||||
const encoded = encodeDomainQualifiedShortId('example.com', serializeShortId(named))
|
||||
const {domain, ints} = decodeDomainQualifiedShortId(encoded)
|
||||
expect(domain).toBe('example.com')
|
||||
expect(deserializeShortId(ints)).toEqual(named)
|
||||
})
|
||||
|
||||
test('rejects a Domain-Qualified Short ID missing the "~" after the domain', () => {
|
||||
expect(() => decodeDomainQualifiedShortId('toolsheddomain.tldDyU')).toThrow()
|
||||
expect(() => decodeDomainQualifiedShortId('~DyU')).toThrow()
|
||||
})
|
||||
|
||||
test('isDomainQualifiedShortId tells a "<domain>:~token" apart from a bare token', () => {
|
||||
expect(isDomainQualifiedShortId('toolsheddomain.tld:~DyU')).toBe(true)
|
||||
expect(isDomainQualifiedShortId('~DyU')).toBe(false)
|
||||
expect(isDomainQualifiedShortId('alice@example.com:i42')).toBe(false)
|
||||
expect(isDomainQualifiedShortId('not a short id at all')).toBe(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -142,8 +142,8 @@ export default {
|
|||
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});
|
||||
this.friends[friend.handle] = {...friend, server: server}
|
||||
this.fetchFriendProfile({username: friend.handle});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -175,7 +175,7 @@ export default {
|
|||
tryDropFriend(friend) {
|
||||
this.dropFriend(friend).then((ok) => {
|
||||
if (ok) {
|
||||
delete this.friends[friend.username]
|
||||
delete this.friends[friend.handle]
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">{{ group ? group.handle : '' }}</h1>
|
||||
<div class="row" v-if="group">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Members</h5>
|
||||
|
|
@ -50,136 +50,6 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Group inventory</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>
|
||||
<table class="table table-striped" v-if="layout === 'table'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Name</th>
|
||||
<th style="width:25%">Availability Policy</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td>
|
||||
<router-link :to="itemRoute(item)">{{ item.name }}</router-link>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||
<td class="table-action">
|
||||
<router-link :to="`${itemRoute(item)}/edit`">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<a :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</td>
|
||||
</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 items" :key="item.id">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-0">
|
||||
<router-link :to="itemRoute(item)">
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</h5>
|
||||
<div class="card-text text-black-50">
|
||||
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
|
||||
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger btn-sm" @click="tryDeleteItem(item)">
|
||||
Delete
|
||||
</button>
|
||||
<router-link :to="`${itemRoute(item)}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
</router-link>
|
||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<button class="btn" @click="fetchItems">Refresh</button>
|
||||
<router-link :to="`/inventory/new?group=${encodeHandleForUrl(group.handle)}`"
|
||||
class="btn btn-primary">Add
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Group storage locations</h5>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Name</th>
|
||||
<th style="width:25%">Path</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Category</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="location in storageLocations" :key="location.id">
|
||||
<td>
|
||||
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<span class="text-muted">{{ location.path }}</span>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
|
||||
<span class="text-muted" v-else>-</span>
|
||||
</td>
|
||||
<td class="table-action">
|
||||
<router-link :to="`${locationRoute(location)}/edit`">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link v-if="locationPrintLinkFor(location)" :to="locationPrintLinkFor(location)">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<button class="btn" @click="fetchLocations">Refresh</button>
|
||||
<router-link :to="`/storage-locations/new?group=${encodeHandleForUrl(group.handle)}`"
|
||||
class="btn btn-primary">Add
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -187,11 +57,11 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import {mapActions} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
import {decodeHandleFromUrl, encodeHandleForUrl} from "@/router";
|
||||
import {decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
name: 'GroupDetail',
|
||||
|
|
@ -209,7 +79,6 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
group: null,
|
||||
layout: "grid",
|
||||
show_invite: false,
|
||||
invitee: ""
|
||||
}
|
||||
|
|
@ -217,19 +86,10 @@ export default {
|
|||
computed: {
|
||||
decodedHandle() {
|
||||
return decodeHandleFromUrl(this.handle)
|
||||
},
|
||||
items() {
|
||||
return this.group ? this.groupInventoryItems(this.group.handle) : []
|
||||
},
|
||||
storageLocations() {
|
||||
return this.group ? this.groupStorageLocations(this.group.handle) : []
|
||||
},
|
||||
...mapGetters(['groupInventoryItems', 'groupStorageLocations'])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
encodeHandleForUrl,
|
||||
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
||||
'deleteInventoryItem', 'fetchGroupStorageLocations', 'deleteStorageLocation']),
|
||||
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup']),
|
||||
// Loads by handle regardless of which backend actually hosts the group - see
|
||||
// store.js's fetchGroup.
|
||||
refresh() {
|
||||
|
|
@ -237,33 +97,6 @@ export default {
|
|||
this.group = group
|
||||
})
|
||||
},
|
||||
fetchItems() {
|
||||
if (this.group) {
|
||||
this.fetchGroupInventoryItems({groupHandle: this.group.handle})
|
||||
}
|
||||
},
|
||||
itemRoute(item) {
|
||||
return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}`
|
||||
},
|
||||
printLinkFor(item) {
|
||||
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
|
||||
},
|
||||
fetchLocations() {
|
||||
if (this.group) {
|
||||
this.fetchGroupStorageLocations({groupHandle: this.group.handle})
|
||||
}
|
||||
},
|
||||
locationRoute(location) {
|
||||
return `/storage-locations/${encodeHandleForUrl(this.group.handle)}/${location.id}`
|
||||
},
|
||||
locationPrintLinkFor(location) {
|
||||
return {path: '/print', query: {kind: 'storage-location', userHandle: this.group.handle, location: location.id}}
|
||||
},
|
||||
tryDeleteLocation(location) {
|
||||
this.deleteStorageLocation(location).then(() => {
|
||||
this.fetchLocations()
|
||||
})
|
||||
},
|
||||
tryInvite() {
|
||||
this.inviteToGroup({groupHandle: this.group.handle, invitee: this.invitee}).then((ok) => {
|
||||
if (ok) {
|
||||
|
|
@ -279,18 +112,10 @@ export default {
|
|||
this.refresh()
|
||||
}).catch(() => {
|
||||
})
|
||||
},
|
||||
tryDeleteItem(item) {
|
||||
this.deleteInventoryItem(item).then(() => {
|
||||
this.fetchItems()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refresh().then(() => {
|
||||
this.fetchItems()
|
||||
this.fetchLocations()
|
||||
})
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,27 @@
|
|||
<BaseLayout>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Inventory Own & Friends"</h1>
|
||||
<h1 class="h3 mb-3">Inventory</h1>
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">{{ user }}'s Inventory</h5>
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
||||
<h5 class="card-title mb-0">{{ selectedOwner }}'s Inventory</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<!--label for="ownerSelect" class="visually-hidden">Viewing inventory for</label-->
|
||||
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
|
||||
v-model="selectedOwner">
|
||||
<option :value="user">{{ user }} (you)</option>
|
||||
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
|
||||
{{ group.handle }}
|
||||
</option>
|
||||
<option v-for="friend in friends" :value="friend.handle" :key="friend.handle">
|
||||
{{ friend.handle }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="btn-group">
|
||||
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
|
||||
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
|
||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
|
|
@ -15,6 +30,8 @@
|
|||
<b-icon-grid></b-icon-grid>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-striped" v-if="layout === 'table'">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -25,7 +42,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in inventory_items" :key="item.id">
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td>
|
||||
<router-link :to="itemRoute(item)">{{ item.name }}</router-link>
|
||||
</td>
|
||||
|
|
@ -34,10 +51,10 @@
|
|||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||
<td class="table-action">
|
||||
<router-link :to="`${itemRoute(item)}/edit`">
|
||||
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<a :href="`${itemRoute(item)}/delete`" @click.prevent="deleteInventoryItem(item)">
|
||||
<a v-if="canEdit" :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
||||
|
|
@ -52,7 +69,7 @@
|
|||
</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 inventory_items"
|
||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items"
|
||||
:key="item.id">
|
||||
<div class="card">
|
||||
<img :src="item.image" class="" :alt="item.image">
|
||||
|
|
@ -70,10 +87,10 @@
|
|||
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger btn-sm"
|
||||
@click="deleteInventoryItem(item)">Delete
|
||||
<button v-if="canEdit" class="btn btn-danger btn-sm"
|
||||
@click="tryDeleteItem(item)">Delete
|
||||
</button>
|
||||
<router-link :to="`${itemRoute(item)}/edit`"
|
||||
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
</router-link>
|
||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
|
||||
|
|
@ -92,10 +109,6 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card">
|
||||
<button class="btn" @click="fetchInventoryItems">Refresh</button>
|
||||
<router-link to="/inventory/new" class="btn btn-primary">Add</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -108,13 +121,15 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||
import {shortenedRoute, encodeHandleForUrl} from "@/router";
|
||||
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
name: "Inventory",
|
||||
data() {
|
||||
return {
|
||||
layout: "grid",
|
||||
props: {
|
||||
// Matches /inventory/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
owner: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
components: {
|
||||
|
|
@ -123,15 +138,67 @@ export default {
|
|||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["inventory_items", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
|
||||
...mapState(["user", "storage_locations"]),
|
||||
...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle", "getPreference"]),
|
||||
...mapState(["user", "storage_locations", "groups", "groupMemberships", "friends"]),
|
||||
// Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences.
|
||||
layout: {
|
||||
get() {
|
||||
return this.getPreference('ui.remembered.inventory_view_mode', 'grid')
|
||||
},
|
||||
set(value) {
|
||||
this.setDevicePreference({key: 'ui.remembered.inventory_view_mode', value})
|
||||
}
|
||||
},
|
||||
// Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe.
|
||||
ownerGroups() {
|
||||
const hostedHandles = new Set(this.groups.map(group => group.handle))
|
||||
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle))
|
||||
return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
|
||||
},
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
selectedOwner: {
|
||||
get() {
|
||||
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
|
||||
},
|
||||
set(value) {
|
||||
const path = value === this.user ? '/inventory' : `/inventory/${encodeHandleForUrl(value)}`
|
||||
this.$router.replace(path)
|
||||
}
|
||||
},
|
||||
isGroupSelected() {
|
||||
return !!this.selectedOwner && this.selectedOwner.startsWith('#')
|
||||
},
|
||||
// Friend items are browse-only - inventory.py's perform_create/update/destroy reject writes from anyone but the owner/a fellow group member.
|
||||
canEdit() {
|
||||
return this.selectedOwner === this.user || this.isGroupSelected
|
||||
},
|
||||
items() {
|
||||
// item_map is keyed by owner handle regardless of group vs friend - see store.js's groupInventoryItems.
|
||||
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
|
||||
},
|
||||
addItemRoute() {
|
||||
return this.selectedOwner === this.user
|
||||
? '/inventory/new'
|
||||
: `/inventory/new/${encodeHandleForUrl(this.selectedOwner)}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
|
||||
// Always the viewer's own items (fetchInventoryItems has no group filter); the owner
|
||||
// handle is always included so /inventory/:handle/:id has exactly one shape.
|
||||
...mapActions(["fetchInventoryItems", "fetchGroupInventoryItems", "fetchFriendInventoryItems",
|
||||
"deleteInventoryItem", "fetchStorageLocations", "fetchIdMap", "fetchGroups",
|
||||
"fetchGroupMemberships", "fetchFriends", "setDevicePreference"]),
|
||||
fetchItemsForOwner() {
|
||||
if (this.selectedOwner === this.user) return this.fetchInventoryItems()
|
||||
if (this.isGroupSelected) return this.fetchGroupInventoryItems({groupHandle: this.selectedOwner})
|
||||
return this.fetchFriendInventoryItems({friendHandle: this.selectedOwner})
|
||||
},
|
||||
tryDeleteItem(item) {
|
||||
this.deleteInventoryItem(item).then(() => {
|
||||
this.fetchItemsForOwner()
|
||||
})
|
||||
},
|
||||
// Derived from the item's own owner, not the current selection, so the link stays correct if the dropdown changes underneath it.
|
||||
itemRoute(item) {
|
||||
return `/inventory/${encodeHandleForUrl(this.user)}/${item.id}`
|
||||
return `/inventory/${encodeHandleForUrl(item.owner_group || item.owner)}/${item.id}`
|
||||
},
|
||||
only_images(files) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
|
|
@ -153,13 +220,21 @@ export default {
|
|||
printLinkFor(item) {
|
||||
const userHandle = item.owner || item.owner_group
|
||||
if (!userHandle) return null
|
||||
return {path: '/print', query: {kind: 'item', userHandle, item: item.id}}
|
||||
return {path: '/print', query: {kind: 'item', userHandle, id: item.id}}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
owner() {
|
||||
this.fetchItemsForOwner()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInventoryItems()
|
||||
this.fetchItemsForOwner()
|
||||
await this.fetchStorageLocations()
|
||||
await this.fetchIdMap()
|
||||
await this.fetchGroups()
|
||||
await this.fetchGroupMemberships()
|
||||
await this.fetchFriends()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
Delete
|
||||
</button>
|
||||
<button v-if="canPrint" class="btn btn-secondary"
|
||||
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, item: id}})">
|
||||
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id: id}})">
|
||||
<b-icon-printer></b-icon-printer>
|
||||
Print label
|
||||
</button>
|
||||
|
|
@ -98,16 +98,12 @@ export default {
|
|||
decodedHandle() {
|
||||
return decodeHandleFromUrl(this.handle)
|
||||
},
|
||||
// Am I a member of this group, hosted here (groupIdByHandle, from idmap) or elsewhere
|
||||
// (groupMemberships - see GroupMembership/docs/design-in-progress/groups-mvp.md)? Either
|
||||
// way the backend actually hosting the group is what authorizes the write - this is just
|
||||
// enough of a client-side hint to show/hide the buttons for it.
|
||||
// Client-side hint only; the backend hosting the group is what actually authorizes the write. See docs/implementation.md#group-membership-is-recorded-on-both-sides-like-friendship.
|
||||
isGroupMember() {
|
||||
return this.decodedHandle in this.groupIdByHandle
|
||||
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
|
||||
},
|
||||
// Edit/Delete require actual authorization (own item or member group), not just view
|
||||
// access - get_shared_item's friends_or_self() lets a friend view but never act.
|
||||
// Edit/Delete require actual authorization; get_queryset()'s friends_or_self() lets a friend view but never act. See docs/implementation.md#owner-handle-scoped-routes.
|
||||
canEdit() {
|
||||
return this.decodedHandle === this.user || this.isGroupMember
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ export default {
|
|||
CombinedFileField,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
// Matches /inventory/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
group: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
item: {
|
||||
|
|
@ -133,8 +140,8 @@ export default {
|
|||
await this.fetchStorageLocations();
|
||||
await this.fetchGroups();
|
||||
await this.fetchGroupMemberships();
|
||||
if (this.$route.query.group) {
|
||||
this.item.owner_group = decodeHandleFromUrl(this.$route.query.group)
|
||||
if (this.group) {
|
||||
this.item.owner_group = decodeHandleFromUrl(this.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export default {
|
|||
const unreachable = new NeighborsCache();
|
||||
const servers = await this.lookupServer({username: this.username}).then(servers => new ServerSet(servers, unreachable))
|
||||
const auth = createSignAuth(this.username, this.keypair.signSk);
|
||||
const reply = await servers.getRaw(auth, '/api/friends/');
|
||||
const reply = await servers.getRaw(auth, '/api/v1/friends/');
|
||||
if (reply.status === 200) {
|
||||
this.setUserFromIdentityRecord({username: this.username, keypair: this.keypair});
|
||||
router.push({path: '/'});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@
|
|||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Print a label</h1>
|
||||
|
||||
<!-- Left: what to print. Right: how it looks and how it prints, kept in view via .print-sticky. -->
|
||||
<div class="row">
|
||||
|
||||
<div class="col-lg-7 mb-4">
|
||||
<div class="print-sticky">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Preview</h5>
|
||||
<small class="text-muted">{{ previewTape.mediaWidthMm }} mm tape{{
|
||||
textSizesSummary
|
||||
}}<span v-if="!tape"> (example)</span></small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
|
||||
<div v-if="warnings.length" class="alert alert-warning" role="alert">
|
||||
|
|
@ -11,117 +25,16 @@
|
|||
</div>
|
||||
|
||||
<div v-if="!usbSupported" class="alert alert-warning">
|
||||
This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or
|
||||
Opera over https:// (or http://localhost) to print straight from here, or use one of the
|
||||
alternatives below.
|
||||
This browser can't pair with USB label printers directly. Open this page in
|
||||
Chrome, Edge or
|
||||
Opera over https:// for that - you can still build and download labels
|
||||
as PNGs below.
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Label content</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
|
||||
<label class="form-label">{{ varLabel(v) }}</label>
|
||||
<textarea v-if="v === 'text'" class="form-control" rows="3"
|
||||
v-model="varValues[v]"
|
||||
placeholder="https://example.com/…"></textarea>
|
||||
<input v-else type="text" class="form-control" v-model="varValues[v]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Calculated</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-for="v in derivedVars" :key="v" class="mb-2">
|
||||
<div class="small text-muted">{{ varLabel(v) }}</div>
|
||||
<div class="text-break">{{ fields[v] || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!usbSupported" class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Label preview</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="label-preview mb-3" v-show="fallbackReady">
|
||||
<canvas ref="fallbackCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Orientation</label>
|
||||
<div class="btn-group d-block" role="group">
|
||||
<input type="radio" class="btn-check" id="orientation-along-fallback"
|
||||
autocomplete="off" value="along" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-along-fallback">
|
||||
Along tape
|
||||
</label>
|
||||
<input type="radio" class="btn-check" id="orientation-across-fallback"
|
||||
autocomplete="off" value="across" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-across-fallback">
|
||||
Across tape
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
|
||||
<b-icon-download class="me-1"></b-icon-download>
|
||||
Download label as PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Print from the command line</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Download the PNG above, then send it to a label printer with its CLI tool.
|
||||
</p>
|
||||
<p class="mb-1"><strong>Brother QL-series</strong></p>
|
||||
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
|
||||
<p class="mb-1"><strong>Niimbot</strong></p>
|
||||
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="row">
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Label</h5>
|
||||
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{
|
||||
textSizesSummary
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p v-if="!tape" class="text-muted">
|
||||
Connect a printer to preview and print a label.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="preview-row mb-3" v-show="labelBitmap">
|
||||
<div class="ruler-v">
|
||||
<div class="ruler-v-corner"></div>
|
||||
<div class="ruler ruler-v-ticks"
|
||||
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||
:style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
|
||||
class="tick" :class="{'tick-major': t.major}"
|
||||
:style="{top: t.pos + 'px'}"></span>
|
||||
|
|
@ -142,7 +55,7 @@
|
|||
</div>
|
||||
<!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
|
||||
<div class="tape-full"
|
||||
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||
:style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||
<div class="label-preview">
|
||||
<canvas ref="labelCanvas"></canvas>
|
||||
</div>
|
||||
|
|
@ -153,76 +66,121 @@
|
|||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Orientation</label>
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" id="orientation-along"
|
||||
autocomplete="off" value="along" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-along">
|
||||
Along tape
|
||||
<div class="btn-group btn-group-toggle" role="group">
|
||||
<label class="btn btn-outline-secondary"
|
||||
:class="{active: orientation === 'along'}">
|
||||
<input type="radio" autocomplete="off" value="along"
|
||||
v-model="orientation"> Along tape
|
||||
</label>
|
||||
<input type="radio" class="btn-check" id="orientation-across"
|
||||
autocomplete="off" value="across" v-model="orientation">
|
||||
<label class="btn btn-outline-secondary" for="orientation-across">
|
||||
Across tape
|
||||
<label class="btn btn-outline-secondary"
|
||||
:class="{active: orientation === 'across'}">
|
||||
<input type="radio" autocomplete="off" value="across"
|
||||
v-model="orientation"> Across tape
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Copies</label>
|
||||
<input type="number" class="form-control copies-input"
|
||||
v-model.number="copies" min="1" max="20">
|
||||
v-model.number="copies" min="1" max="20" :disabled="!connected">
|
||||
</div>
|
||||
<div class="col-auto" v-if="chainSupported">
|
||||
<label class="form-label d-block">Chain mode</label>
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input"
|
||||
id="chain-mode" v-model="chainMode">
|
||||
<label class="custom-control-label" for="chain-mode"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto" v-if="!connected">
|
||||
<label class="form-label">Tape height (px)</label>
|
||||
<input type="number" class="form-control copies-input"
|
||||
v-model.number="simulatedHeightPx" min="8" max="1000">
|
||||
</div>
|
||||
<div class="col-auto ms-auto">
|
||||
<button class="btn btn-primary" :disabled="!canPrint || busy"
|
||||
@click="print">
|
||||
<b-icon-printer class="me-1"></b-icon-printer>
|
||||
Print
|
||||
<button class="btn btn-primary" :disabled="!canAct"
|
||||
@click="connected ? print() : downloadPng()">
|
||||
<b-icon-printer v-if="connected" class="me-1"></b-icon-printer>
|
||||
<b-icon-download v-else class="me-1"></b-icon-download>
|
||||
{{ connected ? "Print" : "Download PNG" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Printer</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p v-if="!devices.length" class="text-muted">
|
||||
No label printer paired yet.
|
||||
</p>
|
||||
<ul v-else class="list-group mb-3">
|
||||
<li v-for="d in deviceRows" :key="d.index"
|
||||
class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div>{{ d.name }}</div>
|
||||
<small v-if="d.isConnected" class="text-success">connected</small>
|
||||
<small v-else-if="!d.supported" class="text-muted">{{ d.reason }}</small>
|
||||
</div>
|
||||
<button v-if="d.isConnected" class="btn btn-sm btn-outline-secondary"
|
||||
:disabled="busy" @click="disconnect()">
|
||||
Disconnect
|
||||
</button>
|
||||
<button v-else-if="d.supported" class="btn btn-sm btn-outline-primary"
|
||||
:disabled="busy" @click="connect(d.index)">
|
||||
Connect
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" :disabled="busy || !blobReady" @click="requestPrinter">
|
||||
|
||||
<div
|
||||
class="printer-bar d-flex flex-wrap align-items-center chip-row mt-3 pt-3 border-top">
|
||||
<span v-for="d in deviceRows" :key="d.index" class="printer-chip"
|
||||
:class="{'printer-chip-connected': d.isConnected, 'printer-chip-disabled': !d.supported}"
|
||||
:title="!d.supported ? d.reason : ''">
|
||||
{{ d.name }}
|
||||
<button v-if="d.isConnected && !d.isPngExport"
|
||||
class="btn btn-link btn-sm p-0 ms-1"
|
||||
:disabled="busy" @click="disconnect()">disconnect</button>
|
||||
<button v-else-if="!d.isConnected && d.supported"
|
||||
class="btn btn-link btn-sm p-0 ms-1"
|
||||
:disabled="busy" @click="connect(d.index)">connect</button>
|
||||
</span>
|
||||
<button v-if="usbSupported" class="btn btn-sm btn-outline-primary ms-auto"
|
||||
:disabled="busy || !blobReady" @click="requestPrinter">
|
||||
<b-icon-plug class="me-1"></b-icon-plug>
|
||||
Pair new printer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="cli-details mt-3 pt-3 border-top">
|
||||
<summary class="small text-muted">Print from the command line instead</summary>
|
||||
<div class="mt-2">
|
||||
<p class="mb-1"><strong>Brother QL-series</strong></p>
|
||||
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
|
||||
<p class="mb-1"><strong>Niimbot</strong></p>
|
||||
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 mb-4">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Content</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div :class="v === 'text' ? 'col-12' : 'col-sm-6'" v-for="v in baseVars" :key="v">
|
||||
<label class="form-label">{{ varLabel(v) }}</label>
|
||||
<textarea v-if="v === 'text'" class="form-control" rows="3"
|
||||
v-model="varValues[v]"
|
||||
placeholder="https://example.com/…"></textarea>
|
||||
<select v-else-if="v === 'kind'" class="form-control"
|
||||
v-model="varValues[v]">
|
||||
<option value="">–</option>
|
||||
<option v-for="k in contentKinds" :key="k.id" :value="k.id">{{
|
||||
k.label
|
||||
}}</option>
|
||||
</select>
|
||||
<input v-else type="text" class="form-control" v-model="varValues[v]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calculated-fields mt-3 pt-3 border-top">
|
||||
<div class="small text-uppercase text-muted mb-2">Calculated</div>
|
||||
<div class="d-flex flex-wrap chip-row">
|
||||
<div v-for="v in derivedVars" :key="v" class="calculated-field">
|
||||
<div class="small text-muted">{{ varLabel(v) }}</div>
|
||||
<div class="text-break">{{ fields[v] || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
|
||||
:recent-template-ids="recentTemplateIds"
|
||||
<div class="col-lg-12 mb-4">
|
||||
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="previewTape"
|
||||
:recent-template-ids="recentTemplateIds" class="mb-3"
|
||||
@input="selectedTemplate = $event"></label-layout-preview>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -234,7 +192,7 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {markRaw, nextTick} from "vue";
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||
import printerManager from "@/printerManager.js";
|
||||
|
|
@ -243,20 +201,22 @@ import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/web
|
|||
import {
|
||||
tapeFromStatus,
|
||||
drawLabel,
|
||||
drawFallbackLabel,
|
||||
buildLabelContent,
|
||||
buildLabelFields,
|
||||
preloadQrEncoder,
|
||||
preloadPixelFontRenderer
|
||||
preloadPixelFontRenderer,
|
||||
withDerivedVars,
|
||||
templateContent
|
||||
} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
|
||||
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, CONTENT_KINDS, CONTENT_KINDS_BY_ID} from "@/label-layouts.js";
|
||||
import {shortenedRoute} from "@/router";
|
||||
|
||||
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
|
||||
// docs/implementation.md#calculated-short-link-fields.
|
||||
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See docs/implementation.md#calculated-short-link-fields.
|
||||
const SHORT_URL_VAR = "shortUrl";
|
||||
// See docs/implementation.md#calculated-short-link-fields.
|
||||
const SHORT_ID_VAR = "shortId";
|
||||
// See docs/implementation.md#calculated-short-link-fields.
|
||||
const DOMAIN_SHORT_ID_VAR = "domainShortId";
|
||||
|
||||
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
|
||||
const BLOB_URL = "/vendor/libweblabel.js";
|
||||
|
|
@ -268,6 +228,14 @@ const MAX_RECENT_TEMPLATES = 4;
|
|||
|
||||
const MAX_ZOOM = 4; /* never magnify the preview more than this */
|
||||
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
|
||||
// A plausible printer profile for the preview/ruler when the PNG Export "printer" (see deviceRows) is the active one; its height is user-adjustable (see simulatedHeightPx), everything else about it isn't.
|
||||
const SIMULATED_DPI = 180;
|
||||
const SIMULATED_DEFAULT_HEIGHT_PX = 72; /* ~9mm at SIMULATED_DPI */
|
||||
const SIMULATED_MIN_HEIGHT_PX = 8;
|
||||
const SIMULATED_MAX_HEIGHT_PX = 1000;
|
||||
const SIMULATED_LEAD_PX = Math.round(2 * SIMULATED_DPI / 25.4); /* blank runway before content, same idea as tapeFromStatus's marginsMm */
|
||||
// deviceRows' sentinel index for the always-present PNG Export row - never a real index into `devices`.
|
||||
const PNG_EXPORT_INDEX = -1;
|
||||
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
|
||||
const RULER_TIERS = [
|
||||
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
|
||||
|
|
@ -297,7 +265,8 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
usbSupported: true,
|
||||
// Whether this browser can pair with real USB printers at all; when false, deviceRows below only ever has its PNG Export row.
|
||||
usbSupported: "usb" in navigator,
|
||||
blobReady: false,
|
||||
busy: false,
|
||||
error: null,
|
||||
|
|
@ -311,10 +280,10 @@ export default {
|
|||
printedWidthPx: 0,
|
||||
// Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
|
||||
textSizesPx: [],
|
||||
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
|
||||
// Scan-reliability messages from label.js's drawLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
|
||||
warnings: [],
|
||||
|
||||
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
|
||||
// One input per BASE_VARS entry; derived vars (userHandle/url/qualifiedHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over buildLabelFields' computed value.
|
||||
varValues: {
|
||||
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
||||
text: buildLabelContent(this.prefill),
|
||||
|
|
@ -324,25 +293,36 @@ export default {
|
|||
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
|
||||
},
|
||||
copies: 1,
|
||||
// Only sent to a real printer whose driver reports the "chain" capability (see chainSupported) - harmless to leave set when switching to a driver that ignores it.
|
||||
chainMode: false,
|
||||
// PNG Export's stand-in for a real tape's mediaWidthMm - there's no physical tape to measure, so this is the one thing about previewTape's simulated profile the user can adjust.
|
||||
simulatedHeightPx: SIMULATED_DEFAULT_HEIGHT_PX,
|
||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
|
||||
recentTemplateIds: [],
|
||||
// "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel/drawFallbackLabel.
|
||||
// "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel.
|
||||
orientation: "along",
|
||||
|
||||
fallbackReady: false,
|
||||
// TODO: replace with the real commands for our printers.
|
||||
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
|
||||
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(["user"]),
|
||||
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
|
||||
homeDomain() {
|
||||
const at = this.user ? this.user.indexOf("@") : -1;
|
||||
return at === -1 ? null : this.user.slice(at + 1);
|
||||
},
|
||||
baseVars() {
|
||||
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
|
||||
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR && v !== DOMAIN_SHORT_ID_VAR);
|
||||
},
|
||||
contentKinds() {
|
||||
return CONTENT_KINDS;
|
||||
},
|
||||
derivedVars() {
|
||||
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
|
||||
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, DOMAIN_SHORT_ID_VAR, SHORT_URL_VAR];
|
||||
},
|
||||
// Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
|
||||
fields() {
|
||||
|
|
@ -356,20 +336,35 @@ export default {
|
|||
const shortId = this.shortId(derived);
|
||||
if (shortId) {
|
||||
derived[SHORT_ID_VAR] = shortId;
|
||||
// Bare, same Home-Instance trust model as shortId - webdomain only picks a frontend, not a backend. See docs/implementation.md#calculated-short-link-fields.
|
||||
if (derived.webdomain) {
|
||||
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
|
||||
}
|
||||
if (this.homeDomain) {
|
||||
derived[DOMAIN_SHORT_ID_VAR] = this.homeDomain + ":" + shortId;
|
||||
}
|
||||
}
|
||||
return derived;
|
||||
},
|
||||
currentTemplate() {
|
||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||
},
|
||||
// Always includes the virtual PNG Export row, so the printer list (and the preview built from
|
||||
// whichever row is active) looks the same whether or not this browser/session has any real
|
||||
// USB printer paired - PNG Export is simply the active row whenever no real device is.
|
||||
deviceRows() {
|
||||
const pngRow = {
|
||||
index: PNG_EXPORT_INDEX,
|
||||
name: "PNG Export",
|
||||
supported: true,
|
||||
reason: "",
|
||||
isConnected: !this.connected,
|
||||
isPngExport: true,
|
||||
};
|
||||
if (!this.blob) {
|
||||
return [];
|
||||
return [pngRow];
|
||||
}
|
||||
return this.devices.map((d, index) => {
|
||||
const real = this.devices.map((d, index) => {
|
||||
const info = this.blob.findDevice(d.vendorId, d.productId);
|
||||
return {
|
||||
index,
|
||||
|
|
@ -379,20 +374,41 @@ export default {
|
|||
isConnected: d === this.connected,
|
||||
};
|
||||
});
|
||||
return [pngRow, ...real];
|
||||
},
|
||||
canPrint() {
|
||||
return Boolean(this.tape && this.labelBitmap && !this.busy);
|
||||
// Print/Download button and Copies input are only meaningful with a real printer connected; PNG Export can always export once a bitmap's rendered.
|
||||
canAct() {
|
||||
return Boolean(this.labelBitmap && !this.busy);
|
||||
},
|
||||
// Whether the connected printer's driver honors chain mode - some (e.g. Brother's QL line) don't. See vendor/weblabel.js's MultiPrinterBlob.can.
|
||||
chainSupported() {
|
||||
return Boolean(this.connected && this.blob && this.blob.can("chain", this.connected));
|
||||
},
|
||||
// The connected printer's tape, or PNG Export's stand-in profile (its height user-adjustable via simulatedHeightPx) so the ruler/preview always have something realistic to show.
|
||||
previewTape() {
|
||||
if (this.tape) {
|
||||
return this.tape;
|
||||
}
|
||||
const printAreaPx = Math.min(SIMULATED_MAX_HEIGHT_PX,
|
||||
Math.max(SIMULATED_MIN_HEIGHT_PX, Number(this.simulatedHeightPx) || SIMULATED_DEFAULT_HEIGHT_PX));
|
||||
return {
|
||||
dpi: SIMULATED_DPI,
|
||||
mediaWidthMm: Math.round(printAreaPx / SIMULATED_DPI * 25.4 * 10) / 10,
|
||||
printAreaPx,
|
||||
printLengthPx: 0,
|
||||
leadPx: SIMULATED_LEAD_PX,
|
||||
};
|
||||
},
|
||||
// px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
|
||||
tapePxPerMm() {
|
||||
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
|
||||
return (this.previewTape.dpi / 25.4) * this.zoom;
|
||||
},
|
||||
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
|
||||
horizontalTotalMm() {
|
||||
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
|
||||
return this.printedWidthPx ? this.printedWidthPx / (this.previewTape.dpi / 25.4) : 0;
|
||||
},
|
||||
verticalTotalMm() {
|
||||
return this.tape ? this.tape.mediaWidthMm : 0;
|
||||
return this.previewTape.mediaWidthMm;
|
||||
},
|
||||
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
|
||||
rulerTier() {
|
||||
|
|
@ -401,14 +417,14 @@ export default {
|
|||
},
|
||||
// Ticks along the tape's printed length, feed margins included (still real tape).
|
||||
horizontalRulerTicks() {
|
||||
if (!this.tape || !this.printedWidthPx) {
|
||||
if (!this.printedWidthPx) {
|
||||
return [];
|
||||
}
|
||||
return this.rulerTicks(this.horizontalTotalMm);
|
||||
},
|
||||
// Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
|
||||
verticalRulerTicks() {
|
||||
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
|
||||
return this.rulerTicks(this.verticalTotalMm);
|
||||
},
|
||||
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
|
||||
textSizesSummary() {
|
||||
|
|
@ -421,29 +437,20 @@ export default {
|
|||
watch: {
|
||||
varValues: {
|
||||
handler() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
selectedTemplate() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
orientation() {
|
||||
if (this.usbSupported) {
|
||||
this.redraw();
|
||||
} else {
|
||||
this.redrawFallback();
|
||||
}
|
||||
},
|
||||
// Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if).
|
||||
simulatedHeightPx() {
|
||||
this.redraw();
|
||||
},
|
||||
// Catches printer connect/disconnect/switch, redrawing with the real tape or falling back to previewTape's simulated one.
|
||||
tape: {
|
||||
handler() {
|
||||
this.redraw();
|
||||
|
|
@ -454,12 +461,14 @@ export default {
|
|||
methods: {
|
||||
...mapActions(["fetchIdMap"]),
|
||||
|
||||
// camelCase -> Title Case (e.g. "itemHandle" -> "Item Handle") so a new template var needs no hand-written label.
|
||||
// camelCase -> Title Case (e.g. "qualifiedHandle" -> "Qualified Handle") so a new template var needs no hand-written label.
|
||||
varLabel(v) {
|
||||
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
||||
},
|
||||
// Which short-id.js kind applies depends on f.kind directly. See docs/implementation.md#content-kinds-registry.
|
||||
shortId(f) {
|
||||
if (!f.userHandle) {
|
||||
const entry = f.kind && CONTENT_KINDS_BY_ID[f.kind];
|
||||
if (!f.userHandle || !entry || !f.id) {
|
||||
return null;
|
||||
}
|
||||
if (f.userHandle.startsWith("#")) {
|
||||
|
|
@ -467,29 +476,17 @@ export default {
|
|||
if (owner_group_id === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (f.itemId) {
|
||||
return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1);
|
||||
}
|
||||
if (f.locationId) {
|
||||
return shortenedRoute({
|
||||
kind: "group_storage_location", owner_group_id, storage_location_id: f.locationId
|
||||
kind: entry.groupShortIdKind, owner_group_id, [entry.localIdField]: f.id
|
||||
}).slice(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const owner_identity_id = this.identityIdByHandle[f.userHandle];
|
||||
if (owner_identity_id === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (f.itemId) {
|
||||
return shortenedRoute({kind: "item", owner_identity_id, item_local_id: f.itemId}).slice(1);
|
||||
}
|
||||
if (f.locationId) {
|
||||
return shortenedRoute({
|
||||
kind: "storage_location", owner_identity_id, storage_location_id: f.locationId
|
||||
kind: entry.shortIdKind, owner_identity_id, [entry.localIdField]: f.id
|
||||
}).slice(1);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
|
||||
|
|
@ -502,9 +499,6 @@ export default {
|
|||
return ticks;
|
||||
},
|
||||
|
||||
// Reads the recently-printed template ids back from localStorage; same try/catch shape as
|
||||
// cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
|
||||
// break printing.
|
||||
loadRecentTemplateIds() {
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
|
||||
|
|
@ -514,9 +508,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
|
||||
// capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
|
||||
// last four printed/downloaded layouts to the top.
|
||||
rememberPrintedTemplate(id) {
|
||||
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
|
||||
this.recentTemplateIds = updated;
|
||||
|
|
@ -583,6 +574,10 @@ export default {
|
|||
},
|
||||
|
||||
connect(index) {
|
||||
if (index === PNG_EXPORT_INDEX) {
|
||||
this.guard(() => this.closeConnection());
|
||||
return;
|
||||
}
|
||||
this.guard(() => this.connectToDevice(this.devices[index]));
|
||||
},
|
||||
|
||||
|
|
@ -604,9 +599,6 @@ export default {
|
|||
|
||||
redraw() {
|
||||
this.labelBitmap = null;
|
||||
if (!this.tape) {
|
||||
return;
|
||||
}
|
||||
const canvas = this.$refs.labelCanvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
|
|
@ -615,7 +607,7 @@ export default {
|
|||
let textSizesPx, warnings;
|
||||
try {
|
||||
const content = templateContent(this.currentTemplate, this.fields);
|
||||
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
|
||||
({textSizesPx, warnings} = drawLabel(canvas, this.previewTape, content, this.orientation));
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
|
|
@ -630,29 +622,8 @@ export default {
|
|||
this.zoom = this.fitZoom(canvas);
|
||||
},
|
||||
|
||||
redrawFallback() {
|
||||
this.fallbackReady = false;
|
||||
const canvas = this.$refs.fallbackCanvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.resizeObserver.observe(canvas.parentElement);
|
||||
let warnings;
|
||||
try {
|
||||
const content = templateContent(this.currentTemplate, this.fields);
|
||||
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
return;
|
||||
}
|
||||
this.error = null;
|
||||
this.warnings = warnings;
|
||||
this.fallbackReady = true;
|
||||
this.fitZoom(canvas);
|
||||
},
|
||||
|
||||
downloadPng() {
|
||||
const canvas = this.$refs.fallbackCanvas;
|
||||
const canvas = this.$refs.labelCanvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -681,7 +652,7 @@ export default {
|
|||
print() {
|
||||
this.guard(async () => {
|
||||
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
|
||||
await this.blob.printBitmap(this.labelBitmap, {copies});
|
||||
await this.blob.printBitmap(this.labelBitmap, {copies, chain: this.chainMode});
|
||||
this.rememberPrintedTemplate(this.selectedTemplate);
|
||||
});
|
||||
},
|
||||
|
|
@ -723,22 +694,23 @@ export default {
|
|||
// Same reasoning - drawTextLeaf's bitmap-font tiers (see label.js's fontTierFor) need this
|
||||
// loaded before the first real draw, same as qrReady below.
|
||||
const fontsReady = Promise.all([qrReady, preloadPixelFontRenderer()]);
|
||||
if (!("usb" in navigator)) {
|
||||
this.usbSupported = false;
|
||||
await Promise.all([nextTick(), fontsReady]);
|
||||
this.redrawFallback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.blob = await MultiPrinterBlob.load(BLOB_URL);
|
||||
} catch (e) {
|
||||
const blobLoad = this.usbSupported
|
||||
? MultiPrinterBlob.load(BLOB_URL).catch(e => {
|
||||
this.error = `Could not load the printer driver: ${e.message}`;
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
await fontsReady;
|
||||
// Draws PNG Export's simulated tape right away rather than waiting on the driver/device enumeration below; the tape watcher redraws again with a real one if/once autoConnectPreferred finds it.
|
||||
await nextTick();
|
||||
this.redraw();
|
||||
this.blob = await blobLoad;
|
||||
if (!this.blob) {
|
||||
return;
|
||||
}
|
||||
this.blobReady = true;
|
||||
navigator.usb.addEventListener("connect", this.onUsbChange);
|
||||
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
||||
await fontsReady;
|
||||
await this.guard(async () => {
|
||||
await this.refreshDevices();
|
||||
await this.autoConnectPreferred();
|
||||
|
|
@ -756,6 +728,44 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Sticks the preview/print card in view while the left column scrolls; only above `lg`, where the columns are actually side by side. */
|
||||
@media (min-width: 992px) {
|
||||
.print-sticky {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.calculated-field {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.printer-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--bs-gray-300);
|
||||
border-radius: 1rem;
|
||||
padding: .15rem .35rem .15rem .7rem;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.printer-chip-connected {
|
||||
border-color: var(--bs-success);
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.printer-chip-disabled {
|
||||
color: var(--bs-gray-600);
|
||||
}
|
||||
|
||||
.cli-details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.label-preview {
|
||||
overflow-x: auto;
|
||||
padding: .75rem;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Scan a code</h1>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
|
||||
<div class="custom-control custom-switch mb-3">
|
||||
<input class="custom-control-input" type="checkbox" id="visitFirstMatch"
|
||||
v-model="visitFirstMatch">
|
||||
<label class="form-check-label" for="visitFirstMatch">Visit first match</label>
|
||||
<label class="custom-control-label" for="visitFirstMatch">Visit first match</label>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
|
|
@ -70,17 +70,10 @@
|
|||
import {mapActions} from "vuex";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import CameraScanner from "@/components/CameraScanner.vue";
|
||||
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
||||
import {decodeShortId, deserializeShortId} from "@/short-id";
|
||||
import {encodeHandleForUrl, expandedRoute, OWNED_KIND_FIELDS} from "@/router";
|
||||
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} from "@/short-id";
|
||||
|
||||
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
|
||||
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
|
||||
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
|
||||
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
|
||||
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
|
||||
// human-readable "what this points at" - not needed for the two URL cases, which link to
|
||||
// somewhere already showing that.
|
||||
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;
|
||||
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
|
||||
|
||||
function classifyScanText(text) {
|
||||
if (!text) {
|
||||
|
|
@ -88,32 +81,29 @@ function classifyScanText(text) {
|
|||
}
|
||||
try {
|
||||
const url = new URL(text);
|
||||
// Same-origin URLs (the common case - a label printed by this same app) get routed
|
||||
// in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
|
||||
// needs no async lookup to confirm it's real (unlike the token/handle formats below), so
|
||||
// it's already "resolved" the moment it's classified - see resolveDescription and
|
||||
// maybeVisitFirstMatch.
|
||||
return url.origin === window.location.origin
|
||||
? {to: url.pathname + url.search + url.hash, immediate: true}
|
||||
: {href: url.href};
|
||||
} catch {
|
||||
// Not an absolute URL - fall through to the other known formats below.
|
||||
}
|
||||
if (text.startsWith('~')) {
|
||||
if (text.startsWith('~') || isDomainQualifiedShortId(text)) {
|
||||
try {
|
||||
const decoded = deserializeShortId(decodeShortId(text));
|
||||
// expandedRoute needs idmap already loaded to resolve item/group_item (see
|
||||
// router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
|
||||
// it from there, same as a cold-opened short link) when it can't yet.
|
||||
return {to: expandedRoute(decoded) || '/' + text, decoded};
|
||||
const qualified = isDomainQualifiedShortId(text) ? decodeDomainQualifiedShortId(text) : null;
|
||||
const decoded = deserializeShortId(qualified ? qualified.ints : decodeShortId(text));
|
||||
return {to: (!qualified && expandedRoute(decoded)) || '/' + text, decoded, domain: qualified?.domain};
|
||||
} catch {
|
||||
return null; // starts with '~' but isn't a real short id - leave as plain text
|
||||
return null; // looked like a short id but isn't a real one - leave as plain text
|
||||
}
|
||||
}
|
||||
const handleMatch = text.match(ITEM_HANDLE_RE);
|
||||
if (handleMatch) {
|
||||
const [, handle, id] = handleMatch;
|
||||
return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
|
||||
const ownerMatch = text.match(OWNER_QUALIFIED_ID_RE);
|
||||
if (ownerMatch) {
|
||||
const [, handle, kindLetter, id] = ownerMatch;
|
||||
const kind = kindLetter === "s" ? "storage_location" : "item";
|
||||
const to = kind === "storage_location"
|
||||
? `/storage-locations/${encodeHandleForUrl(handle)}/${id}`
|
||||
: `/inventory/${encodeHandleForUrl(handle)}/${id}`;
|
||||
return {to, ownerQualifiedId: {handle, kind, id}};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -154,7 +144,7 @@ export default {
|
|||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]),
|
||||
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap", "resolveShortId"]),
|
||||
|
||||
resolveDescription(entry) {
|
||||
const {link, text} = entry;
|
||||
|
|
@ -190,13 +180,17 @@ export default {
|
|||
},
|
||||
|
||||
async describeLink(link) {
|
||||
if (link.itemHandle) {
|
||||
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
|
||||
if (link.ownerQualifiedId) {
|
||||
const {handle, kind, id} = link.ownerQualifiedId;
|
||||
return kind === "storage_location" ? this.describeLocation(handle, id) : this.describeItem(handle, id);
|
||||
}
|
||||
const decoded = link.decoded;
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
if (link.domain) {
|
||||
return this.describeForeignShortId(link.domain, decoded);
|
||||
}
|
||||
if (decoded.kind === "item" || decoded.kind === "group_item") {
|
||||
const byId = decoded.kind === "item"
|
||||
? this.$store.getters.identityHandleById
|
||||
|
|
@ -236,6 +230,22 @@ export default {
|
|||
return null; // workflow, category, file: no per-item title lookup wired up yet
|
||||
},
|
||||
|
||||
async describeForeignShortId(domain, decoded) {
|
||||
const owned = OWNED_KIND_FIELDS[decoded.kind];
|
||||
if (!owned) {
|
||||
return null; // no owner id to resolve a foreign backend against yet (group/category/file/workflow)
|
||||
}
|
||||
const resolved = await this.resolveShortId({
|
||||
domain, kind: decoded.kind, ownerId: decoded[owned.ownerField], localId: decoded[owned.localField],
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return owned.isLocation
|
||||
? this.describeLocation(resolved.handle, resolved.id)
|
||||
: this.describeItem(resolved.handle, resolved.id);
|
||||
},
|
||||
|
||||
async describeItem(handle, id) {
|
||||
const item = await this.fetchItemByHandle({handle, id});
|
||||
return item ? `[#${item.id}] ${item.name}` : null;
|
||||
|
|
@ -263,20 +273,11 @@ export default {
|
|||
error: null,
|
||||
};
|
||||
this.cameraLog.unshift(entry);
|
||||
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
|
||||
// tracks property sets through the reactive proxy unshift() just installed, and
|
||||
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
|
||||
// description would never appear to update (stuck on "resolving...") even once the
|
||||
// lookup actually finished.
|
||||
this.resolveDescription(this.cameraLog[0]);
|
||||
// Caps the log (old entries trimmed) rather than growing forever, matching
|
||||
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
||||
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// Not component data: resolveDescription's cached values are read back into each entry's
|
||||
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
||||
this.descriptionCache = new Map();
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,23 +73,26 @@ 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;
|
||||
}
|
||||
try {
|
||||
const servers = await this.getHomeServers();
|
||||
const response = await servers.delete(this.signAuth, '/api/account/');
|
||||
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;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
<BaseLayout hide-search>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<div v-if="qualified" class="card">
|
||||
<div class="card-header">Domain</div>
|
||||
<div class="card-body">{{ qualified.domain }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Decoded</div>
|
||||
<div class="card-body">
|
||||
|
|
@ -64,8 +68,11 @@
|
|||
<script>
|
||||
import {mapActions} from 'vuex';
|
||||
import BaseLayout from '@/components/BaseLayout.vue';
|
||||
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
|
||||
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP} from '@/router';
|
||||
import {
|
||||
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
|
||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||
} from '@/short-id';
|
||||
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
|
||||
|
||||
const EXAMPLES = [
|
||||
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||
|
|
@ -130,12 +137,17 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
error: null
|
||||
error: null,
|
||||
foreignRoute: undefined,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// '<domain>:~<token>' vs a bare '~<token>' - see docs/handles-and-shortids.md#domain-qualified-short-id.
|
||||
qualified() {
|
||||
return isDomainQualifiedShortId(this.short_id) ? decodeDomainQualifiedShortId(this.short_id) : null;
|
||||
},
|
||||
decoded() {
|
||||
return decodeShortId(this.short_id);
|
||||
return this.qualified ? this.qualified.ints : decodeShortId(this.short_id);
|
||||
},
|
||||
deserialized() {
|
||||
return deserializeShortId(this.decoded);
|
||||
|
|
@ -145,7 +157,7 @@ export default {
|
|||
return fields;
|
||||
},
|
||||
expandedRoute() {
|
||||
return buildExpandedRoute(this.deserialized);
|
||||
return this.qualified ? (this.foreignRoute || null) : buildExpandedRoute(this.deserialized);
|
||||
},
|
||||
examples() {
|
||||
return EXAMPLES.map(named => {
|
||||
|
|
@ -165,11 +177,19 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchIdMap'])
|
||||
...mapActions(['fetchIdMap']),
|
||||
async resolveIfQualified() {
|
||||
if (this.qualified) {
|
||||
this.foreignRoute = await domainQualifiedRoute(this.qualified.domain, this.decoded) || null;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.resolveIfQualified();
|
||||
if (NEEDS_IDMAP.has(this.deserialized.kind) && !this.$store.state.idmapLoaded) {
|
||||
this.fetchIdMap().catch(e => {
|
||||
this.fetchIdMap()
|
||||
.then(() => this.resolveIfQualified())
|
||||
.catch(e => {
|
||||
this.error = e.message;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,20 @@
|
|||
<div class="row">
|
||||
<div class="col-12 col-xl-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">{{ user }}'s Storage Locations</h5>
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
||||
<h5 class="card-title mb-0">{{ selectedOwner }}'s Storage Locations</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<!--label for="ownerSelect" class="visually-hidden">Viewing storage locations for</label-->
|
||||
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
|
||||
v-model="selectedOwner">
|
||||
<option :value="user">{{ user }} (you)</option>
|
||||
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
|
||||
{{ group.handle }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="btn-group">
|
||||
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
|
||||
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
|
||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
|
|
@ -15,6 +27,8 @@
|
|||
<b-icon-grid></b-icon-grid>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-striped" v-if="layout === 'table'">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -25,7 +39,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="location in storage_locations" :key="location.id">
|
||||
<tr v-for="location in locations" :key="location.id">
|
||||
<td>
|
||||
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
|
||||
</td>
|
||||
|
|
@ -40,7 +54,7 @@
|
|||
<router-link :to="`${locationRoute(location)}/edit`">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<a :href="`${locationRoute(location)}/delete`" @click.prevent="deleteStorageLocation(location)">
|
||||
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
||||
|
|
@ -55,7 +69,7 @@
|
|||
</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="location in storage_locations"
|
||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="location in locations"
|
||||
:key="location.id">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
|
@ -73,7 +87,7 @@
|
|||
</div>
|
||||
<div class="btn-group mt-2">
|
||||
<button class="btn btn-danger btn-sm"
|
||||
@click="deleteStorageLocation(location.id)">Delete
|
||||
@click="tryDeleteLocation(location)">Delete
|
||||
</button>
|
||||
<router-link :to="`${locationRoute(location)}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
|
|
@ -94,10 +108,6 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card">
|
||||
<button class="btn" @click="fetchStorageLocations">Refresh</button>
|
||||
<router-link to="/storage-locations/new" class="btn btn-primary">Add</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -109,13 +119,15 @@
|
|||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {shortenedRoute, encodeHandleForUrl} from "@/router";
|
||||
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
name: "StorageLocation",
|
||||
data() {
|
||||
return {
|
||||
layout: "grid",
|
||||
props: {
|
||||
// Matches /storage-location/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
owner: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
components: {
|
||||
|
|
@ -123,14 +135,55 @@ export default {
|
|||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
|
||||
...mapState(["user", "storage_locations"]),
|
||||
...mapGetters(["identityIdByHandle", "groupIdByHandle", "groupStorageLocations", "getPreference"]),
|
||||
...mapState(["user", "storage_locations", "groups", "groupMemberships"]),
|
||||
// Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences.
|
||||
layout: {
|
||||
get() {
|
||||
return this.getPreference('ui.remembered.storage_location_view_mode', 'grid')
|
||||
},
|
||||
set(value) {
|
||||
this.setDevicePreference({key: 'ui.remembered.storage_location_view_mode', value})
|
||||
}
|
||||
},
|
||||
// Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe.
|
||||
ownerGroups() {
|
||||
const hostedHandles = new Set(this.groups.map(group => group.handle))
|
||||
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle))
|
||||
return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
|
||||
},
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
selectedOwner: {
|
||||
get() {
|
||||
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
|
||||
},
|
||||
set(value) {
|
||||
const path = value === this.user ? '/storage-location' : `/storage-location/${encodeHandleForUrl(value)}`
|
||||
this.$router.replace(path)
|
||||
}
|
||||
},
|
||||
locations() {
|
||||
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
|
||||
},
|
||||
addLocationRoute() {
|
||||
return this.selectedOwner === this.user
|
||||
? '/storage-locations/new'
|
||||
: `/storage-locations/new/${encodeHandleForUrl(this.selectedOwner)}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchStorageLocations", "deleteStorageLocation", "fetchIdMap"]),
|
||||
// This list is always the caller's own personal locations (fetchStorageLocations has no
|
||||
// group content), so the owner_group branches below are currently unreachable here - kept
|
||||
// for parity with Inventory.vue's shortIdLink/printLinkFor in case that ever changes.
|
||||
...mapActions(["fetchStorageLocations", "fetchGroupStorageLocations", "deleteStorageLocation",
|
||||
"fetchIdMap", "fetchGroups", "fetchGroupMemberships", "setDevicePreference"]),
|
||||
fetchLocationsForOwner() {
|
||||
return this.selectedOwner === this.user
|
||||
? this.fetchStorageLocations()
|
||||
: this.fetchGroupStorageLocations({groupHandle: this.selectedOwner})
|
||||
},
|
||||
tryDeleteLocation(location) {
|
||||
this.deleteStorageLocation(location).then(() => {
|
||||
this.fetchLocationsForOwner()
|
||||
})
|
||||
},
|
||||
locationRoute(location) {
|
||||
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}`
|
||||
},
|
||||
|
|
@ -147,15 +200,22 @@ export default {
|
|||
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
|
||||
},
|
||||
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
|
||||
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
|
||||
// printLinkFor. See docs/implementation.md#print-link-shape.
|
||||
printLinkFor(location) {
|
||||
const userHandle = location.owner_group || location.owner
|
||||
return {path: '/print', query: {kind: 'storage-location', userHandle, location: location.id}}
|
||||
return {path: '/print', query: {kind: 'storage-location', userHandle, id: location.id}}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
owner() {
|
||||
this.fetchLocationsForOwner()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchStorageLocations()
|
||||
this.fetchLocationsForOwner()
|
||||
await this.fetchIdMap()
|
||||
await this.fetchGroups()
|
||||
await this.fetchGroupMemberships()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -165,11 +225,11 @@ export default {
|
|||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
.btn-group.mt-2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
.btn-group.mt-2 .btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@
|
|||
Edit
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger"
|
||||
@click="deleteStorageLocation(location).then(() => $router.push(ownerOverviewRoute(location)))">
|
||||
@click="deleteStorageLocation(location).then(() => $router.push(ownerLocationOverviewRoute(location)))">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</button>
|
||||
<button class="btn btn-secondary"
|
||||
@click="$router.push({path: '/print', query: {kind: 'storage-location', userHandle: decodedHandle, location: id}})">
|
||||
@click="$router.push({path: '/print', query: {kind: 'storage-location', userHandle: decodedHandle, id: id}})">
|
||||
<b-icon-printer></b-icon-printer>
|
||||
Print label
|
||||
</button>
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
|
||||
|
||||
export default {
|
||||
name: "StorageLocationDetail",
|
||||
|
|
@ -93,7 +93,7 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
ownerOverviewRoute,
|
||||
ownerLocationOverviewRoute,
|
||||
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
|
||||
async loadLocation() {
|
||||
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
|
||||
|
||||
export default {
|
||||
name: "StorageLocationEdit",
|
||||
|
|
@ -100,7 +100,7 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
ownerOverviewRoute,
|
||||
ownerLocationOverviewRoute,
|
||||
...mapActions(["fetchStorageLocationByHandle", "updateStorageLocation", "fetchInfo",
|
||||
"fetchStorageLocations", "fetchGroupStorageLocations"]),
|
||||
async loadLocation() {
|
||||
|
|
@ -121,7 +121,7 @@ export default {
|
|||
category: this.location.category === "" ? null : this.location.category,
|
||||
parent: this.location.parent === "" ? null : this.location.parent
|
||||
};
|
||||
this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerOverviewRoute(updated)));
|
||||
this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerLocationOverviewRoute(updated)));
|
||||
},
|
||||
isChildOf(location, parentId) {
|
||||
// Simple check to prevent circular references
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
|
||||
|
||||
export default {
|
||||
name: "StorageLocationNew",
|
||||
|
|
@ -70,6 +70,13 @@ export default {
|
|||
BaseLayout,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
// Matches /storage-locations/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
group: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
location: {
|
||||
|
|
@ -87,7 +94,7 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
ownerOverviewRoute,
|
||||
ownerLocationOverviewRoute,
|
||||
...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
|
||||
'fetchGroupMemberships', 'fetchGroupStorageLocations']),
|
||||
async loadParentOptionsForOwner() {
|
||||
|
|
@ -104,7 +111,7 @@ export default {
|
|||
category: this.location.category === "" ? null : this.location.category,
|
||||
parent: this.location.parent === "" ? null : this.location.parent
|
||||
};
|
||||
this.createStorageLocation(locationData).then(created => this.$router.push(ownerOverviewRoute(created)));
|
||||
this.createStorageLocation(locationData).then(created => this.$router.push(ownerLocationOverviewRoute(created)));
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -132,8 +139,8 @@ export default {
|
|||
await this.fetchStorageLocations();
|
||||
await this.fetchGroups();
|
||||
await this.fetchGroupMemberships();
|
||||
if (this.$route.query.group) {
|
||||
this.location.owner_group = decodeHandleFromUrl(this.$route.query.group)
|
||||
if (this.group) {
|
||||
this.location.owner_group = decodeHandleFromUrl(this.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export default {
|
|||
},
|
||||
|
||||
totalSteps() {
|
||||
return this.stepDefinitions.length || this.workflowInstance?.total_steps || 1;
|
||||
return this.stepDefinitions.length || 1;
|
||||
},
|
||||
|
||||
currentStepDefinition() {
|
||||
|
|
@ -314,6 +314,7 @@ export default {
|
|||
...mapActions([
|
||||
'fetchActiveWorkflows',
|
||||
'updateWorkflow',
|
||||
'updateWorkflowStep',
|
||||
'deleteWorkflow'
|
||||
]),
|
||||
|
||||
|
|
@ -338,11 +339,20 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
navigateToStep(step) {
|
||||
async navigateToStep(step) {
|
||||
const stepStr = String(step);
|
||||
// For navigation, we need to validate the step exists in step definitions
|
||||
const stepExists = this.stepDefinitions.some(step => step.step === stepStr);
|
||||
if (stepExists || (step >= 1 && step <= this.totalSteps)) {
|
||||
try {
|
||||
await this.updateWorkflowStep({
|
||||
workflowId: this.id,
|
||||
currentStep: stepStr,
|
||||
payload: this.workflowInstance.payload
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error persisting workflow step:', error);
|
||||
}
|
||||
this.$router.push({
|
||||
name: 'workflow-detail',
|
||||
params: { id: this.id, step: stepStr }
|
||||
|
|
|
|||
|
|
@ -248,11 +248,11 @@ export default {
|
|||
}
|
||||
},
|
||||
async viewWorkflowDetails(workflow) {
|
||||
console.log('Viewing details for workflow:', workflow);
|
||||
const currentStep = workflow.current_step ||
|
||||
workflow.payload?.current_step ||
|
||||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
||||
"initial";
|
||||
const currentStep = String(
|
||||
workflow.current_step ||
|
||||
getWorkflow(workflow.slug)?.stepDefinitions?.[0]?.step ||
|
||||
1
|
||||
);
|
||||
|
||||
this.$router.push({
|
||||
name: 'workflow-detail',
|
||||
|
|
|
|||
|
|
@ -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,28 +104,30 @@ 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;
|
||||
}
|
||||
try {
|
||||
const servers = await this.getHomeServers();
|
||||
const response = await servers.delete(this.signAuth, '/api/account_data/');
|
||||
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,25 +151,27 @@ 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 {
|
||||
const base64 = await this.fileToBase64(this.selectedFile);
|
||||
const servers = await this.getHomeServers();
|
||||
const summary = await servers.post(this.signAuth, '/api/import/', {zip: base64});
|
||||
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,12 +179,12 @@ export default {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Data import failed', error);
|
||||
alert('Data import failed: ' + error);
|
||||
this.importError = 'Data import failed: ' + error;
|
||||
}
|
||||
},
|
||||
async exportData() {
|
||||
const servers = await this.getHomeServers();
|
||||
const data = await servers.getRaw(this.signAuth, '/api/export/');
|
||||
const data = await servers.getRaw(this.signAuth, '/api/v1/export/');
|
||||
const blob = new Blob([await data.blob()], {type: 'application/zip'});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@
|
|||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label d-block">Email notifications</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check input-switch" type="checkbox"
|
||||
<div class="custom-control custom-switch">
|
||||
<input class="custom-control-input" type="checkbox"
|
||||
id="flexSwitchCheckDefault">
|
||||
<label class="form-check label-switch" for="flexSwitchCheckDefault">Receive
|
||||
<label class="custom-control-label" for="flexSwitchCheckDefault">Receive
|
||||
email notifications</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@
|
|||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label d-block">Web notifications</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check input-switch" type="checkbox"
|
||||
<div class="custom-control custom-switch">
|
||||
<input class="custom-control-input" type="checkbox"
|
||||
id="flexSwitchCheckDefault">
|
||||
<label class="form-check label-switch" for="flexSwitchCheckDefault">Receive
|
||||
<label class="custom-control-label" for="flexSwitchCheckDefault">Receive
|
||||
web notifications</label>
|
||||
<small>These notifications can be sent to your email or
|
||||
phone</small>
|
||||
|
|
|
|||
|
|
@ -6,38 +6,28 @@
|
|||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label d-block">Profile visibility</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check input-switch" type="checkbox"
|
||||
id="flexSwitchCheckDefault">
|
||||
<label class="form-check
|
||||
label-switch" for="flexSwitchCheckDefault">Visible to
|
||||
everyone</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label
|
||||
d-block">Photo visibility</label>
|
||||
<div class="form-check
|
||||
form-switch">
|
||||
<input class="form-check
|
||||
input-switch" type="checkbox"
|
||||
id="flexSwitchCheckDefault">
|
||||
<label class="form-check
|
||||
label-switch" for="flexSwitchCheckDefault">Visible to
|
||||
<div class="custom-control custom-switch">
|
||||
<input class="custom-control-input" type="checkbox"
|
||||
id="profile-visibility">
|
||||
<label class="custom-control-label" for="profile-visibility">Visible to
|
||||
everyone</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label
|
||||
d-block">Last seen</label>
|
||||
<div class="form-check
|
||||
form-switch">
|
||||
<input class="form-check
|
||||
input-switch" type="checkbox"
|
||||
id="flexSwitchCheckDefault">
|
||||
<label class="form-check
|
||||
label-switch" for="flexSwitchCheckDefault">Visible to
|
||||
<label class="form-label d-block">Photo visibility</label>
|
||||
<div class="custom-control custom-switch">
|
||||
<input class="custom-control-input" type="checkbox"
|
||||
id="photo-visibility">
|
||||
<label class="custom-control-label" for="photo-visibility">Visible to
|
||||
everyone</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label d-block">Last seen</label>
|
||||
<div class="custom-control custom-switch">
|
||||
<input class="custom-control-input" type="checkbox"
|
||||
id="last-seen-visibility">
|
||||
<label class="custom-control-label" for="last-seen-visibility">Visible to
|
||||
everyone</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export function buildWorkflowApiPayload(workflow) {
|
|||
slug: workflow.slug,
|
||||
state: 'running',
|
||||
current_step: 1,
|
||||
total_steps: workflow.stepDefinitions.length,
|
||||
payload: {
|
||||
...ownPayload
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue