From 267d749c45a52378310705768c3b6685df8744f6 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Tue, 23 Jun 2020 12:55:28 +0300 Subject: [PATCH] Collection: change collections to be an extension of items Each collection now has an item and the item's UID is the collections UID. This lets us manipulate collections just like items, and as part of transactions. This is significant because it lets us change them as part of transactions! --- .../migrations/0016_auto_20200623_0820.py | 31 +++++++++++++ django_etebase/models.py | 11 ++--- django_etebase/permissions.py | 6 +-- django_etebase/serializers.py | 43 ++++++++++--------- django_etebase/views.py | 37 ++++++---------- 5 files changed, 72 insertions(+), 56 deletions(-) create mode 100644 django_etebase/migrations/0016_auto_20200623_0820.py diff --git a/django_etebase/migrations/0016_auto_20200623_0820.py b/django_etebase/migrations/0016_auto_20200623_0820.py new file mode 100644 index 0000000..2c11157 --- /dev/null +++ b/django_etebase/migrations/0016_auto_20200623_0820.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.3 on 2020-06-23 08:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_etebase', '0015_collectionitemrevision_salt'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='main_item', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parent', to='django_etebase.CollectionItem'), + ), + migrations.AlterUniqueTogether( + name='collection', + unique_together=set(), + ), + migrations.RemoveField( + model_name='collection', + name='uid', + ), + migrations.RemoveField( + model_name='collection', + name='version', + ), + ] diff --git a/django_etebase/models.py b/django_etebase/models.py index 53239e7..2702389 100644 --- a/django_etebase/models.py +++ b/django_etebase/models.py @@ -27,20 +27,15 @@ UidValidator = RegexValidator(regex=r'^[a-zA-Z0-9]*$', message='Not a valid UID' class Collection(models.Model): - uid = models.CharField(db_index=True, blank=False, null=False, - max_length=43, validators=[UidValidator]) - version = models.PositiveSmallIntegerField() + main_item = models.ForeignKey('CollectionItem', related_name='parent', null=True, on_delete=models.SET_NULL) owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - class Meta: - unique_together = ('uid', 'owner') - def __str__(self): return self.uid @cached_property - def main_item(self): - return self.items.get(uid=None) + def uid(self): + return self.main_item.uid @property def content(self): diff --git a/django_etebase/permissions.py b/django_etebase/permissions.py index a4217a8..6a36afb 100644 --- a/django_etebase/permissions.py +++ b/django_etebase/permissions.py @@ -31,7 +31,7 @@ class IsCollectionAdmin(permissions.BasePermission): def has_permission(self, request, view): collection_uid = view.kwargs['collection_uid'] try: - collection = view.get_collection_queryset().get(uid=collection_uid) + collection = view.get_collection_queryset().get(main_item__uid=collection_uid) return is_collection_admin(collection, request.user) except Collection.DoesNotExist: # If the collection does not exist, we want to 404 later, not permission denied. @@ -53,7 +53,7 @@ class IsCollectionAdminOrReadOnly(permissions.BasePermission): return True try: - collection = view.get_collection_queryset().get(uid=collection_uid) + collection = view.get_collection_queryset().get(main_item__uid=collection_uid) if request.method in permissions.SAFE_METHODS: return True @@ -73,7 +73,7 @@ class HasWriteAccessOrReadOnly(permissions.BasePermission): def has_permission(self, request, view): collection_uid = view.kwargs['collection_uid'] try: - collection = view.get_collection_queryset().get(uid=collection_uid) + collection = view.get_collection_queryset().get(main_item__uid=collection_uid) if request.method in permissions.SAFE_METHODS: return True else: diff --git a/django_etebase/serializers.py b/django_etebase/serializers.py index c401d91..c194fdd 100644 --- a/django_etebase/serializers.py +++ b/django_etebase/serializers.py @@ -182,15 +182,19 @@ class CollectionItemBulkGetSerializer(serializers.ModelSerializer): class CollectionSerializer(serializers.ModelSerializer): - encryptionKey = CollectionEncryptionKeyField() + collectionKey = CollectionEncryptionKeyField() accessLevel = serializers.SerializerMethodField('get_access_level_from_context') stoken = serializers.CharField(read_only=True) + + uid = serializers.CharField(source='main_item.uid') + encryptionKey = BinaryBase64Field(source='main_item.encryptionKey') etag = serializers.CharField(allow_null=True, write_only=True) - content = CollectionItemRevisionSerializer(many=False) + version = serializers.IntegerField(min_value=0, source='main_item.version') + content = CollectionItemRevisionSerializer(many=False, source='main_item.content') class Meta: model = models.Collection - fields = ('uid', 'version', 'accessLevel', 'encryptionKey', 'content', 'stoken', 'etag') + fields = ('uid', 'version', 'accessLevel', 'encryptionKey', 'collectionKey', 'content', 'stoken', 'etag') def get_access_level_from_context(self, obj): request = self.context.get('request', None) @@ -200,9 +204,16 @@ class CollectionSerializer(serializers.ModelSerializer): def create(self, validated_data): """Function that's called when this serializer creates an item""" + collection_key = validated_data.pop('collectionKey') + etag = validated_data.pop('etag') - revision_data = validated_data.pop('content') - encryption_key = validated_data.pop('encryptionKey') + + main_item_data = validated_data.pop('main_item') + uid = main_item_data.pop('uid') + version = main_item_data.pop('version') + revision_data = main_item_data.pop('content') + encryption_key = main_item_data.pop('encryptionKey') + instance = self.__class__.Meta.model(**validated_data) with transaction.atomic(): @@ -211,7 +222,10 @@ class CollectionSerializer(serializers.ModelSerializer): instance.save() main_item = models.CollectionItem.objects.create( - uid=None, encryptionKey=None, version=instance.version, collection=instance) + uid=uid, encryptionKey=encryption_key, version=version, collection=instance) + + instance.main_item = main_item + instance.save() process_revisions_for_item(main_item, revision_data) @@ -219,26 +233,13 @@ class CollectionSerializer(serializers.ModelSerializer): stoken=models.Stoken.objects.create(), user=validated_data.get('owner'), accessLevel=models.AccessLevels.ADMIN, - encryptionKey=encryption_key, + encryptionKey=collection_key, ).save() return instance def update(self, instance, validated_data): - """Function that's called when this serializer is meant to update an item""" - revision_data = validated_data.pop('content') - - with transaction.atomic(): - main_item = instance.main_item - # We don't have to use select_for_update here because the unique constraint on current guards against - # the race condition. But it's a good idea because it'll lock and wait rather than fail. - current_revision = main_item.revisions.filter(current=True).select_for_update().first() - current_revision.current = None - current_revision.save() - - process_revisions_for_item(main_item, revision_data) - - return instance + raise NotImplementedError() class CollectionMemberSerializer(serializers.ModelSerializer): diff --git a/django_etebase/views.py b/django_etebase/views.py index 70e635b..4f5d757 100644 --- a/django_etebase/views.py +++ b/django_etebase/views.py @@ -147,11 +147,12 @@ class BaseViewSet(viewsets.ModelViewSet): class CollectionViewSet(BaseViewSet): - allowed_methods = ['GET', 'POST', 'DELETE'] + allowed_methods = ['GET', 'POST'] permission_classes = BaseViewSet.permission_classes + (permissions.IsCollectionAdminOrReadOnly, ) queryset = Collection.objects.all() serializer_class = CollectionSerializer - lookup_field = 'uid' + lookup_field = 'main_item__uid' + lookup_url_kwarg = 'uid' stoken_id_fields = ['items__revisions__stoken__id', 'members__stoken__id'] def get_queryset(self, queryset=None): @@ -173,19 +174,7 @@ class CollectionViewSet(BaseViewSet): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) def update(self, request, *args, **kwargs): - instance = self.get_object() - - stoken = request.GET.get('stoken', None) - - if stoken is not None and stoken != instance.stoken: - content = {'code': 'stale_stoken', 'detail': 'Stoken is too old'} - return Response(content, status=status.HTTP_400_BAD_REQUEST) - - serializer = self.get_serializer(instance, data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - - return Response({}) + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -216,7 +205,7 @@ class CollectionViewSet(BaseViewSet): 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) + .values_list('collection__main_item__uid', flat=True) if len(remed) > 0: ret['removedMemberships'] = [{'uid': x} for x in remed] @@ -234,7 +223,7 @@ class CollectionItemViewSet(BaseViewSet): def get_queryset(self): collection_uid = self.kwargs['collection_uid'] try: - collection = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + collection = self.get_collection_queryset(Collection.objects).get(main_item__uid=collection_uid) except Collection.DoesNotExist: raise Http404("Collection does not exist") # XXX Potentially add this for performance: .prefetch_related('revisions__chunks') @@ -280,7 +269,7 @@ class CollectionItemViewSet(BaseViewSet): @action_decorator(detail=True, methods=['GET']) def revision(self, request, collection_uid=None, uid=None): # FIXME: need pagination support - col = get_object_or_404(self.get_collection_queryset(Collection.objects), uid=collection_uid) + col = get_object_or_404(self.get_collection_queryset(Collection.objects), main_item__uid=collection_uid) col_it = get_object_or_404(col.items, uid=uid) serializer = CollectionItemRevisionSerializer(col_it.revisions.order_by('-id'), many=True) @@ -336,7 +325,7 @@ class CollectionItemViewSet(BaseViewSet): with transaction.atomic(): # We need this for locking on the collection object collection_object = get_object_or_404( self.get_collection_queryset(Collection.objects).select_for_update(), # Lock writes on the collection - uid=collection_uid) + main_item__uid=collection_uid) if stoken is not None and stoken != collection_object.stoken: content = {'code': 'stale_stoken', 'detail': 'Stoken is too old'} @@ -388,7 +377,7 @@ class CollectionItemChunkViewSet(viewsets.ViewSet): return queryset.filter(members__user=user) def create(self, request, collection_uid=None, collection_item_uid=None): - col = get_object_or_404(self.get_collection_queryset(), uid=collection_uid) + col = get_object_or_404(self.get_collection_queryset(), main_item__uid=collection_uid) col_it = get_object_or_404(col.items, uid=collection_item_uid) serializer = self.get_serializer_class()(data=request.data) @@ -408,7 +397,7 @@ class CollectionItemChunkViewSet(viewsets.ViewSet): import os from django.views.static import serve - col = get_object_or_404(self.get_collection_queryset(), uid=collection_uid) + col = get_object_or_404(self.get_collection_queryset(), main_item__uid=collection_uid) col_it = get_object_or_404(col.items, uid=collection_item_uid) chunk = get_object_or_404(col_it.chunks, uid=uid) @@ -436,7 +425,7 @@ class CollectionMemberViewSet(BaseViewSet): def get_queryset(self, queryset=None): collection_uid = self.kwargs['collection_uid'] try: - collection = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + collection = self.get_collection_queryset(Collection.objects).get(main_item__uid=collection_uid) except Collection.DoesNotExist: raise Http404('Collection does not exist') @@ -478,7 +467,7 @@ class CollectionMemberViewSet(BaseViewSet): @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) + col = get_object_or_404(self.get_collection_queryset(Collection.objects), main_item__uid=collection_uid) member = col.members.get(user=request.user) self.perform_destroy(member) @@ -534,7 +523,7 @@ class InvitationOutgoingViewSet(InvitationBaseViewSet): collection_uid = serializer.validated_data.get('collection', {}).get('uid') try: - collection = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + collection = self.get_collection_queryset(Collection.objects).get(main_item__uid=collection_uid) except Collection.DoesNotExist: raise Http404('Collection does not exist')