stash
This commit is contained in:
parent
582853ab27
commit
9ce700c388
7 changed files with 185 additions and 8 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, Group, GroupInvite, GroupInviteIncoming
|
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
|
||||||
|
|
||||||
|
|
||||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||||
|
|
@ -48,6 +48,11 @@ class GroupInviteIncomingAdmin(admin.ModelAdmin):
|
||||||
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
|
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'group_name', 'group_domain', 'created_at')
|
||||||
|
search_fields = ('user__username', 'group_name', 'group_domain')
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -56,3 +61,4 @@ admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
||||||
admin.site.register(Group, GroupAdmin)
|
admin.site.register(Group, GroupAdmin)
|
||||||
admin.site.register(GroupInvite, GroupInviteAdmin)
|
admin.site.register(GroupInvite, GroupInviteAdmin)
|
||||||
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
|
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
|
||||||
|
admin.site.register(GroupMembership, GroupMembershipAdmin)
|
||||||
|
|
|
||||||
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-08-26 16:25
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GroupMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('group_name', models.CharField(max_length=255)),
|
||||||
|
('group_domain', models.CharField(max_length=255)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'group_name', 'group_domain')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -180,3 +180,23 @@ class GroupInviteIncoming(models.Model):
|
||||||
inviter_domain = 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')
|
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMembership(models.Model):
|
||||||
|
"""A pointer kept on a member's own home backend, recording that the local user is a member
|
||||||
|
of a group that may be hosted here or on a remote domain (mirrors FriendRequestIncoming's
|
||||||
|
role: independently recorded on the member's own side, not just the group's authoritative
|
||||||
|
backend). Written once the invitee's accept request against the group's home backend has
|
||||||
|
succeeded (see GroupInvitesIncomingAccept), analogous to how a friendship is independently
|
||||||
|
recorded on both sides via KnownIdentity.friends rather than only on one. See
|
||||||
|
docs/design-in-progress/groups-mvp.md's 'Known limitation'."""
|
||||||
|
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_memberships')
|
||||||
|
group_name = models.CharField(max_length=255)
|
||||||
|
group_domain = models.CharField(max_length=255)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'group_name', 'group_domain')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} MEMBER_OF #{self.group_name}@{self.group_domain}"
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.viewsets import ViewSetMixin
|
from rest_framework.viewsets import ViewSetMixin
|
||||||
|
|
||||||
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity, ToolshedUser
|
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity, \
|
||||||
|
ToolshedUser
|
||||||
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
|
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
|
||||||
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
|
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
|
||||||
verify_incoming_group_invite
|
verify_incoming_group_invite
|
||||||
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer
|
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer
|
||||||
|
|
||||||
|
|
||||||
class Groups(APIView, ViewSetMixin):
|
class Groups(APIView, ViewSetMixin):
|
||||||
|
|
@ -126,6 +127,34 @@ def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def recordGroupMembership(request, pk, format=None): # /api/groupinvites/<pk>/accept/
|
||||||
|
"""Local bookkeeping call on the invitee's own home backend, made by the client once its
|
||||||
|
accept request against the group's home backend (acceptGroupInvite) has succeeded. Turns the
|
||||||
|
now-obsolete GroupInviteIncoming into a durable GroupMembership pointer, so this backend
|
||||||
|
remembers which foreign groups its own user belongs to -- the same way FriendRequestIncoming
|
||||||
|
ends up recorded as a friend on both sides, not just the group's authoritative backend."""
|
||||||
|
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
|
||||||
|
membership, _ = GroupMembership.objects.get_or_create(
|
||||||
|
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
|
||||||
|
invite.delete()
|
||||||
|
return Response(status=status.HTTP_201_CREATED, data=GroupMembershipSerializer(membership).data)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberships(APIView, ViewSetMixin):
|
||||||
|
"""/api/groupmemberships/ - the personal index of every group (local or remote) this backend
|
||||||
|
has recorded the caller as belonging to, kept independently of the group's own membership
|
||||||
|
roster (see GroupMembership and recordGroupMembership)."""
|
||||||
|
authentication_classes = [SignatureAuthenticationLocal]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request, format=None): # /api/groupmemberships/
|
||||||
|
memberships = request.user.group_memberships.all()
|
||||||
|
return Response(GroupMembershipSerializer(memberships, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
|
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
|
||||||
raw_request = request.body.decode('utf-8')
|
raw_request = request.body.decode('utf-8')
|
||||||
|
|
@ -161,5 +190,7 @@ urlpatterns = [
|
||||||
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
|
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
|
||||||
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
|
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
|
||||||
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
|
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
|
||||||
|
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),
|
||||||
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
|
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
|
||||||
|
path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming, \
|
||||||
|
GroupMembership
|
||||||
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
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
|
||||||
|
|
@ -119,6 +120,17 @@ class GroupInviteIncomingSerializer(serializers.ModelSerializer):
|
||||||
return obj.inviter_username + '@' + obj.inviter_domain
|
return obj.inviter_username + '@' + obj.inviter_domain
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMembershipSerializer(serializers.ModelSerializer):
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = GroupMembership
|
||||||
|
fields = ['id', 'group_name', 'group_domain', 'handle', 'created_at']
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return f"#{obj.group_name}@{obj.group_domain}"
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = serializers.SerializerMethodField()
|
category = serializers.SerializerMethodField()
|
||||||
handle = serializers.SerializerMethodField()
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from django.test import Client
|
||||||
|
|
||||||
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
|
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
|
||||||
DummyExternalUser
|
DummyExternalUser
|
||||||
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity
|
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity
|
||||||
|
|
||||||
client = SignatureAuthClient()
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
@ -124,9 +124,13 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
self.assertTrue(group.is_member(invitee.public_identity))
|
self.assertTrue(group.is_member(invitee.public_identity))
|
||||||
self.assertEqual(GroupInvite.objects.count(), 0)
|
self.assertEqual(GroupInvite.objects.count(), 0)
|
||||||
|
|
||||||
reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee)
|
reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||||
self.assertEqual(reply5.status_code, 204)
|
self.assertEqual(reply5.status_code, 201)
|
||||||
|
self.assertEqual(reply5.json()['handle'], str(group))
|
||||||
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
||||||
|
membership = GroupMembership.objects.get(user=invitee)
|
||||||
|
self.assertEqual(membership.group_name, group.name)
|
||||||
|
self.assertEqual(membership.group_domain, group.domain)
|
||||||
|
|
||||||
def test_invite_non_member_denied(self):
|
def test_invite_non_member_denied(self):
|
||||||
group = self.f['group1']
|
group = self.f['group1']
|
||||||
|
|
@ -189,3 +193,69 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
})
|
})
|
||||||
self.assertEqual(reply.status_code, 401)
|
self.assertEqual(reply.status_code, 401)
|
||||||
self.assertFalse(group.is_member(invitee.public_identity))
|
self.assertFalse(group.is_member(invitee.public_identity))
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
|
"""Covers the pointer index kept on a member's own home backend (GroupMembership), which is
|
||||||
|
what lets that backend remember foreign group membership, analogous to how a friendship ends
|
||||||
|
up recorded on both sides rather than only on the group's authoritative backend."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_groups()
|
||||||
|
|
||||||
|
def test_record_membership_for_remote_group(self):
|
||||||
|
# local_user2's own home backend has no Group row at all for this group -- it's hosted
|
||||||
|
# entirely on another domain -- yet it should still remember the membership once the
|
||||||
|
# invitee's client confirms the accept against the group's home backend succeeded.
|
||||||
|
invitee = self.f['local_user2']
|
||||||
|
incoming = GroupInviteIncoming.objects.create(
|
||||||
|
group_name='remoteworkshop', group_domain='other.example',
|
||||||
|
inviter_username='someone', inviter_domain='other.example',
|
||||||
|
invitee_user=invitee, secret='some-secret')
|
||||||
|
|
||||||
|
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||||
|
|
||||||
|
self.assertEqual(reply.status_code, 201)
|
||||||
|
self.assertEqual(reply.json()['handle'], '#remoteworkshop@other.example')
|
||||||
|
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
||||||
|
membership = GroupMembership.objects.get(user=invitee)
|
||||||
|
self.assertEqual(membership.group_name, 'remoteworkshop')
|
||||||
|
self.assertEqual(membership.group_domain, 'other.example')
|
||||||
|
|
||||||
|
def test_record_membership_wrong_user_denied(self):
|
||||||
|
incoming = GroupInviteIncoming.objects.create(
|
||||||
|
group_name='remoteworkshop', group_domain='other.example',
|
||||||
|
inviter_username='someone', inviter_domain='other.example',
|
||||||
|
invitee_user=self.f['local_user2'], secret='some-secret')
|
||||||
|
|
||||||
|
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), self.f['local_user1'])
|
||||||
|
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertEqual(GroupMembership.objects.count(), 0)
|
||||||
|
self.assertEqual(GroupInviteIncoming.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_decline_does_not_record_membership(self):
|
||||||
|
incoming = GroupInviteIncoming.objects.create(
|
||||||
|
group_name='remoteworkshop', group_domain='other.example',
|
||||||
|
inviter_username='someone', inviter_domain='other.example',
|
||||||
|
invitee_user=self.f['local_user2'], secret='some-secret')
|
||||||
|
|
||||||
|
reply = client.delete('/api/groupinvites/{}/'.format(incoming.id), self.f['local_user2'])
|
||||||
|
|
||||||
|
self.assertEqual(reply.status_code, 204)
|
||||||
|
self.assertEqual(GroupMembership.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_list_memberships(self):
|
||||||
|
GroupMembership.objects.create(
|
||||||
|
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example')
|
||||||
|
|
||||||
|
reply = client.get('/api/groupmemberships/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(len(reply.json()), 1)
|
||||||
|
self.assertEqual(reply.json()[0]['handle'], '#remoteworkshop@other.example')
|
||||||
|
|
||||||
|
reply2 = client.get('/api/groupmemberships/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply2.status_code, 200)
|
||||||
|
self.assertEqual(len(reply2.json()), 0)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export default createStore({
|
||||||
friendProfiles: {},
|
friendProfiles: {},
|
||||||
groups: [],
|
groups: [],
|
||||||
groupInvites: [],
|
groupInvites: [],
|
||||||
|
groupMemberships: [],
|
||||||
idmap: {identities: [], groups: []},
|
idmap: {identities: [], groups: []},
|
||||||
idmapLoaded: false,
|
idmapLoaded: false,
|
||||||
item_map: {},
|
item_map: {},
|
||||||
|
|
@ -114,6 +115,9 @@ export default createStore({
|
||||||
setGroupInvites(state, invites) {
|
setGroupInvites(state, invites) {
|
||||||
state.groupInvites = invites;
|
state.groupInvites = invites;
|
||||||
},
|
},
|
||||||
|
setGroupMemberships(state, memberships) {
|
||||||
|
state.groupMemberships = memberships;
|
||||||
|
},
|
||||||
setIdMap(state, idmap) {
|
setIdMap(state, idmap) {
|
||||||
state.idmap = idmap;
|
state.idmap = idmap;
|
||||||
state.idmapLoaded = true;
|
state.idmapLoaded = true;
|
||||||
|
|
@ -544,6 +548,12 @@ export default createStore({
|
||||||
commit('setGroupInvites', data)
|
commit('setGroupInvites', data)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
async fetchGroupMemberships({commit, dispatch, getters}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.get(getters.signAuth, '/api/groupmemberships/')
|
||||||
|
commit('setGroupMemberships', data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) {
|
async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) {
|
||||||
const home_servers = await dispatch('getHomeServers')
|
const home_servers = await dispatch('getHomeServers')
|
||||||
const home_reply = await home_servers.post(
|
const home_reply = await home_servers.post(
|
||||||
|
|
@ -571,7 +581,7 @@ export default createStore({
|
||||||
secret: invite.secret
|
secret: invite.secret
|
||||||
})
|
})
|
||||||
const home_servers = await dispatch('getHomeServers')
|
const home_servers = await dispatch('getHomeServers')
|
||||||
await home_servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
await home_servers.post(getters.signAuth, '/api/groupinvites/' + invite.id + '/accept/', {})
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async declineGroupInvite({dispatch, getters}, invite) {
|
async declineGroupInvite({dispatch, getters}, invite) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue