Compare commits

...

2 commits

Author SHA1 Message Date
9f3dc7c993 stash 2026-08-26 22:18:55 +02:00
23185e1721 stash 2026-08-26 22:18:02 +02:00
21 changed files with 700 additions and 210 deletions

View file

@ -40,18 +40,11 @@ class GroupDetail(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication] authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get(self, request, pk, format=None): # /api/groups/<pk>/ def get(self, request, handle, format=None): # /api/groups/<name@domain>/
group = get_object_or_404(Group, pk=pk) try:
if not group.is_member(request.user): name, domain = split_userhandle_or_throw(handle)
return Response(status=status.HTTP_404_NOT_FOUND) except ValueError:
return Response(GroupSerializer(group).data) return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
class GroupDetailByHandle(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, name, domain, format=None): # /api/groups/handle/<name>/<domain>/
group = get_object_or_404(Group, name=name, domain=domain) group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user): if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
@ -61,8 +54,12 @@ class GroupDetailByHandle(APIView, ViewSetMixin):
@api_view(['DELETE']) @api_view(['DELETE'])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def removeGroupMember(request, pk, identity_id, format=None): # /api/groups/<pk>/members/<identity_id>/ def removeGroupMember(request, handle, identity_id, format=None): # /api/groups/<name@domain>/members/<identity_id>/
group = get_object_or_404(Group, pk=pk) try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user): if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
member = get_object_or_404(group.members, pk=identity_id) member = get_object_or_404(group.members, pk=identity_id)
@ -76,8 +73,12 @@ def removeGroupMember(request, pk, identity_id, format=None): # /api/groups/<pk
@api_view(['POST']) @api_view(['POST'])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def createGroupInvite(request, pk, format=None): # /api/groups/<pk>/invites/ def createGroupInvite(request, handle, format=None): # /api/groups/<name@domain>/invites/
group = get_object_or_404(Group, pk=pk) try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user): if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
invitee = request.data.get('invitee') invitee = request.data.get('invitee')
@ -197,10 +198,9 @@ def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lan
urlpatterns = [ urlpatterns = [
path('groups/', Groups.as_view(), name='groups'), path('groups/', Groups.as_view(), name='groups'),
path('groups/<int:pk>/', GroupDetail.as_view(), name='group_detail'), path('groups/<str:handle>/', GroupDetail.as_view(), name='group_detail'),
path('groups/handle/<str:name>/<str:domain>/', GroupDetailByHandle.as_view(), name='group_detail_by_handle'), path('groups/<str:handle>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
path('groups/<int:pk>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'), path('groups/<str:handle>/invites/', createGroupInvite, name='create_group_invite'),
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'), path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'), path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'), path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),

View file

@ -15,6 +15,21 @@ from toolshed.serializers import InventoryItemSerializer, StorageLocationSeriali
router = routers.SimpleRouter() 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): def inventory_items(identity):
try: try:
user = identity.user.get() user = identity.user.get()
@ -54,13 +69,10 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
if identity.user.exists(): if identity.user.exists():
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
return group_items return group_items
group_id = self.request.query_params.get('group') group_handle = self.request.query_params.get('group')
if group_id: if group_handle:
try: group = resolve_group_by_handle(group_handle)
group = Group.objects.get(pk=group_id) if not group or not group.is_member(identity):
except Group.DoesNotExist:
return InventoryItem.objects.none()
if not group.is_member(identity):
return InventoryItem.objects.none() return InventoryItem.objects.none()
return InventoryItem.objects.filter(owner_group=group) return InventoryItem.objects.filter(owner_group=group)
if identity.user.exists(): if identity.user.exists():
@ -68,12 +80,11 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
return InventoryItem.objects.none() return InventoryItem.objects.none()
def perform_create(self, serializer): def perform_create(self, serializer):
group_id = self.request.data.get('owner_group') group_handle = self.request.data.get('owner_group')
with transaction.atomic(): with transaction.atomic():
if group_id: if group_handle:
try: group = resolve_group_by_handle(group_handle)
group = Group.objects.get(pk=group_id) if not group:
except Group.DoesNotExist:
raise NotFound('No such group') raise NotFound('No such group')
if not group.is_member(self.request.user): if not group.is_member(self.request.user):
raise PermissionDenied('Not a member of this group') raise PermissionDenied('Not a member of this group')
@ -155,21 +166,53 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
lookup_url_kwarg = 'pk' lookup_url_kwarg = 'pk'
def get_queryset(self): def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): # Mirrors InventoryItemViewSet.get_queryset() - see its own comments for why the
return StorageLocation.objects.filter(owner=self.request.user.user.get()) # list/detail scopes differ and why group membership alone (no linked ToolshedUser
# required) is enough for the group branch.
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):
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.none() return StorageLocation.objects.none()
def perform_create(self, serializer): def perform_create(self, serializer):
group_handle = self.request.data.get('owner_group')
with transaction.atomic(): with transaction.atomic():
serializer.save(owner=self.request.user.user.get()) 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):
raise PermissionDenied('Not a member of this group')
serializer.save(owner=None, owner_group=group).clean()
else:
serializer.save(owner=self.request.user.user.get()).clean()
@staticmethod
def _is_authorized(request, instance):
if instance.owner_id:
return request.user.user.filter(pk=instance.owner_id).exists()
return instance.owner_group.is_member(request.user)
def perform_update(self, serializer): def perform_update(self, serializer):
with transaction.atomic(): with transaction.atomic():
if serializer.instance.owner == self.request.user.user.get(): if self._is_authorized(self.request, serializer.instance):
serializer.save() serializer.save().clean()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get(): if self._is_authorized(self.request, instance):
instance.delete() instance.delete()

View file

@ -0,0 +1,63 @@
# Generated by Django 4.2.2 on 2026-08-26 18:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0006_groupmembership'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('toolshed', '0022_storagelocation_id_not_null_and_unique'),
]
operations = [
migrations.RemoveConstraint(
model_name='storagelocation',
name='storagelocation_unique_owner_scoped_id',
),
migrations.AddField(
model_name='ownerstoragelocationsequence',
name='owner_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='authentication.group'),
),
migrations.AddField(
model_name='storagelocation',
name='owner_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to='authentication.group'),
),
migrations.AlterField(
model_name='owneritemsequence',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ownerstoragelocationsequence',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ownerstoragelocationsequence',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='storagelocation',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='ownerstoragelocationsequence',
constraint=models.UniqueConstraint(condition=models.Q(('owner__isnull', False)), fields=('owner',), name='ownerstoragelocationsequence_unique_owner'),
),
migrations.AddConstraint(
model_name='ownerstoragelocationsequence',
constraint=models.UniqueConstraint(condition=models.Q(('owner_group__isnull', False)), fields=('owner_group',), name='ownerstoragelocationsequence_unique_owner_group'),
),
migrations.AddConstraint(
model_name='storagelocation',
constraint=models.UniqueConstraint(fields=('owner', 'owner_group', 'id'), name='storagelocation_unique_owner_scoped_id'),
),
]

View file

@ -170,15 +170,25 @@ class ItemTag(models.Model):
class OwnerStorageLocationSequence(models.Model): class OwnerStorageLocationSequence(models.Model):
"""Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation """Tracks the last StorageLocation id handed out per owner/owner_group scope for sequential,
(see StorageLocation.create_for_owner).""" gapless allocation (see StorageLocation.create_for_owner); exactly one of owner/owner_group is
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True) set, mirroring OwnerItemSequence/InventoryItem's own split."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
last_id = models.PositiveIntegerField(default=0) last_id = models.PositiveIntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner'], condition=models.Q(owner__isnull=False),
name='ownerstoragelocationsequence_unique_owner'),
models.UniqueConstraint(fields=['owner_group'], condition=models.Q(owner_group__isnull=False),
name='ownerstoragelocationsequence_unique_owner_group'),
]
@classmethod @classmethod
def allocate(cls, *, owner): def allocate(cls, *, owner=None, owner_group=None):
with transaction.atomic(): with transaction.atomic():
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner) seq, _ = cls.objects.select_for_update().get_or_create(owner=owner, owner_group=owner_group)
seq.last_id += 1 seq.last_id += 1
seq.save(update_fields=['last_id']) seq.save(update_fields=['last_id'])
return seq.last_id return seq.last_id
@ -186,7 +196,7 @@ class OwnerStorageLocationSequence(models.Model):
class StorageLocation(models.Model): class StorageLocation(models.Model):
internal_id = models.AutoField(primary_key=True) internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within the owner's own locations (see # Externally visible id, sequential/gapless within the owner/owner_group's own locations (see
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not # OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
# .objects.create(). # .objects.create().
id = models.PositiveIntegerField(editable=False) id = models.PositiveIntegerField(editable=False)
@ -195,24 +205,32 @@ class StorageLocation(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations') related_name='storage_locations')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations')
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'), models.UniqueConstraint(fields=['owner', 'owner_group', 'id'],
name='storagelocation_unique_owner_scoped_id'),
] ]
def __str__(self): def __str__(self):
parent = str(self.parent) + "/" if self.parent else "" parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name return parent + self.name
def clean(self):
if (self.owner is None) == (self.owner_group is None):
raise ValidationError("Exactly one of owner or owner_group must be set")
@classmethod @classmethod
def create_for_owner(cls, *, owner, **kwargs): def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
"""The only supported way to create a StorageLocation: atomically allocates the next id """The only supported way to create a StorageLocation: atomically allocates the next id
for this owner's scope.""" for this owner/owner_group's scope."""
with transaction.atomic(): with transaction.atomic():
next_id = OwnerStorageLocationSequence.allocate(owner=owner) next_id = OwnerStorageLocationSequence.allocate(owner=owner, owner_group=owner_group)
return cls.objects.create(owner=owner, id=next_id, **kwargs) return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
class WorkflowInstance(models.Model): class WorkflowInstance(models.Model):

View file

@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from rest_framework import serializers from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming, \ from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming, \
GroupMembership GroupMembership
@ -177,9 +178,13 @@ class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
request = self.context.get('request') request = self.context.get('request')
if request is not None and type(request.user) == KnownIdentity and request.user.user.exists(): if request is None or type(request.user) != KnownIdentity:
return queryset.filter(owner=request.user.user.get()) return queryset.none()
return queryset.none() identity = request.user
owner_filter = Q(owner_group__in=identity.member_of_groups.all())
if identity.user.exists():
owner_filter |= Q(owner=identity.user.get())
return queryset.filter(owner_filter)
def to_internal_value(self, data): def to_internal_value(self, data):
queryset = self.get_queryset() queryset = self.get_queryset()
@ -198,6 +203,7 @@ class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
class StorageLocationSerializer(serializers.ModelSerializer): class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True) owner = OwnerSerializer(read_only=True)
owner_group = GroupOwnerSerializer(read_only=True)
category = serializers.CharField(required=False, allow_null=True, allow_blank=True) category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True) allow_null=True)
@ -205,7 +211,7 @@ class StorageLocationSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = StorageLocation model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent'] fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'owner_group', 'parent']
read_only_fields = ['id', 'path'] read_only_fields = ['id', 'path']
@staticmethod @staticmethod

View file

