stash
This commit is contained in:
parent
6d2167ac66
commit
4787acd8eb
23 changed files with 1241 additions and 25 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||||
AccountPreference
|
AccountPreference, Group, GroupInvite, GroupInviteIncoming
|
||||||
|
|
||||||
|
|
||||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||||
|
|
@ -29,8 +29,30 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin):
|
||||||
search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key')
|
search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key')
|
||||||
|
|
||||||
|
|
||||||
|
class GroupAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'domain', 'get_members')
|
||||||
|
search_fields = ('name', 'domain', 'members__username')
|
||||||
|
|
||||||
|
@admin.display(description='Members')
|
||||||
|
def get_members(self, obj):
|
||||||
|
return ', '.join(str(member) for member in obj.members.all())
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInviteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('secret', 'group', 'invitee_username', 'invitee_domain')
|
||||||
|
search_fields = ('secret', 'group__name', 'invitee_username', 'invitee_domain')
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInviteIncomingAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
|
||||||
|
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(ToolshedUser, ToolshedUserAdmin)
|
admin.site.register(ToolshedUser, ToolshedUserAdmin)
|
||||||
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
||||||
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
||||||
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
||||||
admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
||||||
|
admin.site.register(Group, GroupAdmin)
|
||||||
|
admin.site.register(GroupInvite, GroupInviteAdmin)
|
||||||
|
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-08-19 13:44
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0004_alter_accountpreference_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Group',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('domain', models.CharField(max_length=255)),
|
||||||
|
('members', models.ManyToManyField(related_name='member_of_groups', to='authentication.knownidentity')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GroupInviteIncoming',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('secret', models.CharField(max_length=255)),
|
||||||
|
('group_name', models.CharField(max_length=255)),
|
||||||
|
('group_domain', models.CharField(max_length=255)),
|
||||||
|
('inviter_username', models.CharField(max_length=255)),
|
||||||
|
('inviter_domain', models.CharField(max_length=255)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('invitee_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_invites_incoming', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GroupInvite',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('secret', models.CharField(max_length=255)),
|
||||||
|
('invitee_username', models.CharField(max_length=255)),
|
||||||
|
('invitee_domain', models.CharField(max_length=255)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='authentication.group')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='group',
|
||||||
|
index=models.Index(fields=['name', 'domain'], name='group_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='group',
|
||||||
|
unique_together={('name', 'domain')},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -144,3 +144,42 @@ class FriendRequestIncoming(models.Model):
|
||||||
befriender_public_key = models.CharField(max_length=255)
|
befriender_public_key = models.CharField(max_length=255)
|
||||||
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
|
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Group(models.Model):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
domain = models.CharField(max_length=255)
|
||||||
|
members = models.ManyToManyField(KnownIdentity, related_name='member_of_groups')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('name', 'domain')
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['name', 'domain'], name='group_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.name}@{self.domain}"
|
||||||
|
|
||||||
|
def is_member(self, identity):
|
||||||
|
return self.members.filter(pk=identity.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInvite(models.Model):
|
||||||
|
"""A pending invite tracked on the group's own home backend, checked when the invitee's
|
||||||
|
accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own
|
||||||
|
backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance)."""
|
||||||
|
secret = models.CharField(max_length=255)
|
||||||
|
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
|
||||||
|
invitee_username = models.CharField(max_length=255)
|
||||||
|
invitee_domain = models.CharField(max_length=255)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInviteIncoming(models.Model):
|
||||||
|
secret = models.CharField(max_length=255)
|
||||||
|
group_name = models.CharField(max_length=255)
|
||||||
|
group_domain = models.CharField(max_length=255)
|
||||||
|
inviter_username = models.CharField(max_length=255)
|
||||||
|
inviter_domain = models.CharField(max_length=255)
|
||||||
|
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from authentication.models import ToolshedUser
|
from authentication.models import ToolshedUser, Group
|
||||||
|
|
||||||
|
|
||||||
class OwnerSerializer(serializers.ReadOnlyField):
|
class OwnerSerializer(serializers.ReadOnlyField):
|
||||||
|
|
@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField):
|
||||||
|
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
return value.username + '@' + value.domain
|
return value.username + '@' + value.domain
|
||||||
|
|
||||||
|
|
||||||
|
class GroupOwnerSerializer(serializers.ReadOnlyField):
|
||||||
|
class Meta:
|
||||||
|
model = Group
|
||||||
|
fields = ['name', 'domain']
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return f"#{value.name}@{value.domain}"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ def split_userhandle_or_throw(userhandle):
|
||||||
return username, domain
|
return username, domain
|
||||||
|
|
||||||
|
|
||||||
|
def split_grouphandle_or_throw(grouphandle):
|
||||||
|
if not grouphandle.startswith('#'):
|
||||||
|
raise ValueError('Group handle must be in the format #name@domain')
|
||||||
|
return split_userhandle_or_throw(grouphandle[1:])
|
||||||
|
|
||||||
|
|
||||||
def verify_request(request, raw_request_body):
|
def verify_request(request, raw_request_body):
|
||||||
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
||||||
|
|
||||||
|
|
@ -74,6 +80,36 @@ def verify_incoming_friend_request(request, raw_request_body):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
|
||||||
|
"""Self-certifying verifier for the two legs of the group invite/accept dance that land on a
|
||||||
|
backend which doesn't have the caller cached as a KnownIdentity yet (see
|
||||||
|
docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own
|
||||||
|
backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the
|
||||||
|
group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors
|
||||||
|
verify_incoming_friend_request exactly, just with configurable field names."""
|
||||||
|
try:
|
||||||
|
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
claimed_handle = request.data[handle_field]
|
||||||
|
claimed_key = request.data[key_field]
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
if not claimed_handle or not claimed_key:
|
||||||
|
return False
|
||||||
|
if username + "@" + domain != claimed_handle:
|
||||||
|
return False
|
||||||
|
if len(claimed_key) != 64:
|
||||||
|
return False
|
||||||
|
verify_key = VerifyKey(bytes.fromhex(claimed_key))
|
||||||
|
try:
|
||||||
|
verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex))
|
||||||
|
return True
|
||||||
|
except BadSignatureError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def authenticate_request_against_known_identities(request, raw_request_body):
|
def authenticate_request_against_known_identities(request, raw_request_body):
|
||||||
try:
|
try:
|
||||||
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
|
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import json
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from nacl.encoding import HexEncoder
|
from nacl.encoding import HexEncoder
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity, Group
|
||||||
from hostadmin.models import Domain
|
from hostadmin.models import Domain
|
||||||
from nacl.signing import SigningKey
|
from nacl.signing import SigningKey
|
||||||
|
|
||||||
|
|
@ -86,3 +86,9 @@ class UserTestMixin:
|
||||||
domain=self.f['example_com'].name)
|
domain=self.f['example_com'].name)
|
||||||
self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org')
|
self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org')
|
||||||
self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org')
|
self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org')
|
||||||
|
|
||||||
|
|
||||||
|
class GroupTestMixin:
|
||||||
|
def prepare_groups(self):
|
||||||
|
self.f['group1'] = Group.objects.create(name='group1', domain=self.f['example_com'].name)
|
||||||
|
self.f['group1'].members.add(self.f['local_user1'].public_identity)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ urlpatterns = [
|
||||||
path('auth/', include('authentication.api')),
|
path('auth/', include('authentication.api')),
|
||||||
path('admin/', include('hostadmin.api')),
|
path('admin/', include('hostadmin.api')),
|
||||||
path('api/', include('toolshed.api.friend')),
|
path('api/', include('toolshed.api.friend')),
|
||||||
|
path('api/', include('toolshed.api.group')),
|
||||||
path('api/', include('toolshed.api.inventory')),
|
path('api/', include('toolshed.api.inventory')),
|
||||||
path('api/', include('toolshed.api.info')),
|
path('api/', include('toolshed.api.info')),
|
||||||
path('api/', include('toolshed.api.files')),
|
path('api/', include('toolshed.api.files')),
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ class ItemPropertyInline(admin.TabularInline):
|
||||||
|
|
||||||
class InventoryItemAdmin(admin.ModelAdmin):
|
class InventoryItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
||||||
'storage_location', 'get_tags', 'get_properties')
|
'owner_group', 'storage_location', 'get_tags', 'get_properties')
|
||||||
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
|
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
|
||||||
'storage_location__name', 'tags__name', 'itemproperty__property__name')
|
'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__name')
|
||||||
inlines = (ItemTagInline, ItemPropertyInline)
|
inlines = (ItemTagInline, ItemPropertyInline)
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
|
|
|
||||||
165
backend/toolshed/api/group.py
Normal file
165
backend/toolshed/api/group.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||||
|
from rest_framework.generics import get_object_or_404
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ViewSetMixin
|
||||||
|
|
||||||
|
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity, ToolshedUser
|
||||||
|
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
|
||||||
|
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
|
||||||
|
verify_incoming_group_invite
|
||||||
|
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class Groups(APIView, ViewSetMixin):
|
||||||
|
authentication_classes = [SignatureAuthentication]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request, format=None): # /api/groups/
|
||||||
|
groups = request.user.member_of_groups.all()
|
||||||
|
return Response(GroupSerializer(groups, many=True).data)
|
||||||
|
|
||||||
|
def post(self, request, format=None): # /api/groups/
|
||||||
|
name = request.data.get('name')
|
||||||
|
if not name:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'})
|
||||||
|
if Group.objects.filter(name=name, domain=request.user.domain).exists():
|
||||||
|
return Response(status=status.HTTP_409_CONFLICT, data={'status': 'a group with this name already exists'})
|
||||||
|
group = Group.objects.create(name=name, domain=request.user.domain)
|
||||||
|
group.members.add(request.user)
|
||||||
|
return Response(status=status.HTTP_201_CREATED, data=GroupSerializer(group).data)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupDetail(APIView, ViewSetMixin):
|
||||||
|
authentication_classes = [SignatureAuthentication]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request, pk, format=None): # /api/groups/<pk>/
|
||||||
|
group = get_object_or_404(Group, pk=pk)
|
||||||
|
if not group.is_member(request.user):
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return Response(GroupSerializer(group).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def removeGroupMember(request, pk, identity_id, format=None): # /api/groups/<pk>/members/<identity_id>/
|
||||||
|
group = get_object_or_404(Group, pk=pk)
|
||||||
|
if not group.is_member(request.user):
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
member = get_object_or_404(group.members, pk=identity_id)
|
||||||
|
if group.members.count() <= 1:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
data={'status': "cannot remove the group's last member"})
|
||||||
|
group.members.remove(member)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def createGroupInvite(request, pk, format=None): # /api/groups/<pk>/invites/
|
||||||
|
group = get_object_or_404(Group, pk=pk)
|
||||||
|
if not group.is_member(request.user):
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
invitee = request.data.get('invitee')
|
||||||
|
if not invitee:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invitee is required'})
|
||||||
|
try:
|
||||||
|
invitee_username, invitee_domain = split_userhandle_or_throw(invitee)
|
||||||
|
except ValueError:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid invitee handle'})
|
||||||
|
if group.members.filter(username=invitee_username, domain=invitee_domain).exists():
|
||||||
|
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'})
|
||||||
|
secret = secrets.token_hex(64)
|
||||||
|
GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain,
|
||||||
|
secret=secret)
|
||||||
|
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'})
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInvitesIncoming(APIView, ViewSetMixin):
|
||||||
|
"""/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery
|
||||||
|
endpoint an inviter's client posts to directly on the invitee's own home backend (see
|
||||||
|
docs/design-in-progress/groups-mvp.md's invite/accept dance)."""
|
||||||
|
def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites
|
||||||
|
raw_request = request.body.decode('utf-8')
|
||||||
|
if not (user := authenticate_request_against_local_users(request, raw_request)):
|
||||||
|
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
||||||
|
invites = user.group_invites_incoming.all()
|
||||||
|
return Response(GroupInviteIncomingSerializer(invites, many=True).data)
|
||||||
|
|
||||||
|
def post(self, request, format=None): # /api/groupinvites/ - delivery, self-certified, caller isn't known here
|
||||||
|
raw_request = request.body.decode('utf-8')
|
||||||
|
for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'):
|
||||||
|
if field not in request.data:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
|
||||||
|
if not verify_incoming_group_invite(request, raw_request, 'inviter', 'inviter_key'):
|
||||||
|
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
||||||
|
try:
|
||||||
|
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
|
||||||
|
inviter_username, inviter_domain = split_userhandle_or_throw(request.data['inviter'])
|
||||||
|
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
|
||||||
|
except ValueError:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
|
||||||
|
try:
|
||||||
|
invitee_user = ToolshedUser.objects.get(username=invitee_username, domain=invitee_domain)
|
||||||
|
except ToolshedUser.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'invitee is not local to this server'})
|
||||||
|
GroupInviteIncoming.objects.create(
|
||||||
|
group_name=group_name, group_domain=group_domain,
|
||||||
|
inviter_username=inviter_username, inviter_domain=inviter_domain,
|
||||||
|
invitee_user=invitee_user, secret=request.data['secret'])
|
||||||
|
return Response(status=status.HTTP_201_CREATED, data={'status': 'delivered'})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
|
||||||
|
get_object_or_404(request.user.group_invites_incoming, pk=pk).delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
|
||||||
|
raw_request = request.body.decode('utf-8')
|
||||||
|
for field in ('group', 'invitee', 'invitee_key', 'secret'):
|
||||||
|
if field not in request.data:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
|
||||||
|
if not verify_incoming_group_invite(request, raw_request, 'invitee', 'invitee_key'):
|
||||||
|
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
||||||
|
try:
|
||||||
|
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
|
||||||
|
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
|
||||||
|
except ValueError:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(name=group_name, domain=group_domain)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no such group here'})
|
||||||
|
invite = GroupInvite.objects.filter(group=group, invitee_username=invitee_username,
|
||||||
|
invitee_domain=invitee_domain, secret=request.data['secret'])
|
||||||
|
if not invite.exists():
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no matching invite'})
|
||||||
|
identity, _ = KnownIdentity.objects.get_or_create(
|
||||||
|
username=invitee_username, domain=invitee_domain, public_key=request.data['invitee_key'])
|
||||||
|
group.members.add(identity)
|
||||||
|
invite.delete()
|
||||||
|
return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'})
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('groups/', Groups.as_view(), name='groups'),
|
||||||
|
path('groups/<int:pk>/', GroupDetail.as_view(), name='group_detail'),
|
||||||
|
path('groups/<int:pk>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
|
||||||
|
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
|
||||||
|
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
|
||||||
|
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
|
||||||
|
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
|
||||||
|
]
|
||||||
|
|
@ -2,10 +2,11 @@ from django.db import transaction
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework import routers, viewsets, status
|
from rest_framework import routers, viewsets, status
|
||||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
||||||
|
from rest_framework.exceptions import NotFound, PermissionDenied
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity, Group
|
||||||
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
|
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||||
|
|
@ -36,21 +37,61 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
# A KnownIdentity acting purely as a group member (e.g. a remote member on a group
|
||||||
return InventoryItem.objects.filter(owner=self.request.user.user.get())
|
# hosted on this backend) never has a local ToolshedUser account here - group-owned
|
||||||
|
# items must stay reachable for such an identity, only personal ("owner=...") items
|
||||||
|
# require .user.exists().
|
||||||
|
if type(self.request.user) != KnownIdentity:
|
||||||
|
return InventoryItem.objects.none()
|
||||||
|
identity = self.request.user
|
||||||
|
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
|
||||||
|
if self.action != 'list':
|
||||||
|
# retrieve/update/destroy: anything the caller may act on - their own items, or any
|
||||||
|
# group they're currently a member of. The narrower per-group listing below is only
|
||||||
|
# for the list action, so the main Inventory page stays scoped to personal items.
|
||||||
|
if identity.user.exists():
|
||||||
|
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
|
||||||
|
return group_items
|
||||||
|
group_id = self.request.query_params.get('group')
|
||||||
|
if group_id:
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(pk=group_id)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
return InventoryItem.objects.none()
|
||||||
|
if not group.is_member(identity):
|
||||||
|
return InventoryItem.objects.none()
|
||||||
|
return InventoryItem.objects.filter(owner_group=group)
|
||||||
|
if identity.user.exists():
|
||||||
|
return InventoryItem.objects.filter(owner=identity.user.get())
|
||||||
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')
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
serializer.save(owner=self.request.user.user.get()).clean()
|
if group_id:
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(pk=group_id)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
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().clean()
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-08-19 13:44
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
|
||||||
|
('toolshed', '0011_workflowinstance_staged_files'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inventoryitem',
|
||||||
|
name='owner_group',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='authentication.group'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inventoryitem',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -3,7 +3,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from django_softdelete.models import SoftDeleteModel
|
from django_softdelete.models import SoftDeleteModel
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity, Group
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,7 +97,10 @@ class InventoryItem(SoftDeleteModel):
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
|
||||||
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
|
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
|
||||||
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
|
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
|
||||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
related_name='inventory_items')
|
||||||
|
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
related_name='inventory_items')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
||||||
properties = models.ManyToManyField(Property, through='ItemProperty')
|
properties = models.ManyToManyField(Property, through='ItemProperty')
|
||||||
|
|
@ -108,6 +111,8 @@ class InventoryItem(SoftDeleteModel):
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if (self.name is None or self.name == "") and self.files.count() == 0:
|
if (self.name is None or self.name == "") and self.files.count() == 0:
|
||||||
raise ValidationError("Name or at least one file must be set")
|
raise ValidationError("Name or at least one file must be set")
|
||||||
|
if (self.owner is None) == (self.owner_group is None):
|
||||||
|
raise ValidationError("Exactly one of owner or owner_group must be set")
|
||||||
|
|
||||||
|
|
||||||
class ItemProperty(models.Model):
|
class ItemProperty(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
||||||
from authentication.serializers import OwnerSerializer
|
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from files.serializers import FileSerializer
|
from files.serializers import FileSerializer
|
||||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
|
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
|
||||||
|
|
@ -68,6 +68,45 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
||||||
return obj.befriender_username + '@' + obj.befriender_domain
|
return obj.befriender_username + '@' + obj.befriender_domain
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberSerializer(serializers.ModelSerializer):
|
||||||
|
username = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = KnownIdentity
|
||||||
|
fields = ['id', 'username', 'public_key']
|
||||||
|
|
||||||
|
def get_username(self, obj):
|
||||||
|
return obj.username + '@' + obj.domain
|
||||||
|
|
||||||
|
|
||||||
|
class GroupSerializer(serializers.ModelSerializer):
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
members = GroupMemberSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Group
|
||||||
|
fields = ['id', 'name', 'domain', 'handle', 'members']
|
||||||
|
read_only_fields = ['domain', 'handle', 'members']
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInviteIncomingSerializer(serializers.ModelSerializer):
|
||||||
|
group = serializers.SerializerMethodField()
|
||||||
|
inviter = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = GroupInviteIncoming
|
||||||
|
fields = ['id', 'group', 'inviter', 'secret']
|
||||||
|
|
||||||
|
def get_group(self, obj):
|
||||||
|
return f"#{obj.group_name}@{obj.group_domain}"
|
||||||
|
|
||||||
|
def get_inviter(self, obj):
|
||||||
|
return obj.inviter_username + '@' + obj.inviter_domain
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = serializers.SerializerMethodField()
|
category = serializers.SerializerMethodField()
|
||||||
handle = serializers.SerializerMethodField()
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
@ -139,6 +178,7 @@ class ItemPropertySerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class InventoryItemSerializer(serializers.ModelSerializer):
|
class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
owner = OwnerSerializer(read_only=True)
|
owner = OwnerSerializer(read_only=True)
|
||||||
|
owner_group = GroupOwnerSerializer(read_only=True)
|
||||||
tags = serializers.SerializerMethodField()
|
tags = serializers.SerializerMethodField()
|
||||||
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
|
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
|
||||||
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
||||||
|
|
@ -147,8 +187,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy',
|
||||||
'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
||||||
|
|
||||||
def get_tags(self, obj):
|
def get_tags(self, obj):
|
||||||
return [tag.name for tag in obj.tags.all()]
|
return [tag.name for tag in obj.tags.all()]
|
||||||
|
|
|
||||||
191
backend/toolshed/tests/test_group.py
Normal file
191
backend/toolshed/tests/test_group.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
|
||||||
|
DummyExternalUser
|
||||||
|
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity
|
||||||
|
|
||||||
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
class GroupModelTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_groups()
|
||||||
|
|
||||||
|
def test_group_str(self):
|
||||||
|
self.assertEqual(str(self.f['group1']), '#group1@' + self.f['example_com'].name)
|
||||||
|
|
||||||
|
def test_is_member(self):
|
||||||
|
self.assertTrue(self.f['group1'].is_member(self.f['local_user1'].public_identity))
|
||||||
|
self.assertFalse(self.f['group1'].is_member(self.f['local_user2'].public_identity))
|
||||||
|
|
||||||
|
|
||||||
|
class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_create_group(self):
|
||||||
|
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||||
|
self.assertEqual(reply.status_code, 201)
|
||||||
|
group = Group.objects.get(name='workshop')
|
||||||
|
self.assertEqual(group.domain, self.f['local_user1'].domain)
|
||||||
|
self.assertTrue(group.is_member(self.f['local_user1'].public_identity))
|
||||||
|
|
||||||
|
def test_create_group_duplicate_name(self):
|
||||||
|
client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||||
|
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
|
||||||
|
self.assertEqual(reply.status_code, 409)
|
||||||
|
|
||||||
|
def test_create_group_missing_name(self):
|
||||||
|
reply = client.post('/api/groups/', self.f['local_user1'], {})
|
||||||
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
|
def test_list_groups(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
reply = client.get('/api/groups/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(len(reply.json()), 1)
|
||||||
|
reply2 = client.get('/api/groups/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply2.status_code, 200)
|
||||||
|
self.assertEqual(len(reply2.json()), 0)
|
||||||
|
|
||||||
|
def test_group_detail_member(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json()['handle'], str(self.f['group1']))
|
||||||
|
self.assertEqual(len(reply.json()['members']), 1)
|
||||||
|
|
||||||
|
def test_group_detail_non_member(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_remove_member(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
self.f['group1'].members.add(self.f['local_user2'].public_identity)
|
||||||
|
reply = client.delete('/api/groups/{}/members/{}/'.format(
|
||||||
|
self.f['group1'].id, self.f['local_user2'].public_identity.id), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 204)
|
||||||
|
self.assertEqual(self.f['group1'].members.count(), 1)
|
||||||
|
|
||||||
|
def test_remove_last_member_blocked(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
reply = client.delete('/api/groups/{}/members/{}/'.format(
|
||||||
|
self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
self.assertEqual(self.f['group1'].members.count(), 1)
|
||||||
|
|
||||||
|
def test_remove_member_non_member_denied(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
self.f['group1'].members.add(self.f['local_user2'].public_identity)
|
||||||
|
reply = client.delete('/api/groups/{}/members/{}/'.format(
|
||||||
|
self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertEqual(self.f['group1'].members.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_groups()
|
||||||
|
|
||||||
|
def test_invite_local_member_full_flow(self):
|
||||||
|
group = self.f['group1']
|
||||||
|
inviter = self.f['local_user1']
|
||||||
|
invitee = self.f['local_user2']
|
||||||
|
|
||||||
|
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
|
||||||
|
self.assertEqual(reply1.status_code, 201)
|
||||||
|
secret = reply1.json()['secret']
|
||||||
|
self.assertEqual(GroupInvite.objects.count(), 1)
|
||||||
|
|
||||||
|
reply2 = client.post('/api/groupinvites/', inviter, {
|
||||||
|
'group': str(group), 'inviter': str(inviter), 'inviter_key': inviter.public_key(),
|
||||||
|
'invitee': str(invitee), 'secret': secret
|
||||||
|
})
|
||||||
|
self.assertEqual(reply2.status_code, 201)
|
||||||
|
incoming = GroupInviteIncoming.objects.get()
|
||||||
|
self.assertEqual(incoming.invitee_user, invitee)
|
||||||
|
|
||||||
|
reply3 = client.get('/api/groupinvites/', invitee)
|
||||||
|
self.assertEqual(reply3.status_code, 200)
|
||||||
|
self.assertEqual(len(reply3.json()), 1)
|
||||||
|
self.assertEqual(reply3.json()[0]['group'], str(group))
|
||||||
|
self.assertEqual(reply3.json()[0]['inviter'], str(inviter))
|
||||||
|
|
||||||
|
reply4 = client.post('/api/group_invites/accept/', invitee, {
|
||||||
|
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
|
||||||
|
})
|
||||||
|
self.assertEqual(reply4.status_code, 201)
|
||||||
|
self.assertTrue(group.is_member(invitee.public_identity))
|
||||||
|
self.assertEqual(GroupInvite.objects.count(), 0)
|
||||||
|
|
||||||
|
reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee)
|
||||||
|
self.assertEqual(reply5.status_code, 204)
|
||||||
|
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_invite_non_member_denied(self):
|
||||||
|
group = self.f['group1']
|
||||||
|
reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user2'],
|
||||||
|
{'invitee': str(self.f['ext_user1'])})
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertEqual(GroupInvite.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_invite_already_member(self):
|
||||||
|
group = self.f['group1']
|
||||||
|
group.members.add(self.f['local_user2'].public_identity)
|
||||||
|
reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user1'],
|
||||||
|
{'invitee': str(self.f['local_user2'])})
|
||||||
|
self.assertEqual(reply.status_code, 208)
|
||||||
|
|
||||||
|
def test_decline_invite(self):
|
||||||
|
invite = GroupInviteIncoming.objects.create(
|
||||||
|
group_name=self.f['group1'].name, group_domain=self.f['group1'].domain,
|
||||||
|
inviter_username=self.f['local_user1'].username, inviter_domain=self.f['local_user1'].domain,
|
||||||
|
invitee_user=self.f['local_user2'], secret='some-secret')
|
||||||
|
reply = client.delete('/api/groupinvites/{}/'.format(invite.id), self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 204)
|
||||||
|
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_accept_wrong_secret(self):
|
||||||
|
group = self.f['group1']
|
||||||
|
inviter = self.f['local_user1']
|
||||||
|
invitee = self.f['local_user2']
|
||||||
|
client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
|
||||||
|
reply = client.post('/api/group_invites/accept/', invitee, {
|
||||||
|
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(),
|
||||||
|
'secret': 'not-the-right-secret'
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertFalse(group.is_member(invitee.public_identity))
|
||||||
|
|
||||||
|
def test_accept_remote_member(self):
|
||||||
|
group = self.f['group1']
|
||||||
|
inviter = self.f['local_user1']
|
||||||
|
invitee = DummyExternalUser('newmember', 'remote.example', known=False)
|
||||||
|
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
|
||||||
|
secret = reply1.json()['secret']
|
||||||
|
|
||||||
|
reply = client.post('/api/group_invites/accept/', invitee, {
|
||||||
|
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 201)
|
||||||
|
identity = KnownIdentity.objects.get(username='newmember', domain='remote.example')
|
||||||
|
self.assertTrue(group.is_member(identity))
|
||||||
|
|
||||||
|
def test_accept_bad_signature(self):
|
||||||
|
group = self.f['group1']
|
||||||
|
inviter = self.f['local_user1']
|
||||||
|
invitee = self.f['local_user2']
|
||||||
|
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
|
||||||
|
secret = reply1.json()['secret']
|
||||||
|
bad_signature_client = SignatureAuthClient(bad_signature=True)
|
||||||
|
reply = bad_signature_client.post('/api/group_invites/accept/', invitee, {
|
||||||
|
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 401)
|
||||||
|
self.assertFalse(group.is_member(invitee.public_identity))
|
||||||
|
|
@ -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
|
from toolshed.models import InventoryItem, Category
|
||||||
from toolshed.tests import InventoryTestMixin
|
from toolshed.tests import InventoryTestMixin
|
||||||
|
|
@ -333,4 +333,87 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
|
||||||
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
|
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
|
||||||
'files': [{'data': self.f['encoded_content3']}]
|
'files': [{'data': self.f['encoded_content3']}]
|
||||||
})
|
})
|
||||||
self.assertEqual(reply.status_code, 400)
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupOwnedInventoryApiTestCase(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_item(self, name='drill'):
|
||||||
|
return client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||||
|
'name': name, 'owned_quantity': 1, 'availability_policy': 'private',
|
||||||
|
'owner_group': self.f['group1'].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_create_group_owned_item(self):
|
||||||
|
reply = self.create_group_item()
|
||||||
|
self.assertEqual(reply.status_code, 201)
|
||||||
|
item = InventoryItem.objects.get(name='drill')
|
||||||
|
self.assertIsNone(item.owner)
|
||||||
|
self.assertEqual(item.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_item_non_member_denied(self):
|
||||||
|
reply = client.post('/api/inventory_items/', self.f['ext_user1'], {
|
||||||
|
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
|
||||||
|
'owner_group': self.f['group1'].id,
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
self.assertEqual(InventoryItem.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_other_member_can_edit(self):
|
||||||
|
item_id = self.create_group_item().json()['id']
|
||||||
|
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
||||||
|
'name': 'drill-renamed'
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed')
|
||||||
|
|
||||||
|
def test_other_member_can_delete(self):
|
||||||
|
item_id = self.create_group_item().json()['id']
|
||||||
|
reply = client.delete('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 204)
|
||||||
|
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
|
||||||
|
|
||||||
|
def test_remote_member_without_local_account_can_edit(self):
|
||||||
|
# A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity -
|
||||||
|
# see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned
|
||||||
|
# items here; it must not be treated as unauthorized just because .user.exists() is False.
|
||||||
|
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||||
|
item_id = self.create_group_item().json()['id']
|
||||||
|
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'], {
|
||||||
|
'name': 'drill-renamed-by-remote-member'
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed-by-remote-member')
|
||||||
|
|
||||||
|
def test_non_member_cannot_see_or_edit(self):
|
||||||
|
item_id = self.create_group_item().json()['id']
|
||||||
|
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_group_items_excluded_from_personal_list(self):
|
||||||
|
self.create_group_item()
|
||||||
|
reply = client.get('/api/inventory_items/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(len(reply.json()), 0)
|
||||||
|
|
||||||
|
def test_group_items_listed_by_group_query_param(self):
|
||||||
|
self.create_group_item()
|
||||||
|
reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(len(reply.json()), 1)
|
||||||
|
self.assertEqual(reply.json()[0]['name'], 'drill')
|
||||||
|
|
||||||
|
def test_group_items_not_listed_for_non_member_query_param(self):
|
||||||
|
self.create_group_item()
|
||||||
|
reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(len(reply.json()), 0)
|
||||||
|
|
@ -33,6 +33,12 @@
|
||||||
<span class="align-middle">Friends</span>
|
<span class="align-middle">Friends</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<router-link to="/groups" class="sidebar-link">
|
||||||
|
<b-icon-people-fill class="bi-valign-middle"></b-icon-people-fill>
|
||||||
|
<span class="align-middle">Groups</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
<li class="sidebar-item">
|
<li class="sidebar-item">
|
||||||
<router-link to="/files" class="sidebar-link">
|
<router-link to="/files" class="sidebar-link">
|
||||||
<b-icon-folder class="bi-valign-middle"></b-icon-folder>
|
<b-icon-folder class="bi-valign-middle"></b-icon-folder>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import store from '@/store';
|
||||||
import Profile from '@/views/Profile.vue';
|
import Profile from '@/views/Profile.vue';
|
||||||
import Settings from '@/views/Settings.vue';
|
import Settings from '@/views/Settings.vue';
|
||||||
import Friends from '@/views/Friends.vue';
|
import Friends from '@/views/Friends.vue';
|
||||||
|
import Groups from '@/views/Groups.vue';
|
||||||
|
import GroupNew from '@/views/GroupNew.vue';
|
||||||
|
import GroupDetail from '@/views/GroupDetail.vue';
|
||||||
import Inventory from '@/views/Inventory.vue';
|
import Inventory from '@/views/Inventory.vue';
|
||||||
import Search from '@/views/Search.vue';
|
import Search from '@/views/Search.vue';
|
||||||
import InventoryDetail from '@/views/InventoryDetail.vue';
|
import InventoryDetail from '@/views/InventoryDetail.vue';
|
||||||
|
|
@ -62,6 +65,19 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/friends',
|
path: '/friends',
|
||||||
component: Friends,
|
component: Friends,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
|
}, {
|
||||||
|
path: '/groups',
|
||||||
|
component: Groups,
|
||||||
|
meta: {requiresAuth: true}
|
||||||
|
}, {
|
||||||
|
path: '/groups/new',
|
||||||
|
component: GroupNew,
|
||||||
|
meta: {requiresAuth: true}
|
||||||
|
}, {
|
||||||
|
path: '/groups/:id',
|
||||||
|
component: GroupDetail,
|
||||||
|
meta: {requiresAuth: true},
|
||||||
|
props: true
|
||||||
}, {path: '/files', component: Files, meta: {requiresAuth: true}
|
}, {path: '/files', component: Files, meta: {requiresAuth: true}
|
||||||
}, {path: '/workflows',
|
}, {path: '/workflows',
|
||||||
component: Workflows,
|
component: Workflows,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ export default createStore({
|
||||||
remember: false,
|
remember: false,
|
||||||
friends: [],
|
friends: [],
|
||||||
friendProfiles: {},
|
friendProfiles: {},
|
||||||
|
groups: [],
|
||||||
|
groupInvites: [],
|
||||||
item_map: {},
|
item_map: {},
|
||||||
home_servers: null,
|
home_servers: null,
|
||||||
all_friends_servers: null,
|
all_friends_servers: null,
|
||||||
|
|
@ -104,6 +106,12 @@ export default createStore({
|
||||||
setFriendProfiles(state, profiles) {
|
setFriendProfiles(state, profiles) {
|
||||||
state.friendProfiles = profiles;
|
state.friendProfiles = profiles;
|
||||||
},
|
},
|
||||||
|
setGroups(state, groups) {
|
||||||
|
state.groups = groups;
|
||||||
|
},
|
||||||
|
setGroupInvites(state, invites) {
|
||||||
|
state.groupInvites = invites;
|
||||||
|
},
|
||||||
setHomeServers(state, home_servers) {
|
setHomeServers(state, home_servers) {
|
||||||
state.home_servers = home_servers;
|
state.home_servers = home_servers;
|
||||||
},
|
},
|
||||||
|
|
@ -479,6 +487,75 @@ 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 + '/')
|
||||||
},
|
},
|
||||||
|
// Groups are only ever hosted on the current user's own home backend for now (see
|
||||||
|
// docs/design-in-progress/groups-mvp.md) - a remote member's edit/delete rights on a
|
||||||
|
// group-owned item work regardless, but "My Groups" has no way to discover a group hosted
|
||||||
|
// elsewhere, so every group action below talks to getHomeServers rather than resolving a
|
||||||
|
// per-group domain.
|
||||||
|
async fetchGroups({commit, dispatch, getters}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.get(getters.signAuth, '/api/groups/')
|
||||||
|
commit('setGroups', data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async fetchGroup({dispatch, getters}, {id}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
|
||||||
|
},
|
||||||
|
async createGroup({dispatch, getters}, {name}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
return await servers.post(getters.signAuth, '/api/groups/', {name})
|
||||||
|
},
|
||||||
|
async removeGroupMember({dispatch, getters}, {groupId, identityId}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/')
|
||||||
|
},
|
||||||
|
async fetchGroupInvites({commit, dispatch, getters}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.get(getters.signAuth, '/api/groupinvites/')
|
||||||
|
commit('setGroupInvites', data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) {
|
||||||
|
const home_servers = await dispatch('getHomeServers')
|
||||||
|
const home_reply = await home_servers.post(
|
||||||
|
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee})
|
||||||
|
if (!home_reply.secret) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
|
||||||
|
await invitee_servers.post(getters.signAuth, '/api/groupinvites/', {
|
||||||
|
group: groupHandle,
|
||||||
|
inviter: state.user,
|
||||||
|
inviter_key: nacl.to_hex(state.keypair.signPk),
|
||||||
|
invitee,
|
||||||
|
secret: home_reply.secret
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async acceptGroupInvite({state, dispatch, getters}, invite) {
|
||||||
|
const group_domain = invite.group.split('@')[1]
|
||||||
|
const group_servers = await dispatch('getFriendServers', {username: 'x@' + group_domain})
|
||||||
|
await group_servers.post(getters.signAuth, '/api/group_invites/accept/', {
|
||||||
|
group: invite.group,
|
||||||
|
invitee: state.user,
|
||||||
|
invitee_key: nacl.to_hex(state.keypair.signPk),
|
||||||
|
secret: invite.secret
|
||||||
|
})
|
||||||
|
const home_servers = await dispatch('getHomeServers')
|
||||||
|
await home_servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async declineGroupInvite({dispatch, getters}, invite) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
||||||
|
},
|
||||||
|
async fetchGroupInventoryItems({commit, dispatch, getters}, groupId) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId)
|
||||||
|
commit('setInventoryItems', {url: '/group/' + groupId, items})
|
||||||
|
return items
|
||||||
|
},
|
||||||
async fetchFiles({state, commit, dispatch, getters}) {
|
async fetchFiles({state, commit, dispatch, getters}) {
|
||||||
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
||||||
return state.files
|
return state.files
|
||||||
|
|
@ -708,6 +785,7 @@ export default createStore({
|
||||||
inventory_items(state) {
|
inventory_items(state) {
|
||||||
return state.item_map['/'] || []
|
return state.item_map['/'] || []
|
||||||
},
|
},
|
||||||
|
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
|
||||||
loaded_items(state) {
|
loaded_items(state) {
|
||||||
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
|
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
|
||||||
return acc.concat(items)
|
return acc.concat(items)
|
||||||
|
|
|
||||||
207
frontend/src/views/GroupDetail.vue
Normal file
207
frontend/src/views/GroupDetail.vue
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
<template>
|
||||||
|
<BaseLayout>
|
||||||
|
<main class="content">
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<h1 class="h3 mb-3">{{ group ? group.handle : '' }}</h1>
|
||||||
|
<div class="row" v-if="group">
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Members</h5>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style="width: 16em">
|
||||||
|
<a @click="refresh" class="align-middle">
|
||||||
|
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||||
|
Refresh
|
||||||
|
</a>
|
||||||
|
<a @click="show_invite = true" class="align-middle">
|
||||||
|
<b-icon-plus></b-icon-plus>
|
||||||
|
Add member
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="show_invite">
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control" placeholder="user@domain"
|
||||||
|
v-model="invitee">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-primary" @click="tryInvite">Invite</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="member in group.members" :key="member.id">
|
||||||
|
<td>
|
||||||
|
<user-name-tag :user="member"/>
|
||||||
|
</td>
|
||||||
|
<td class="table-action">
|
||||||
|
<a href="#" class="align-middle" @click.prevent="tryRemoveMember(member)">
|
||||||
|
<b-icon-trash></b-icon-trash>
|
||||||
|
Remove
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Group inventory</h5>
|
||||||
|
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
||||||
|
<b-icon-list></b-icon-list>
|
||||||
|
</button>
|
||||||
|
<button v-else @click="layout = 'grid'" class="btn">
|
||||||
|
<b-icon-grid></b-icon-grid>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped" v-if="layout === 'table'">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%;">Name</th>
|
||||||
|
<th style="width:25%">Availability Policy</th>
|
||||||
|
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in items" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">
|
||||||
|
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||||
|
<td class="table-action">
|
||||||
|
<router-link :to="`/inventory/${item.id}/edit`">
|
||||||
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
|
</router-link>
|
||||||
|
<a :href="`/inventory/${item.id}/delete`" @click.prevent="tryDeleteItem(item)">
|
||||||
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="card-body" v-else>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items" :key="item.id">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<router-link :to="`/inventory/${item.id}`">
|
||||||
|
{{ item.name }}
|
||||||
|
</router-link>
|
||||||
|
</h5>
|
||||||
|
<div class="card-text text-black-50">
|
||||||
|
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
|
||||||
|
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-danger btn-sm" @click="tryDeleteItem(item)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<router-link :to="`/inventory/${item.id}/edit`"
|
||||||
|
class="btn btn-primary btn-sm">Edit
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<button class="btn" @click="fetchItems">Refresh</button>
|
||||||
|
<router-link :to="`/inventory/new?group=${id}`" class="btn btn-primary">Add</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import UserNameTag from "@/components/UserNameTag.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupDetail',
|
||||||
|
components: {
|
||||||
|
BaseLayout,
|
||||||
|
UserNameTag,
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
id: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
group: null,
|
||||||
|
layout: "grid",
|
||||||
|
show_invite: false,
|
||||||
|
invitee: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(['user']),
|
||||||
|
items() {
|
||||||
|
return this.groupInventoryItems(this.id)
|
||||||
|
},
|
||||||
|
...mapGetters(['groupInventoryItems'])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
||||||
|
'deleteInventoryItem']),
|
||||||
|
refresh() {
|
||||||
|
this.fetchGroup({id: this.id}).then((group) => {
|
||||||
|
this.group = group
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fetchItems() {
|
||||||
|
this.fetchGroupInventoryItems(this.id)
|
||||||
|
},
|
||||||
|
tryInvite() {
|
||||||
|
this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee})
|
||||||
|
.then((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
this.show_invite = false
|
||||||
|
this.invitee = ""
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tryRemoveMember(member) {
|
||||||
|
this.removeGroupMember({groupId: this.id, identityId: member.id}).then(() => {
|
||||||
|
this.refresh()
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tryDeleteItem(item) {
|
||||||
|
this.deleteInventoryItem(item).then(() => {
|
||||||
|
this.fetchItems()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.refresh()
|
||||||
|
this.fetchItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
52
frontend/src/views/GroupNew.vue
Normal file
52
frontend/src/views/GroupNew.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<BaseLayout>
|
||||||
|
<main class="content">
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<h1 class="h3 mb-3">New group</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" v-model="name">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||||
|
@click="tryCreateGroup">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapActions} from "vuex";
|
||||||
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupNew',
|
||||||
|
components: {
|
||||||
|
BaseLayout
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
name: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['createGroup']),
|
||||||
|
tryCreateGroup() {
|
||||||
|
this.createGroup({name: this.name}).then((group) => {
|
||||||
|
this.$router.push(`/groups/${group.id}`)
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
122
frontend/src/views/Groups.vue
Normal file
122
frontend/src/views/Groups.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<template>
|
||||||
|
<BaseLayout>
|
||||||
|
<main class="content">
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<h1 class="h3 mb-3">Groups</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">My Groups</h5>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="d-none d-md-table-cell" style="width:25%">Members</th>
|
||||||
|
<th style="width: 16em">
|
||||||
|
<a @click="fetchGroups" class="align-middle">
|
||||||
|
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||||
|
Refresh
|
||||||
|
</a>
|
||||||
|
<router-link to="/groups/new" class="align-middle">
|
||||||
|
<b-icon-plus></b-icon-plus>
|
||||||
|
New group
|
||||||
|
</router-link>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="group in groups" :key="group.id">
|
||||||
|
<td>
|
||||||
|
<router-link :to="`/groups/${group.id}`">{{ group.handle }}</router-link>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
|
||||||
|
<td class="table-action"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Open group invites</h5>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Group</th>
|
||||||
|
<th class="d-none d-md-table-cell" style="width:25%">Invited by</th>
|
||||||
|
<th style="width: 16em">
|
||||||
|
<a @click="fetchGroupInvites" class="align-middle">
|
||||||
|
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||||
|
Refresh
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="invite in groupInvites" :key="invite.id">
|
||||||
|
<td>{{ invite.group }}</td>
|
||||||
|
<td class="d-none d-md-table-cell">{{ invite.inviter }}</td>
|
||||||
|
<td class="table-action">
|
||||||
|
<button class="btn btn-sm btn-success" @click="tryAcceptInvite(invite)">
|
||||||
|
<b-icon-check></b-icon-check>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" @click="tryDeclineInvite(invite)">
|
||||||
|
<b-icon-x></b-icon-x>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapActions, mapState} from "vuex";
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Groups',
|
||||||
|
components: {
|
||||||
|
BaseLayout,
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(['groups', 'groupInvites']),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['fetchGroups', 'fetchGroupInvites', 'acceptGroupInvite', 'declineGroupInvite']),
|
||||||
|
tryAcceptInvite(invite) {
|
||||||
|
this.acceptGroupInvite(invite).then(() => {
|
||||||
|
this.fetchGroupInvites()
|
||||||
|
this.fetchGroups()
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tryDeclineInvite(invite) {
|
||||||
|
this.declineGroupInvite(invite).then(() => {
|
||||||
|
this.fetchGroupInvites()
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchGroups()
|
||||||
|
this.fetchGroupInvites()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
|
|
@ -92,7 +92,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(["inventory_items"]),
|
...mapGetters(["loaded_items"]),
|
||||||
...mapState(["availability_policies", "storage_locations"]),
|
...mapState(["availability_policies", "storage_locations"]),
|
||||||
item() {
|
item() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -104,14 +104,14 @@ export default {
|
||||||
image: "",
|
image: "",
|
||||||
files: [],
|
files: [],
|
||||||
storage_location: null,
|
storage_location: null,
|
||||||
...this.inventory_items.find(item => item.id === parseInt(this.id))
|
...this.loaded_items.find(item => item.id === parseInt(this.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
|
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
|
||||||
changeFiles(files) {
|
changeFiles(files) {
|
||||||
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
|
this.loaded_items.find(item => item.id === parseInt(this.id)).files = files
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@
|
||||||
<label for="property" class="form-label">Property</label>
|
<label for="property" class="form-label">Property</label>
|
||||||
<property-field :value="item.properties"></property-field>
|
<property-field :value="item.properties"></property-field>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="owner" class="form-label">Owner</label>
|
||||||
|
<select class="form-select" id="owner" name="owner" v-model="item.owner_group">
|
||||||
|
<option :value="null">Myself</option>
|
||||||
|
<option v-for="group in groups" :value="group.id" :key="group.id">
|
||||||
|
{{ group.handle }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="quantity" class="form-label">Quantity</label>
|
<label for="quantity" class="form-label">Quantity</label>
|
||||||
<input type="number" class="form-control" id="quantity" name="quantity"
|
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||||
|
|
@ -96,19 +105,24 @@ export default {
|
||||||
tags: [],
|
tags: [],
|
||||||
properties: [],
|
properties: [],
|
||||||
files: [],
|
files: [],
|
||||||
storage_location: null
|
storage_location: null,
|
||||||
|
owner_group: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations'])
|
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(["availability_policies", "storage_locations"]),
|
...mapState(["availability_policies", "storage_locations", "groups"]),
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.fetchInfo();
|
await this.fetchInfo();
|
||||||
await this.fetchStorageLocations();
|
await this.fetchStorageLocations();
|
||||||
|
await this.fetchGroups();
|
||||||
|
if (this.$route.query.group) {
|
||||||
|
this.item.owner_group = parseInt(this.$route.query.group, 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue