This commit is contained in:
j3d1 2026-08-26 22:18:55 +02:00
parent 23185e1721
commit 9f3dc7c993
8 changed files with 335 additions and 103 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)