Compare commits
2 commits
eaae7c286a
...
4787acd8eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 4787acd8eb | |||
| 6d2167ac66 |
24 changed files with 1254 additions and 27 deletions
|
|
@ -1,7 +1,7 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||
AccountPreference
|
||||
AccountPreference, Group, GroupInvite, GroupInviteIncoming
|
||||
|
||||
|
||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||
|
|
@ -29,8 +29,30 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin):
|
|||
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(KnownIdentity, KnownIdentityAdmin)
|
||||
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
||||
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
||||
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)
|
||||
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
|
||||
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 authentication.models import ToolshedUser
|
||||
from authentication.models import ToolshedUser, Group
|
||||
|
||||
|
||||
class OwnerSerializer(serializers.ReadOnlyField):
|
||||
|
|
@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField):
|
|||
|
||||
def to_representation(self, value):
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
||||
|
||||
|
|
@ -74,6 +80,36 @@ def verify_incoming_friend_request(request, raw_request_body):
|
|||
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):
|
||||
try:
|
||||
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 nacl.encoding import HexEncoder
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.models import ToolshedUser, KnownIdentity, Group
|
||||
from hostadmin.models import Domain
|
||||
from nacl.signing import SigningKey
|
||||
|
||||
|
|
@ -86,3 +86,9 @@ class UserTestMixin:
|
|||
domain=self.f['example_com'].name)
|
||||
self.f['ext_user1'] = DummyExternalUser('extuser1', '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('admin/', include('hostadmin.api')),
|
||||
path('api/', include('toolshed.api.friend')),
|
||||
path('api/', include('toolshed.api.group')),
|
||||
path('api/', include('toolshed.api.inventory')),
|
||||
path('api/', include('toolshed.api.info')),
|
||||
path('api/', include('toolshed.api.files')),
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ class ItemPropertyInline(admin.TabularInline):
|
|||
|
||||
class InventoryItemAdmin(admin.ModelAdmin):
|
||||
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',
|
||||
'storage_location__name', 'tags__name', 'itemproperty__property__name')
|
||||
'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__name')
|
||||
inlines = (ItemTagInline, ItemPropertyInline)
|
||||
|
||||
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 rest_framework import routers, viewsets, status
|
||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
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 files.models import File
|
||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||
|
|
@ -36,21 +37,61 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
|||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
||||
return InventoryItem.objects.filter(owner=self.request.user.user.get())
|
||||
# A KnownIdentity acting purely as a group member (e.g. a remote member on a group
|
||||
# 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()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
group_id = self.request.data.get('owner_group')
|
||||
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):
|
||||
with transaction.atomic():
|
||||
if serializer.instance.owner == self.request.user.user.get():
|
||||
if self._is_authorized(self.request, serializer.instance):
|
||||
serializer.save().clean()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.owner == self.request.user.user.get():
|
||||
if self._is_authorized(self.request, instance):
|
||||
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 rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.models import ToolshedUser, KnownIdentity, Group
|
||||
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')
|
||||
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
|
||||
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)
|
||||
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
||||
properties = models.ManyToManyField(Property, through='ItemProperty')
|
||||
|
|
@ -108,6 +111,8 @@ class InventoryItem(SoftDeleteModel):
|
|||
def clean(self):
|
||||
if (self.name is None or self.name == "") and self.files.count() == 0:
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
||||
from authentication.serializers import OwnerSerializer
|
||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
||||
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
category = serializers.SerializerMethodField()
|
||||
handle = serializers.SerializerMethodField()
|
||||
|
|
@ -139,6 +178,7 @@ class ItemPropertySerializer(serializers.ModelSerializer):
|
|||
|
||||
class InventoryItemSerializer(serializers.ModelSerializer):
|
||||
owner = OwnerSerializer(read_only=True)
|
||||
owner_group = GroupOwnerSerializer(read_only=True)
|
||||
tags = serializers.SerializerMethodField()
|
||||
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
|
||||
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
||||
|
|
@ -147,8 +187,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
||||
'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
||||
fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy',
|
||||
'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
||||
|
||||
def get_tags(self, obj):
|
||||
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 toolshed.models import InventoryItem, Category
|
||||
from toolshed.tests import InventoryTestMixin
|
||||
|
|
@ -333,4 +333,87 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
|
|||
'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}],
|
||||
'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)
|
||||
|
|
@ -3,6 +3,9 @@ import socketserver
|
|||
import urllib.parse
|
||||
import dnslib
|
||||
import base64
|
||||
import socket
|
||||
|
||||
UPSTREAM_DNS = ("8.8.8.8", 53)
|
||||
|
||||
try:
|
||||
|
||||
|
|
@ -11,6 +14,13 @@ try:
|
|||
if record["name"] == qname and record["type"] == qtype and "value" in record:
|
||||
return record["value"]
|
||||
|
||||
def resolve_recursive(raw_query):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.settimeout(5)
|
||||
sock.sendto(raw_query, UPSTREAM_DNS)
|
||||
data, _ = sock.recvfrom(4096)
|
||||
return data
|
||||
|
||||
|
||||
class DnsHttpRequestHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
|
|
@ -40,15 +50,16 @@ try:
|
|||
print("SRV record")
|
||||
reply = dnslib.SRV(record["priority"], record["weight"], record["port"], record["target"])
|
||||
response.add_answer(dnslib.RR(dns.q.qname, dns.q.qtype, rdata=reply))
|
||||
pack = response.pack()
|
||||
else:
|
||||
response.header.rcode = dnslib.RCODE.NXDOMAIN
|
||||
print(f"Recursively resolving {dns.q.qname}")
|
||||
pack = resolve_recursive(raw)
|
||||
|
||||
print(response)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "application/dns-message")
|
||||
self.end_headers()
|
||||
pack = response.pack()
|
||||
self.wfile.write(pack)
|
||||
return
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@
|
|||
<span class="align-middle">Friends</span>
|
||||
</router-link>
|
||||
</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">
|
||||
<router-link to="/files" class="sidebar-link">
|
||||
<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 Settings from '@/views/Settings.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 Search from '@/views/Search.vue';
|
||||
import InventoryDetail from '@/views/InventoryDetail.vue';
|
||||
|
|
@ -62,6 +65,19 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
path: '/friends',
|
||||
component: Friends,
|
||||
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: '/workflows',
|
||||
component: Workflows,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ export default createStore({
|
|||
remember: false,
|
||||
friends: [],
|
||||
friendProfiles: {},
|
||||
groups: [],
|
||||
groupInvites: [],
|
||||
item_map: {},
|
||||
home_servers: null,
|
||||
all_friends_servers: null,
|
||||
|
|
@ -104,6 +106,12 @@ export default createStore({
|
|||
setFriendProfiles(state, profiles) {
|
||||
state.friendProfiles = profiles;
|
||||
},
|
||||
setGroups(state, groups) {
|
||||
state.groups = groups;
|
||||
},
|
||||
setGroupInvites(state, invites) {
|
||||
state.groupInvites = invites;
|
||||
},
|
||||
setHomeServers(state, home_servers) {
|
||||
state.home_servers = home_servers;
|
||||
},
|
||||
|
|
@ -479,6 +487,75 @@ export default createStore({
|
|||
const servers = await dispatch('getHomeServers')
|
||||
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}) {
|
||||
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.files
|
||||
|
|
@ -708,6 +785,7 @@ export default createStore({
|
|||
inventory_items(state) {
|
||||
return state.item_map['/'] || []
|
||||
},
|
||||
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
|
||||
loaded_items(state) {
|
||||
return Object.entries(state.item_map).reduce((acc, [url, 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: {
|
||||
...mapGetters(["inventory_items"]),
|
||||
...mapGetters(["loaded_items"]),
|
||||
...mapState(["availability_policies", "storage_locations"]),
|
||||
item() {
|
||||
return {
|
||||
|
|
@ -104,14 +104,14 @@ export default {
|
|||
image: "",
|
||||
files: [],
|
||||
storage_location: null,
|
||||
...this.inventory_items.find(item => item.id === parseInt(this.id))
|
||||
...this.loaded_items.find(item => item.id === parseInt(this.id))
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@
|
|||
<label for="property" class="form-label">Property</label>
|
||||
<property-field :value="item.properties"></property-field>
|
||||
</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">
|
||||
<label for="quantity" class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||
|
|
@ -96,19 +105,24 @@ export default {
|
|||
tags: [],
|
||||
properties: [],
|
||||
files: [],
|
||||
storage_location: null
|
||||
storage_location: null,
|
||||
owner_group: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations'])
|
||||
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
|
||||
},
|
||||
computed: {
|
||||
...mapState(["availability_policies", "storage_locations"]),
|
||||
...mapState(["availability_policies", "storage_locations", "groups"]),
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInfo();
|
||||
await this.fetchStorageLocations();
|
||||
await this.fetchGroups();
|
||||
if (this.$route.query.group) {
|
||||
this.item.owner_group = parseInt(this.$route.query.group, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue