This commit is contained in:
j3d1 2026-08-26 18:33:31 +02:00
parent 582853ab27
commit 9ce700c388
7 changed files with 185 additions and 8 deletions

View file

@ -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)

View 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')},
},
),
]

View file

@ -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}"

View file

@ -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'),
] ]

View file

@ -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()

View file

@ -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)

View file

@ -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) {