From 6e7fd5d0dd03b783cfcf99a1960a79d440988cf7 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 27 May 2020 16:03:16 +0300 Subject: [PATCH] Collection membership: implement leaving/revoking access. --- .../0013_collectionmemberremoved.py | 28 +++++++++++++++++++ django_etesync/models.py | 25 ++++++++++++++++- django_etesync/serializers.py | 6 ++-- django_etesync/views.py | 28 ++++++++++++++++++- 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 django_etesync/migrations/0013_collectionmemberremoved.py diff --git a/django_etesync/migrations/0013_collectionmemberremoved.py b/django_etesync/migrations/0013_collectionmemberremoved.py new file mode 100644 index 0000000..e796232 --- /dev/null +++ b/django_etesync/migrations/0013_collectionmemberremoved.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.3 on 2020-05-27 11:29 + +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), + ('django_etesync', '0012_auto_20200527_0743'), + ] + + operations = [ + migrations.CreateModel( + name='CollectionMemberRemoved', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='removed_members', to='django_etesync.Collection')), + ('stoken', models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, to='django_etesync.Stoken')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'collection')}, + }, + ), + ] diff --git a/django_etesync/models.py b/django_etesync/models.py index 91746ba..7fbea61 100644 --- a/django_etesync/models.py +++ b/django_etesync/models.py @@ -14,7 +14,7 @@ from pathlib import Path -from django.db import models +from django.db import models, transaction from django.conf import settings from django.core.validators import RegexValidator from django.db.models import Q @@ -158,6 +158,29 @@ class CollectionMember(models.Model): def __str__(self): return '{} {}'.format(self.collection.uid, self.user) + def revoke(self): + with transaction.atomic(): + CollectionMemberRemoved.objects.update_or_create( + collection=self.collection, user=self.user, + defaults={ + 'stoken': Stoken.objects.create(), + }, + ) + + self.delete() + + +class CollectionMemberRemoved(models.Model): + stoken = models.OneToOneField(Stoken, on_delete=models.PROTECT, null=True) + collection = models.ForeignKey(Collection, related_name='removed_members', on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + class Meta: + unique_together = ('user', 'collection') + + def __str__(self): + return '{} {}'.format(self.collection.uid, self.user) + class CollectionInvitation(models.Model): uid = models.CharField(db_index=True, blank=False, null=False, diff --git a/django_etesync/serializers.py b/django_etesync/serializers.py index 7079742..c5734d2 100644 --- a/django_etesync/serializers.py +++ b/django_etesync/serializers.py @@ -247,11 +247,10 @@ class CollectionMemberSerializer(serializers.ModelSerializer): slug_field=User.USERNAME_FIELD, queryset=User.objects ) - encryptionKey = BinaryBase64Field() class Meta: model = models.CollectionMember - fields = ('username', 'encryptionKey', 'accessLevel') + fields = ('username', 'accessLevel') def create(self, validated_data): raise NotImplementedError() @@ -324,6 +323,9 @@ class InvitationAcceptSerializer(serializers.Serializer): encryptionKey=encryption_key, ) + models.CollectionMemberRemoved.objects.filter( + user=invitation.user, collection=invitation.collection).delete() + invitation.delete() return member diff --git a/django_etesync/views.py b/django_etesync/views.py index 0f448fa..fd91aed 100644 --- a/django_etesync/views.py +++ b/django_etesync/views.py @@ -42,6 +42,7 @@ from .models import ( CollectionItem, CollectionItemRevision, CollectionMember, + CollectionMemberRemoved, CollectionInvitation, Stoken, UserInfo, @@ -181,6 +182,15 @@ class CollectionViewSet(BaseViewSet): 'data': serializer.data, 'stoken': new_stoken, } + + stoken_obj = self.get_stoken_obj(request) + if stoken_obj is not None: + # FIXME: honour limit? (the limit should be combined for data and this because of stoken) + remed = CollectionMemberRemoved.objects.filter(user=request.user, stoken__id__gt=stoken_obj.id) \ + .values_list('collection__uid', flat=True) + if len(remed) > 0: + ret['removedMemberships'] = [{'uid': x} for x in remed] + return Response(ret) @@ -417,7 +427,8 @@ class CollectionItemChunkViewSet(viewsets.ViewSet): class CollectionMemberViewSet(BaseViewSet): allowed_methods = ['GET', 'PUT', 'DELETE'] - permission_classes = BaseViewSet.permission_classes + (permissions.IsCollectionAdmin, ) + our_base_permission_classes = BaseViewSet.permission_classes + permission_classes = our_base_permission_classes + (permissions.IsCollectionAdmin, ) queryset = CollectionMember.objects.all() serializer_class = CollectionMemberSerializer lookup_field = 'user__' + User.USERNAME_FIELD @@ -441,6 +452,21 @@ class CollectionMemberViewSet(BaseViewSet): def create(self, request): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + # FIXME: block leaving if we are the last admins - should be deleted / assigned in this case depending if there + # are other memebers. + def perform_destroy(self, instance): + instance.revoke() + + @action_decorator(detail=False, methods=['POST'], permission_classes=our_base_permission_classes) + def leave(self, request, collection_uid=None): + collection_uid = self.kwargs['collection_uid'] + col = get_object_or_404(self.get_collection_queryset(Collection.objects), uid=collection_uid) + + member = col.members.get(user=request.user) + self.perform_destroy(member) + + return Response({}) + class InvitationOutgoingViewSet(BaseViewSet): allowed_methods = ['GET', 'POST', 'PUT', 'DELETE']