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