From 9ce700c38804927306ccb6612016f06f79ae8bc3 Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 18:33:31 +0200 Subject: [PATCH] stash --- backend/authentication/admin.py | 8 +- .../migrations/0006_groupmembership.py | 28 +++++++ backend/authentication/models.py | 20 +++++ backend/toolshed/api/group.py | 35 ++++++++- backend/toolshed/serializers.py | 14 +++- backend/toolshed/tests/test_group.py | 76 ++++++++++++++++++- frontend/src/store.js | 12 ++- 7 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 backend/authentication/migrations/0006_groupmembership.py diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 3aa5e5f..50dfdbe 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \ - AccountPreference, Group, GroupInvite, GroupInviteIncoming + AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership 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') +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(KnownIdentity, KnownIdentityAdmin) admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin) @@ -56,3 +61,4 @@ admin.site.register(AccountPreference, AccountPreferenceAdmin) admin.site.register(Group, GroupAdmin) admin.site.register(GroupInvite, GroupInviteAdmin) admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin) +admin.site.register(GroupMembership, GroupMembershipAdmin) diff --git a/backend/authentication/migrations/0006_groupmembership.py b/backend/authentication/migrations/0006_groupmembership.py new file mode 100644 index 0000000..1c764be --- /dev/null +++ b/backend/authentication/migrations/0006_groupmembership.py @@ -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')}, + }, + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index a19e759..fe27068 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -180,3 +180,23 @@ class GroupInviteIncoming(models.Model): 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) + + +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}" diff --git a/backend/toolshed/api/group.py b/backend/toolshed/api/group.py index 561e3d2..757d871 100644 --- a/backend/toolshed/api/group.py +++ b/backend/toolshed/api/group.py @@ -9,11 +9,12 @@ 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.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, 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 +from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer class Groups(APIView, ViewSetMixin): @@ -126,6 +127,34 @@ def declineGroupInvite(request, pk, format=None): # /api/groupinvites// 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//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']) def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend raw_request = request.body.decode('utf-8') @@ -161,5 +190,7 @@ urlpatterns = [ path('groups//invites/', createGroupInvite, name='create_group_invite'), path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'), path('groupinvites//', declineGroupInvite, name='decline_group_invite'), + path('groupinvites//accept/', recordGroupMembership, name='record_group_membership'), path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'), + path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'), ] diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 369222f..0bfe6b0 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,6 +1,7 @@ from django.core.exceptions import ObjectDoesNotExist 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 files.models import File from files.serializers import FileSerializer @@ -119,6 +120,17 @@ class GroupInviteIncomingSerializer(serializers.ModelSerializer): 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): category = serializers.SerializerMethodField() handle = serializers.SerializerMethodField() diff --git a/backend/toolshed/tests/test_group.py b/backend/toolshed/tests/test_group.py index b2542de..d6074b0 100644 --- a/backend/toolshed/tests/test_group.py +++ b/backend/toolshed/tests/test_group.py @@ -2,7 +2,7 @@ from django.test import Client from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \ DummyExternalUser -from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity +from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity client = SignatureAuthClient() @@ -124,9 +124,13 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): 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) + reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee) + self.assertEqual(reply5.status_code, 201) + self.assertEqual(reply5.json()['handle'], str(group)) 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): group = self.f['group1'] @@ -189,3 +193,69 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): }) self.assertEqual(reply.status_code, 401) 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) diff --git a/frontend/src/store.js b/frontend/src/store.js index 585e156..e9e725e 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -74,6 +74,7 @@ export default createStore({ friendProfiles: {}, groups: [], groupInvites: [], + groupMemberships: [], idmap: {identities: [], groups: []}, idmapLoaded: false, item_map: {}, @@ -114,6 +115,9 @@ export default createStore({ setGroupInvites(state, invites) { state.groupInvites = invites; }, + setGroupMemberships(state, memberships) { + state.groupMemberships = memberships; + }, setIdMap(state, idmap) { state.idmap = idmap; state.idmapLoaded = true; @@ -544,6 +548,12 @@ export default createStore({ commit('setGroupInvites', 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}) { const home_servers = await dispatch('getHomeServers') const home_reply = await home_servers.post( @@ -571,7 +581,7 @@ export default createStore({ secret: invite.secret }) 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 }, async declineGroupInvite({dispatch, getters}, invite) {