@ -53,56 +53,47 @@ class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def test_group_detail_member(self): def test_group_detail_member(self):
self.prepare_groups() self.prepare_groups()
reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user1']) group = self.f['group1']
reply = client.get('/api/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user1'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['handle'], str(self.f['group1'])) self.assertEqual(reply.json()['handle'], str(group))
self.assertEqual(len(reply.json()['members']), 1) self.assertEqual(len(reply.json()['members']), 1)
def test_group_detail_non_member(self): def test_group_detail_non_member(self):
self.prepare_groups() self.prepare_groups()
reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user2']) group = self.f['group1']
reply = client.get('/api/groups/{}@{}/'.format(group.name, group.domain), self.f['local_user2'])
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
def test_group_detail_by_handle_member(self): def test_group_detail_no_such_group(self):
self.prepare_groups() reply = client.get('/api/groups/nonexistent@example.com/', self.f['local_user1'])
group = self.f['group1']
reply = client.get('/api/groups/handle/{}/{}/'.format(group.name, group.domain), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['id'], group.id)
self.assertEqual(reply.json()['handle'], str(group))
def test_group_detail_by_handle_non_member(self):
self.prepare_groups()
group = self.f['group1']
reply = client.get('/api/groups/handle/{}/{}/'.format(group.name, group.domain), self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_group_detail_by_handle_no_such_group(self):
reply = client.get('/api/groups/handle/nonexistent/example.com/', self.f['local_user1'])
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
def test_remove_member(self): def test_remove_member(self):
self.prepare_groups() self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity) group = self.f['group1']
reply = client.delete('/api/groups/{}/members/{}/'.format( group.members.add(self.f['local_user2'].public_identity)
self.f['group1'].id, self.f['local_user2'].public_identity.id), self.f['local_user1']) reply = client.delete('/api/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(reply.status_code, 204)
self.assertEqual(self.f['group1'].members.count(), 1) self.assertEqual(group.members.count(), 1)
def test_remove_last_member_blocked(self): def test_remove_last_member_blocked(self):
self.prepare_groups() self.prepare_groups()
reply = client.delete('/api/groups/{}/members/{}/'.format( group = self.f['group1']
self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['local_user1']) reply = client.delete('/api/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(reply.status_code, 400)
self.assertEqual(self.f['group1'].members.count(), 1) self.assertEqual(group.members.count(), 1)
def test_remove_member_non_member_denied(self): def test_remove_member_non_member_denied(self):
self.prepare_groups() self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity) group = self.f['group1']
reply = client.delete('/api/groups/{}/members/{}/'.format( group.members.add(self.f['local_user2'].public_identity)
self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['ext_user1']) reply = client.delete('/api/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(reply.status_code, 404)
self.assertEqual(self.f['group1'].members.count(), 2) self.assertEqual(group.members.count(), 2)
class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
@ -116,7 +107,8 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
inviter = self.f['local_user1'] inviter = self.f['local_user1']
invitee = self.f['local_user2'] invitee = self.f['local_user2']
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
{'invitee': str(invitee)})
self.assertEqual(reply1.status_code, 201) self.assertEqual(reply1.status_code, 201)
secret = reply1.json()['secret'] secret = reply1.json()['secret']
self.assertEqual(GroupInvite.objects.count(), 1) self.assertEqual(GroupInvite.objects.count(), 1)
@ -152,7 +144,7 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def test_invite_non_member_denied(self): def test_invite_non_member_denied(self):
group = self.f['group1'] group = self.f['group1']
reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user2'], reply = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user2'],
{'invitee': str(self.f['ext_user1'])}) {'invitee': str(self.f['ext_user1'])})
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
self.assertEqual(GroupInvite.objects.count(), 0) self.assertEqual(GroupInvite.objects.count(), 0)
@ -160,7 +152,7 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def test_invite_already_member(self): def test_invite_already_member(self):
group = self.f['group1'] group = self.f['group1']
group.members.add(self.f['local_user2'].public_identity) group.members.add(self.f['local_user2'].public_identity)
reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user1'], reply = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), self.f['local_user1'],
{'invitee': str(self.f['local_user2'])}) {'invitee': str(self.f['local_user2'])})
self.assertEqual(reply.status_code, 208) self.assertEqual(reply.status_code, 208)
@ -177,7 +169,7 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
group = self.f['group1'] group = self.f['group1']
inviter = self.f['local_user1'] inviter = self.f['local_user1']
invitee = self.f['local_user2'] invitee = self.f['local_user2']
client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter, {'invitee': str(invitee)})
reply = client.post('/api/group_invites/accept/', invitee, { reply = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(),
'secret': 'not-the-right-secret' 'secret': 'not-the-right-secret'
@ -189,7 +181,8 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
group = self.f['group1'] group = self.f['group1']
inviter = self.f['local_user1'] inviter = self.f['local_user1']
invitee = DummyExternalUser('newmember', 'remote.example', known=False) invitee = DummyExternalUser('newmember', 'remote.example', known=False)
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
{'invitee': str(invitee)})
secret = reply1.json()['secret'] secret = reply1.json()['secret']
reply = client.post('/api/group_invites/accept/', invitee, { reply = client.post('/api/group_invites/accept/', invitee, {
@ -206,7 +199,8 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
group = self.f['group1'] group = self.f['group1']
inviter = self.f['local_user1'] inviter = self.f['local_user1']
invitee = self.f['local_user2'] invitee = self.f['local_user2']
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) reply1 = client.post('/api/groups/{}@{}/invites/'.format(group.name, group.domain), inviter,
{'invitee': str(invitee)})
secret = reply1.json()['secret'] secret = reply1.json()['secret']
bad_signature_client = SignatureAuthClient(bad_signature=True) bad_signature_client = SignatureAuthClient(bad_signature=True)
reply = bad_signature_client.post('/api/group_invites/accept/', invitee, { reply = bad_signature_client.post('/api/group_invites/accept/', invitee, {

View file

@ -353,7 +353,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
def create_group_item(self, name='drill'): def create_group_item(self, name='drill'):
return client.post('/api/inventory_items/', self.f['local_user1'], { return client.post('/api/inventory_items/', self.f['local_user1'], {
'name': name, 'owned_quantity': 1, 'availability_policy': 'private', 'name': name, 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': self.f['group1'].id, 'owner_group': str(self.f['group1'])[1:],
}) })
def test_create_group_owned_item(self): def test_create_group_owned_item(self):
@ -368,7 +368,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
def test_create_group_owned_item_non_member_denied(self): def test_create_group_owned_item_non_member_denied(self):
reply = client.post('/api/inventory_items/', self.f['ext_user1'], { reply = client.post('/api/inventory_items/', self.f['ext_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': self.f['group1'].id, 'owner_group': str(self.f['group1'])[1:],
}) })
self.assertEqual(reply.status_code, 403) self.assertEqual(reply.status_code, 403)
self.assertEqual(InventoryItem.objects.count(), 0) self.assertEqual(InventoryItem.objects.count(), 0)
@ -413,14 +413,14 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
def test_group_items_listed_by_group_query_param(self): def test_group_items_listed_by_group_query_param(self):
self.create_group_item() self.create_group_item()
reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['local_user2']) reply = client.get('/api/inventory_items/?group={}'.format(str(self.f['group1'])[1:]), self.f['local_user2'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1) self.assertEqual(len(reply.json()), 1)
self.assertEqual(reply.json()[0]['name'], 'drill') 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_query_param(self):
self.create_group_item() self.create_group_item()
reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['ext_user1']) reply = client.get('/api/inventory_items/?group={}'.format(str(self.f['group1'])[1:]), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0) self.assertEqual(len(reply.json()), 0)
@ -435,7 +435,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
'owned_quantity': 3, 'owned_quantity': 3,
'tags': ['tag1', 'tag2'], 'tags': ['tag1', 'tag2'],
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}], 'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
'owner_group': self.f['group1'].id, 'owner_group': str(self.f['group1'])[1:],
}) })
self.assertEqual(reply.status_code, 201) self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill') item = InventoryItem.objects.get(name='drill')
@ -451,20 +451,21 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
def test_create_group_owned_item_empty_fails(self): 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. # 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'], { reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id, 'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': str(self.f['group1'])[1:],
}) })
self.assertEqual(reply.status_code, 400) self.assertEqual(reply.status_code, 400)
self.assertEqual(InventoryItem.objects.count(), 0) self.assertEqual(InventoryItem.objects.count(), 0)
def test_create_group_owned_item_nonexistent_group(self): def test_create_group_owned_item_nonexistent_group(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], { reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', 'owner_group': 999999, 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': 'nonexistent@example.com',
}) })
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
self.assertEqual(InventoryItem.objects.count(), 0) self.assertEqual(InventoryItem.objects.count(), 0)
def test_group_items_listed_for_nonexistent_group_query_param(self): def test_group_items_listed_for_nonexistent_group_query_param(self):
reply = client.get('/api/inventory_items/?group=999999', self.f['local_user1']) reply = client.get('/api/inventory_items/?group=nonexistent@example.com', self.f['local_user1'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0) self.assertEqual(len(reply.json()), 0)
@ -495,7 +496,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
reply = client.post('/api/inventory_items/', self.f['local_user1'], { reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'category': 'cat1', 'tags': ['tag1'], 'category': 'cat1', 'tags': ['tag1'],
'owner_group': self.f['group1'].id, 'owner_group': str(self.f['group1'])[1:],
}) })
item_id = reply.json()['id'] item_id = reply.json()['id']
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], { reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
@ -509,7 +510,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
def test_group_item_storage_location(self): def test_group_item_storage_location(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], { reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'storage_location': self.f['loc1'].id, 'owner_group': self.f['group1'].id, 'storage_location': self.f['loc1'].id, 'owner_group': str(self.f['group1'])[1:],
}) })
self.assertEqual(reply.status_code, 201) self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill') item = InventoryItem.objects.get(name='drill')
@ -519,7 +520,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
# Parity with TestInventoryItemWithFileApiTestCase.test_post_item_with_file_id. # Parity with TestInventoryItemWithFileApiTestCase.test_post_item_with_file_id.
reply = client.post('/api/inventory_items/', self.f['local_user1'], { reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'files': [self.f['test_file1'].id], 'owner_group': self.f['group1'].id, 'files': [self.f['test_file1'].id], 'owner_group': str(self.f['group1'])[1:],
}) })
self.assertEqual(reply.status_code, 201) self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill') item = InventoryItem.objects.get(name='drill')
@ -529,7 +530,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
reply = client.post('/api/inventory_items/', self.f['local_user1'], { reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'files': [{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'}], 'files': [{'data': self.f['encoded_content3'], 'mime_type': 'text/plain'}],
'owner_group': self.f['group1'].id, 'owner_group': str(self.f['group1'])[1:],
}) })
self.assertEqual(reply.status_code, 201) self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill') item = InventoryItem.objects.get(name='drill')

View file

@ -1,4 +1,4 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category, StorageLocation from toolshed.models import InventoryItem, Category, StorageLocation
from toolshed.tests import InventoryTestMixin, LocationTestMixin from toolshed.tests import InventoryTestMixin, LocationTestMixin
@ -129,3 +129,110 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1) self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)
class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
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:],
})
def test_create_group_owned_location(self):
reply = self.create_group_location()
self.assertEqual(reply.status_code, 201)
location = StorageLocation.objects.get(name='shelf')
self.assertIsNone(location.owner)
self.assertEqual(location.owner_group, self.f['group1'])
self.assertEqual(reply.json()['owner_group'], str(self.f['group1']))
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:],
})
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'], {
'name': 'shelf-renamed'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(StorageLocation.objects.get(id=location_id).name, 'shelf-renamed')
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'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(StorageLocation.objects.filter(id=location_id).count(), 0)
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'])
self.assertEqual(reply.status_code, 200)
reply = client.patch('/api/storage_locations/{}/'.format(location_id), self.f['ext_user1'], {
'name': 'shelf-renamed-by-remote-member'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(StorageLocation.objects.get(id=location_id).name, 'shelf-renamed-by-remote-member')
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'])
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'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_group_locations_listed_by_group_query_param(self):
self.create_group_location()
reply = client.get('/api/storage_locations/?group={}'.format(str(self.f['group1'])[1:]), 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):
self.create_group_location()
reply = client.get('/api/storage_locations/?group={}'.format(str(self.f['group1'])[1:]), 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,
})
self.assertEqual(reply.status_code, 201)
self.assertEqual(reply.json()['path'], 'shelf/bin')
def test_own_personal_location_can_be_parent_of_group_location(self):
# OwnerScopedPrimaryKeyRelatedField (see serializers.py) scopes `parent` to every
# location the requester can act on - own personal ones plus any group's - with no
# further check that it matches the new location's own owner_group. Same behavior
# 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,
})
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'], {
'name': 'bin', 'parent': parent_id,
})
self.assertEqual(reply.status_code, 400)

View file

@ -51,6 +51,11 @@ function itemDetailRoute(handle, item_local_id) {
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; 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;
}
export function ownerOverviewRoute(item) { export function ownerOverviewRoute(item) {
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory'; return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
} }
@ -64,12 +69,15 @@ const EXPANDED_ROUTE_BUILDERS = {
const handle = store.getters.groupHandleById[group_id]; const handle = store.getters.groupHandleById[group_id];
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null; return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
}, },
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`, 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),
workflow: ({workflow_id}) => `/workflows/${workflow_id}`, workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
}; };
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these. // Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group']); export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group', 'storage_location', 'group_storage_location']);
export function expandedRoute({kind, ...fields}) { export function expandedRoute({kind, ...fields}) {
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind]; const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
@ -188,12 +196,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: StorageLocation, component: StorageLocation,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, { }, {
path: '/storage-locations/:id', path: '/storage-locations/:handle/:id',
component: StorageLocationDetail, component: StorageLocationDetail,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
path: '/storage-locations/:id/edit', path: '/storage-locations/:handle/:id/edit',
component: StorageLocationEdit, component: StorageLocationEdit,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true

View file

@ -40,6 +40,12 @@ const SCHEMAS = {
fields: ['category_id'], fields: ['category_id'],
interpret: ([category_id]) => ({kind: 'category', category_id}) interpret: ([category_id]) => ({kind: 'category', category_id})
}, },
'4-2': {
name: 'group_storage_location',
fields: ['owner_group_id', 'storage_location_id'],
interpret: ([owner_group_id, storage_location_id]) =>
({kind: 'group_storage_location', owner_group_id, storage_location_id})
},
} }
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'

View file

@ -83,6 +83,7 @@ export default createStore({
idmap: {identities: [], groups: []}, idmap: {identities: [], groups: []},
idmapLoaded: false, idmapLoaded: false,
item_map: {}, item_map: {},
location_map: {},
home_servers: null, home_servers: null,
all_friends_servers: null, all_friends_servers: null,
messages: [], messages: [],
@ -105,6 +106,9 @@ export default createStore({
setInventoryItems(state, {url, items}) { setInventoryItems(state, {url, items}) {
state.item_map[url] = items; state.item_map[url] = items;
}, },
setLocationsForKey(state, {url, locations}) {
state.location_map[url] = locations;
},
setFriends(state, friends) { setFriends(state, friends) {
state.friends = friends; state.friends = friends;
}, },
@ -397,16 +401,17 @@ export default createStore({
return items return items
}, },
async createInventoryItem({state, dispatch, getters}, item) { async createInventoryItem({state, dispatch, getters}, item) {
const servers = await dispatch('getHomeServers') const servers = item.owner_group
const data = {availability_policy: 'private', ...item} ? 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 reply = await servers.post(getters.signAuth, '/api/inventory_items/', data)
state.last_load.files = 0 state.last_load.files = 0
return reply return reply
}, },
// A group-owned item lives on its owning group's own backend, not necessarily the
// caller's home one (see the group actions below) - item.owner_group ("#name@domain")
// already carries that domain, so update/delete resolve it the same way rather than
// needing the caller to track it separately.
async updateInventoryItem({state, dispatch, getters}, item) { async updateInventoryItem({state, dispatch, getters}, item) {
const servers = item.owner_group const servers = item.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
@ -465,16 +470,10 @@ export default createStore({
return null; return null;
} }
}, },
// A group handle resolves via fetchGroup (itself domain-routed, see below), so a group
// hosted elsewhere works exactly like one hosted on the caller's own backend - no idmap
// detour needed here. ServerSet.get() never checks response.ok, so a 404 (nonexistent or
// non-member group) surfaces as a JSON-parse rejection rather than resolving falsy - same
// try/catch shape as fetchForeignItem.
async fetchItemByHandle({dispatch, getters}, {handle, id}) { async fetchItemByHandle({dispatch, getters}, {handle, id}) {
if (handle.startsWith('#')) { if (handle.startsWith('#')) {
try { try {
const group = await dispatch('fetchGroup', {handle}) const items = await dispatch('fetchGroupInventoryItems', {groupHandle: handle})
const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain})
return items.find(item => item.id === parseInt(id)) || null return items.find(item => item.id === parseInt(id)) || null
} catch (error) { } catch (error) {
console.error(`Failed to fetch group item ${id} for ${handle}:`, error); console.error(`Failed to fetch group item ${id} for ${handle}:`, error);
@ -530,14 +529,6 @@ export default createStore({
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
}, },
// Listing/creating a group is inherently home-scoped - it's this backend's own view of
// "groups I host you in" - but every action below that acts on an *existing* group
// (found via its handle, e.g. from a GroupMembership pointer to a group hosted
// elsewhere - see GroupMembership/docs/design-in-progress/groups-mvp.md) resolves that
// group's own domain and talks to it directly, the same way requestFriend/
// fetchForeignItem resolve a friend's domain, rather than assuming home. A group hosted
// on the caller's own domain goes through exactly the same code path - there's nothing
// to special-case.
async fetchGroups({commit, dispatch, getters}) { async fetchGroups({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groups/') const data = await servers.get(getters.signAuth, '/api/groups/')
@ -552,17 +543,19 @@ export default createStore({
}, },
// handle is "#name@domain". // handle is "#name@domain".
async fetchGroup({dispatch, getters}, {handle}) { async fetchGroup({dispatch, getters}, {handle}) {
const {name, domain} = splitGroupHandle(handle) const {domain} = splitGroupHandle(handle)
const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
return await servers.get(getters.signAuth, `/api/groups/handle/${name}/${domain}/`) return await servers.get(getters.signAuth, `/api/groups/${handle.slice(1)}/`)
}, },
async createGroup({dispatch, getters}, {name}) { async createGroup({dispatch, getters}, {name}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
return await servers.post(getters.signAuth, '/api/groups/', {name}) return await servers.post(getters.signAuth, '/api/groups/', {name})
}, },
async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) { async removeGroupMember({dispatch, getters}, {groupHandle, identityId}) {
const {domain} = splitGroupHandle(groupHandle)
const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/') return await servers.delete(
getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/')
}, },
async fetchGroupInvites({commit, dispatch, getters}) { async fetchGroupInvites({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
@ -576,10 +569,11 @@ export default createStore({
commit('setGroupMemberships', data) commit('setGroupMemberships', data)
return data return data
}, },
async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) { async inviteToGroup({state, dispatch, getters}, {groupHandle, invitee}) {
const {domain} = splitGroupHandle(groupHandle)
const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const group_reply = await group_servers.post( const group_reply = await group_servers.post(
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee}) getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/invites/', {invitee})
if (!group_reply.secret) { if (!group_reply.secret) {
return false return false
} }
@ -610,13 +604,14 @@ export default createStore({
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
}, },
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) { async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) {
const {domain} = splitGroupHandle(groupHandle)
const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId) const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1))
// Namespaced by domain, not just groupId: that pk is only unique within its own // 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 // backend's database, so two different domains could otherwise collide on the same
// item_map key. // item_map key - the handle already carries the domain, so no separate namespacing is needed.
commit('setInventoryItems', {url: '/group/' + domain + '/' + groupId, items}) commit('setInventoryItems', {url: '/group/' + groupHandle, items})
return items return items
}, },
async fetchFiles({state, commit, dispatch, getters}) { async fetchFiles({state, commit, dispatch, getters}) {
@ -722,23 +717,50 @@ export default createStore({
commit('setStorageLocations', data) commit('setStorageLocations', data)
state.last_load.storage_locations = Date.now() state.last_load.storage_locations = Date.now()
return data return data
},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})
return locations
},
async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) {
if (handle.startsWith('#')) {
try {
const locations = await dispatch('fetchGroupStorageLocations', {groupHandle: handle})
return locations.find(location => location.id === parseInt(id)) || null
} catch (error) {
console.error(`Failed to fetch group storage location ${id} for ${handle}:`, error);
return null;
}
}
const locations = await dispatch('fetchStorageLocations')
return locations.find(location => location.id === parseInt(id)) || null
}, },
async deleteStorageLocation({state, dispatch, getters}, location) { async deleteStorageLocation({state, dispatch, getters}, location) {
const servers = await dispatch('getHomeServers') 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 + '/') const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/')
dispatch('fetchStorageLocations') dispatch('fetchStorageLocations')
return ret return ret
}, },
// location.owner_group is the group's own "#name@domain" handle - see
// createInventoryItem's own comment.
async createStorageLocation({state, dispatch, getters}, location) { async createStorageLocation({state, dispatch, getters}, location) {
const servers = await dispatch('getHomeServers') const servers = location.owner_group
const data = {...location} ? 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}
if (data.parent === '') data.parent = null 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/storage_locations/', data)
state.last_load.storage_locations = 0 state.last_load.storage_locations = 0
return reply return reply
}, },
async updateStorageLocation({state, dispatch, getters}, location) { async updateStorageLocation({state, dispatch, getters}, location) {
const servers = await dispatch('getHomeServers') const servers = location.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
: await dispatch('getHomeServers')
const data = {...location} const data = {...location}
if (data.parent === '') data.parent = null if (data.parent === '') data.parent = null
const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data) const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data)
@ -848,15 +870,14 @@ export default createStore({
inventory_items(state) { inventory_items(state) {
return state.item_map['/'] || [] return state.item_map['/'] || []
}, },
groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [], groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [],
groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [],
identityIdByHandle(state) { identityIdByHandle(state) {
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id])) return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
}, },
groupIdByHandle(state) { groupIdByHandle(state) {
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id])) return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
}, },
// Reverse of the two getters above: turns a short-id's raw owner_identity_id/owner_group_id
// back into a handle (see router.js EXPANDED_ROUTE_BUILDERS), no backend lookup needed.
identityHandleById(state) { 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.username]))
}, },

View file

@ -21,6 +21,7 @@ test('every kind round-trips through serialize/encode/decode/deserialize', () =>
{kind: 'group_item', owner_group_id: 5, item_local_id: 42}, {kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'group', group_id: 11}, {kind: 'group', group_id: 11},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000},
{kind: 'file', file_id: 123}, {kind: 'file', file_id: 123},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, {kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
] ]

View file

@ -127,7 +127,55 @@
</div> </div>
<div class="card"> <div class="card">
<button class="btn" @click="fetchItems">Refresh</button> <button class="btn" @click="fetchItems">Refresh</button>
<router-link v-if="isOwnDomain" :to="`/inventory/new?group=${group.id}`" <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 class="btn btn-primary">Add
</router-link> </router-link>
</div> </div>
@ -139,7 +187,7 @@
</template> </template>
<script> <script>
import {mapActions, mapGetters, mapState} from "vuex"; import {mapActions, mapGetters} from "vuex";
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import UserNameTag from "@/components/UserNameTag.vue"; import UserNameTag from "@/components/UserNameTag.vue";
@ -167,21 +215,21 @@ export default {
} }
}, },
computed: { computed: {
...mapState(['user']),
decodedHandle() { decodedHandle() {
return decodeHandleFromUrl(this.handle) return decodeHandleFromUrl(this.handle)
}, },
items() { items() {
return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : [] return this.group ? this.groupInventoryItems(this.group.handle) : []
}, },
isOwnDomain() { storageLocations() {
return this.group && this.group.domain === this.user.split('@')[1] return this.group ? this.groupStorageLocations(this.group.handle) : []
}, },
...mapGetters(['groupInventoryItems']) ...mapGetters(['groupInventoryItems', 'groupStorageLocations'])
}, },
methods: { methods: {
encodeHandleForUrl,
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems', ...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
'deleteInventoryItem']), 'deleteInventoryItem', 'fetchGroupStorageLocations', 'deleteStorageLocation']),
// Loads by handle regardless of which backend actually hosts the group - see // Loads by handle regardless of which backend actually hosts the group - see
// store.js's fetchGroup. // store.js's fetchGroup.
refresh() { refresh() {
@ -191,7 +239,7 @@ export default {
}, },
fetchItems() { fetchItems() {
if (this.group) { if (this.group) {
this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain}) this.fetchGroupInventoryItems({groupHandle: this.group.handle})
} }
}, },
itemRoute(item) { itemRoute(item) {
@ -200,11 +248,24 @@ export default {
printLinkFor(item) { printLinkFor(item) {
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}} 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() { tryInvite() {
this.inviteToGroup({ this.inviteToGroup({groupHandle: this.group.handle, invitee: this.invitee}).then((ok) => {
groupId: this.group.id, domain: this.group.domain, groupHandle: this.group.handle,
invitee: this.invitee
}).then((ok) => {
if (ok) { if (ok) {
this.show_invite = false this.show_invite = false
this.invitee = "" this.invitee = ""
@ -213,7 +274,7 @@ export default {
}) })
}, },
tryRemoveMember(member) { tryRemoveMember(member) {
this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id}) this.removeGroupMember({groupHandle: this.group.handle, identityId: member.id})
.then(() => { .then(() => {
this.refresh() this.refresh()
}).catch(() => { }).catch(() => {
@ -226,7 +287,10 @@ export default {
} }
}, },
mounted() { mounted() {
this.refresh().then(() => this.fetchItems()) this.refresh().then(() => {
this.fetchItems()
this.fetchLocations()
})
} }
} }
</script> </script>

View file

@ -28,7 +28,7 @@
<label for="owner" class="form-label">Owner</label> <label for="owner" class="form-label">Owner</label>
<select class="form-select" id="owner" name="owner" v-model="item.owner_group"> <select class="form-select" id="owner" name="owner" v-model="item.owner_group">
<option :value="null">Myself</option> <option :value="null">Myself</option>
<option v-for="group in groups" :value="group.id" :key="group.id"> <option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }} {{ group.handle }}
</option> </option>
</select> </select>
@ -84,7 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue"; import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue"; import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue"; import CombinedFileField from "@/components/CombinedFileField.vue";
import {ownerOverviewRoute} from "@/router"; import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default { export default {
name: "InventoryNew", name: "InventoryNew",
@ -107,23 +107,34 @@ export default {
properties: [], properties: [],
files: [], files: [],
storage_location: null, storage_location: null,
// The group's own "#name@domain" handle once picked, or null for a personal
// item - createInventoryItem resolves the target domain from it directly.
owner_group: null owner_group: null
} }
} }
}, },
methods: { methods: {
ownerOverviewRoute, ownerOverviewRoute,
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups']) ...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
'fetchGroupMemberships'])
}, },
computed: { computed: {
...mapState(["availability_policies", "storage_locations", "groups"]), ...mapState(["availability_policies", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (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))
}
}, },
async mounted() { async mounted() {
await this.fetchInfo(); await this.fetchInfo();
await this.fetchStorageLocations(); await this.fetchStorageLocations();
await this.fetchGroups(); await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.$route.query.group) { if (this.$route.query.group) {
this.item.owner_group = parseInt(this.$route.query.group, 10) this.item.owner_group = decodeHandleFromUrl(this.$route.query.group)
} }
} }
} }

View file

@ -464,10 +464,18 @@ export default {
} }
if (f.userHandle.startsWith("#")) { if (f.userHandle.startsWith("#")) {
const owner_group_id = this.groupIdByHandle[f.userHandle]; const owner_group_id = this.groupIdByHandle[f.userHandle];
if (owner_group_id === undefined || !f.itemId) { if (owner_group_id === undefined) {
return null; return null;
} }
return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1); 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
}).slice(1);
}
return null;
} }
const owner_identity_id = this.identityIdByHandle[f.userHandle]; const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) { if (owner_identity_id === undefined) {

View file

@ -154,7 +154,7 @@ export default {
}; };
}, },
methods: { methods: {
...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]), ...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]),
resolveDescription(entry) { resolveDescription(entry) {
const {link, text} = entry; const {link, text} = entry;
@ -219,12 +219,19 @@ export default {
} }
return handle === undefined ? null : handle; return handle === undefined ? null : handle;
} }
if (decoded.kind === "storage_location") { if (decoded.kind === "storage_location" || decoded.kind === "group_storage_location") {
if (!this.$store.state.storage_locations.length) { const byId = decoded.kind === "storage_location"
await this.fetchStorageLocations(); ? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "storage_location" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "storage_location"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
} }
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id); return handle === undefined ? null : this.describeLocation(handle, decoded.storage_location_id);
return location ? `[#${location.id}] ${location.name}` : null;
} }
return null; // workflow, category, file: no per-item title lookup wired up yet return null; // workflow, category, file: no per-item title lookup wired up yet
}, },
@ -234,6 +241,11 @@ export default {
return item ? `[#${item.id}] ${item.name}` : null; return item ? `[#${item.id}] ${item.name}` : null;
}, },
async describeLocation(handle, id) {
const location = await this.fetchStorageLocationByHandle({handle, id});
return location ? `[#${location.id}] ${location.name}` : null;
},
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded. // Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
onCameraScan(codes) { onCameraScan(codes) {
codes.forEach(this.logDecode); codes.forEach(this.logDecode);

View file

@ -73,6 +73,7 @@ const EXAMPLES = [
{kind: 'group_item', owner_group_id: 5, item_local_id: 42}, {kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'file', file_id: 123}, {kind: 'file', file_id: 123},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}, {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000},
{kind: 'category', category_id: 5}, {kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9}, {kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
]; ];

View file

@ -27,7 +27,7 @@
<tbody> <tbody>
<tr v-for="location in storage_locations" :key="location.id"> <tr v-for="location in storage_locations" :key="location.id">
<td> <td>
<router-link :to="`/storage-locations/${location.id}`">{{ location.name }}</router-link> <router-link :to="locationRoute(location)">{{ location.name }}</router-link>
</td> </td>
<td class="d-none d-md-table-cell"> <td class="d-none d-md-table-cell">
<span class="text-muted">{{ location.path }}</span> <span class="text-muted">{{ location.path }}</span>
@ -37,10 +37,10 @@
<span class="text-muted" v-else>-</span> <span class="text-muted" v-else>-</span>
</td> </td>
<td class="table-action"> <td class="table-action">
<router-link :to="`/storage-locations/${location.id}/edit`"> <router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
</router-link> </router-link>
<a :href="`/storage-locations/${location.id}/delete`" @click.prevent="deleteStorageLocation(location)"> <a :href="`${locationRoute(location)}/delete`" @click.prevent="deleteStorageLocation(location)">
<b-icon-trash></b-icon-trash> <b-icon-trash></b-icon-trash>
</a> </a>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"> <router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
@ -60,7 +60,7 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<router-link :to="`/storage-locations/${location.id}`"> <router-link :to="locationRoute(location)">
{{ location.name }} {{ location.name }}
</router-link> </router-link>
</h5> </h5>
@ -75,7 +75,7 @@
<button class="btn btn-danger btn-sm" <button class="btn btn-danger btn-sm"
@click="deleteStorageLocation(location.id)">Delete @click="deleteStorageLocation(location.id)">Delete
</button> </button>
<router-link :to="`/storage-locations/${location.id}/edit`" <router-link :to="`${locationRoute(location)}/edit`"
class="btn btn-primary btn-sm">Edit class="btn btn-primary btn-sm">Edit
</router-link> </router-link>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)" <router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
@ -109,7 +109,7 @@
import {mapActions, mapGetters, mapState} from "vuex"; import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {shortenedRoute} from "@/router"; import {shortenedRoute, encodeHandleForUrl} from "@/router";
export default { export default {
name: "StorageLocation", name: "StorageLocation",
@ -123,12 +123,25 @@ export default {
...BIcons ...BIcons
}, },
computed: { computed: {
...mapGetters(["identityIdByHandle"]), ...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations"]), ...mapState(["user", "storage_locations"]),
}, },
methods: { methods: {
...mapActions(["fetchStorageLocations", "deleteStorageLocation", "fetchIdMap"]), ...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.
locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}`
},
shortIdLink(location) { shortIdLink(location) {
if (location.owner_group) {
const owner_group_id = this.groupIdByHandle[location.owner_group]
if (owner_group_id === undefined) return null
return shortenedRoute({
kind: 'group_storage_location', owner_group_id, storage_location_id: location.id
})
}
const owner_identity_id = this.identityIdByHandle[location.owner] const owner_identity_id = this.identityIdByHandle[location.owner]
if (owner_identity_id === undefined) return null if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id}) return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
@ -136,7 +149,8 @@ export default {
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's // 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-for-storage-locations.
printLinkFor(location) { printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}} const userHandle = location.owner_group || location.owner
return {path: '/print', query: {kind: 'storage-location', userHandle, location: location.id}}
}, },
}, },
async mounted() { async mounted() {

View file

@ -23,20 +23,25 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="owner" class="form-label">Owner</label> <label for="owner" class="form-label">Owner</label>
<div>{{ location.owner?.username || '-' }}</div> <div>{{ location.owner || location.owner_group || '-' }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <div class="card" v-if="canEdit">
<button class="btn btn-primary" @click="$router.push('/storage-locations/' + id + '/edit')"> <button class="btn btn-primary" @click="$router.push(`/storage-locations/${handle}/${id}/edit`)">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
Edit Edit
</button> </button>
<button type="submit" class="btn btn-danger" <button type="submit" class="btn btn-danger"
@click="deleteStorageLocation(location).then(() => $router.push('/storage-location'))"> @click="deleteStorageLocation(location).then(() => $router.push(ownerOverviewRoute(location)))">
<b-icon-trash></b-icon-trash> <b-icon-trash></b-icon-trash>
Delete Delete
</button> </button>
<button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'storage-location', userHandle: decodedHandle, location: id}})">
<b-icon-printer></b-icon-printer>
Print label
</button>
</div> </div>
</div> </div>
</div> </div>
@ -47,7 +52,8 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapGetters, mapState} from "vuex";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default { export default {
name: "StorageLocationDetail", name: "StorageLocationDetail",
@ -56,22 +62,52 @@ export default {
...BIcons ...BIcons
}, },
props: { props: {
handle: {
type: String,
required: true
},
id: { id: {
type: String, type: String,
required: true required: true
} }
}, },
data() {
return {
location: {}
}
},
computed: { computed: {
...mapState(["storage_locations"]), ...mapGetters(["groupIdByHandle"]),
location() { ...mapState(["user", "groupMemberships"]),
return this.storage_locations.find(loc => loc.id === parseInt(this.id)) || {} decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
// Am I a member of this group, hosted here or elsewhere - see InventoryDetail.vue's
// isGroupMember for the same check on the item side.
isGroupMember() {
return this.decodedHandle in this.groupIdByHandle
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
},
canEdit() {
return this.decodedHandle === this.user || this.isGroupMember
} }
}, },
methods: { methods: {
...mapActions(["fetchStorageLocations", "deleteStorageLocation"]), ownerOverviewRoute,
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
async loadLocation() {
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
}
}, },
async mounted() { watch: {
await this.fetchStorageLocations() handle: 'loadLocation',
id: 'loadLocation'
},
mounted() {
this.loadLocation()
if (this.decodedHandle.startsWith('#')) {
this.fetchGroupMemberships()
}
} }
} }
</script> </script>

View file

@ -55,6 +55,7 @@
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default { export default {
name: "StorageLocationEdit", name: "StorageLocationEdit",
@ -63,32 +64,56 @@ export default {
...BIcons ...BIcons
}, },
props: { props: {
handle: {
type: String,
required: true
},
id: { id: {
type: String, type: String,
required: true required: true
} }
}, },
computed: { data() {
...mapState(["storage_locations", "categories"]), return {
location() { location: {
return {
name: "", name: "",
description: "", description: "",
category: null, category: null,
parent: null, parent: null,
...this.storage_locations.find(loc => loc.id === parseInt(this.id)) },
} // The location's own owner's full location list, for the parent dropdown - own
// personal locations, or the owning group's own (see loadLocation).
siblingLocations: []
}
},
computed: {
...mapState(["categories"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
}, },
availableParents() { availableParents() {
// Filter out self and children to prevent circular references // Filter out self and children to prevent circular references
return this.storage_locations.filter(loc => return this.siblingLocations.filter(loc =>
loc.id !== parseInt(this.id) && loc.id !== parseInt(this.id) &&
!this.isChildOf(loc, parseInt(this.id)) !this.isChildOf(loc, parseInt(this.id))
) )
} }
}, },
methods: { methods: {
...mapActions(["fetchStorageLocations", "updateStorageLocation", "fetchInfo"]), ownerOverviewRoute,
...mapActions(["fetchStorageLocationByHandle", "updateStorageLocation", "fetchInfo",
"fetchStorageLocations", "fetchGroupStorageLocations"]),
async loadLocation() {
const loaded = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id})
if (loaded) {
this.location = {...this.location, ...loaded}
}
if (this.decodedHandle.startsWith('#')) {
this.siblingLocations = await this.fetchGroupStorageLocations({groupHandle: this.decodedHandle})
} else {
this.siblingLocations = await this.fetchStorageLocations()
}
},
submitForm() { submitForm() {
// Convert empty strings to null for ForeignKey fields // Convert empty strings to null for ForeignKey fields
const locationData = { const locationData = {
@ -96,21 +121,25 @@ export default {
category: this.location.category === "" ? null : this.location.category, category: this.location.category === "" ? null : this.location.category,
parent: this.location.parent === "" ? null : this.location.parent parent: this.location.parent === "" ? null : this.location.parent
}; };
this.updateStorageLocation(locationData); this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerOverviewRoute(updated)));
}, },
isChildOf(location, parentId) { isChildOf(location, parentId) {
// Simple check to prevent circular references // Simple check to prevent circular references
let current = location; let current = location;
while (current && current.parent) { while (current && current.parent) {
if (current.parent === parentId) return true; if (current.parent === parentId) return true;
current = this.storage_locations.find(loc => loc.id === current.parent); current = this.siblingLocations.find(loc => loc.id === current.parent);
} }
return false; return false;
} }
}, },
watch: {
handle: 'loadLocation',
id: 'loadLocation'
},
async mounted() { async mounted() {
await this.fetchInfo(); await this.fetchInfo();
await this.fetchStorageLocations(); await this.loadLocation();
} }
} }
</script> </script>

View file

@ -26,12 +26,21 @@
</option> </option>
</select> </select>
</div> </div>
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
<select class="form-select" id="owner" name="owner" v-model="location.owner_group">
<option :value="null">Myself</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="parent" class="form-label">Parent Location</label> <label for="parent" class="form-label">Parent Location</label>
<select class="form-select" id="parent" name="parent" <select class="form-select" id="parent" name="parent"
v-model="location.parent"> v-model="location.parent">
<option value="">No Parent</option> <option value="">No Parent</option>
<option v-for="parent in storage_locations" :value="parent.id"> <option v-for="parent in availableParents" :value="parent.id">
{{ parent.path }} {{ parent.path }}
</option> </option>
</select> </select>
@ -53,6 +62,7 @@
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex"; import {mapActions, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default { export default {
name: "StorageLocationNew", name: "StorageLocationNew",
@ -66,12 +76,27 @@ export default {
name: "", name: "",
description: "", description: "",
category: null, category: null,
parent: null parent: null,
} // The group's own "#name@domain" handle once picked, or null for a personal
// location - createStorageLocation resolves the target domain from it directly.
owner_group: null
},
// The selected owner's own location list, for the parent dropdown - own personal
// locations, or the chosen group's own (see loadParentOptionsForOwner).
groupLocations: []
} }
}, },
methods: { methods: {
...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations']), ownerOverviewRoute,
...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
'fetchGroupMemberships', 'fetchGroupStorageLocations']),
async loadParentOptionsForOwner() {
if (!this.location.owner_group) {
this.groupLocations = []
return
}
this.groupLocations = await this.fetchGroupStorageLocations({groupHandle: this.location.owner_group})
},
submitForm() { submitForm() {
// Convert empty strings to null for ForeignKey fields // Convert empty strings to null for ForeignKey fields
const locationData = { const locationData = {
@ -79,15 +104,37 @@ export default {
category: this.location.category === "" ? null : this.location.category, category: this.location.category === "" ? null : this.location.category,
parent: this.location.parent === "" ? null : this.location.parent parent: this.location.parent === "" ? null : this.location.parent
}; };
this.createStorageLocation(locationData).then(() => this.$router.push('/storage-location')); this.createStorageLocation(locationData).then(created => this.$router.push(ownerOverviewRoute(created)));
} }
}, },
computed: { computed: {
...mapState(["categories", "storage_locations"]), ...mapState(["categories", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (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))
},
availableParents() {
return this.location.owner_group ? this.groupLocations : this.storage_locations
}
},
watch: {
'location.owner_group'() {
// Previously-selected parent likely doesn't belong to the newly-chosen owner.
this.location.parent = null
this.loadParentOptionsForOwner()
}
}, },
async mounted() { async mounted() {
await this.fetchInfo(); await this.fetchInfo();
await this.fetchStorageLocations(); await this.fetchStorageLocations();
await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.$route.query.group) {
this.location.owner_group = decodeHandleFromUrl(this.$route.query.group)
}
} }
} }
</script> </script>