Search code examples
pythondjangodjango-rest-frameworkdjango-viewsdjango-rest-viewsets

Django Rest Framework: Updating / creating multiple objects dynamically, without giving the pk


I just stumbled upon the hardest problem I ever had with Django Rest Framework. Let me give you my models first, and then explain:

class Stampcardformat(models.Model):
    workunit    = models.ForeignKey(
        Workunit,
        on_delete=models.CASCADE
    )
    uuid        = models.UUIDField(
        default=uuid.uuid4,
        editable=False,
        unique=True
    )
    limit       = models.PositiveSmallIntegerField(
        default=10
    )
    category    = models.CharField(
        max_length=255
    )


class Stampcard(models.Model):
    stampcardformat = models.ForeignKey(
        Stampcardformat,
        on_delete=models.CASCADE
    )
    user        = models.ForeignKey(
        User,
        on_delete=models.CASCADE
    )
    uuid        = models.UUIDField(
        default=uuid.uuid4,
        editable=False,
        unique=True
    )


class Stamp(models.Model):
    stampcardformat = models.ForeignKey(
        Stampcardformat,
        on_delete=models.CASCADE
    )
    stampcard = models.ForeignKey(
        Stampcard,
        on_delete=models.CASCADE,
        blank=True,
        null=True
    )
    uuid        = models.UUIDField(
        default=uuid.uuid4,
        editable=False,
        unique=True
    )

These models describe a simple stampcard model. A stampcard is considered full, when it has as many stamps via foreignkey associated to it as it's stampcardformat's limit number dictates. I need to write view that does the following:

  1. The view takes in a list of stamps (see below) consisting of their uuid's.
  2. It then needs to find the right stampcardformat for each given stamp.
  3. Next it needs to check, whether the requests user has a stampcard with the corresponding stampcardformat.

    a) If it has, it needs to check, if the stampcard is full or not.

    i) If it is full, it needs to create a new stampcard of the given format and update the stamps stampcard-foreignkey to the created stampcard.

    ii) If it isn't full, it needs update the stamps stampcard-foreignkey to the found stampcard

    b) If the user hasn't got a stampcard of the given stampcardformat, it needs to create a new stampcard and update the stamps stampcard-foreignkey to the created stampcard.

Here is the request body list of stamps:

[
    {
        "stamp_uuid": "62c4070f-926a-41dd-a5b1-1ddc2afc01b2"
    },
    {
        "stamp_uuid": "4ad6513f-5171-4684-8377-1b00de4d6c87"
    },
    ...
]

The class based views don't seem to support this behaviour. I tried modifying the class based views, to no avail. I fail besides many points, because the view throws the error:

AssertionError: Expected view StampUpdate to be called with a URL keyword argument named "pk". Fix your URL conf, or set the `.lookup_field` attribute on the view correctly.

Edit

For additional context: I need the url to be without pk, slug or anything. So the url should just be something like:

/api/stampcards/stamps/ 

and do a put (or any request that has a body and works) to it. The route I wrote is:

url(r'^stamps/$', StampUpdate.as_view(), name='stamp-api-update'),

Edit: HUGE update. So I managed to cheese together a view that works. First I updated the stampcard model like this (I did add anew field 'done' to track if it is full):

class Stampcard(models.Model):
    stampcardformat = models.ForeignKey(
        Stampcardformat,
        on_delete=models.CASCADE
    )
    user        = models.ForeignKey(
        User,
        on_delete=models.CASCADE
    )
    uuid        = models.UUIDField(
        default=uuid.uuid4,
        editable=False,
        unique=True
    )
    done        = models.BooleanField(default=False)

Then I wrote the view like this:

class StampUpdate(APIView):
    permission_classes = (IsAuthenticated,)

    def get_object(self, uuid):
        try:
            return Stamp.objects.get(uuid=uuid)
        except Stamp.DoesNotExist():
            raise Http404

    def put(self, request, format=None):
    for stamp_data in request.data:
        stamp = self.get_object(stamp_data['stamp_uuid'])
        if stamp.stampcard==None:
            user_stampcard = self.request.user.stampcard_set.exclude(done=True).filter(stampcardformat=stamp.stampcardformat)
            if user_stampcard.exists():
                earliest_stampcard = user_stampcard.earliest('timestamp')
                stamp.stampcard = earliest_stampcard
                stamp.save()
                if earliest_stampcard.stamp_set.count() == earliest_stampcard.stampcardformat.limit:
                    earliest_stampcard.done=True
                    earliest_stampcard.save()
            else:
                new_stampcard = Stampcard(stampcardformat=stamp.stampcardformat, user=self.request.user)
                new_stampcard.save()
                stamp.stampcard = new_stampcard
                stamp.save()
    new_stampcards = Stampcard.objects.exclude(done=True).filter(user=self.request.user)
    last_full_stampcard = Stampcard.objects.filter(user=self.request.user).filter(done=True)
    if last_full_stampcard.exists():
        last_full_stampcard_uuid=last_full_stampcard.latest('updated').uuid
        last_full_stampcard = Stampcard.objects.filter(uuid=last_full_stampcard_uuid)
        stampcards = new_stampcards | last_full_stampcard
    else:
        stampcards = new_stampcards
    print(stampcards)
    stampcard_serializer = StampcardSerializer(stampcards, many=True)
    return Response(stampcard_serializer.data)

But I have two issues with this code:

  1. My intuition tells me that the parts where is just call save() on the model instance (e.g. stamp.save()) are very unsafe for an api. I couldn't get it to work to serialize the data first. My question is: Is this view okay like this? Or can I improve anything? It doesn't use generic class based used for example, but I don't know how to use them here...
  2. I would also love to return the stampcard, if it got filled up by this method. But I also want to exclude all non-relevant stampcards, which is why I called .exclude(done=True). A stampcard that got filled up unfortunately has done=True though! How can I add stampcards that got filled up in the process to the return value?

Solution

  • I don't think it's unsafe to have stamp.save() in PUT method because by definition it supposes to alter object's value.

    For returning only relevant stampcards, you could just add stampcard to a set like this

    class StampUpdateView(APIView):
        def get_object(self, uuid):
            try:
                return Stamp.objects.get(uuid=uuid)
            except Stamp.DoesNotExist():
                raise Http404
    
        def put(self, request, *args, **kwargs):
            stampcard_set = set()
            for stamp_data in request.data:
                stamp = self.get_object(stamp_data['stamp_uuid'])
    
                user_stampcard = request.user.stampcard_set.exclude(done=True).filter(stampcardformat=stamp.stampcardformat)
                if user_stampcard.exists():
                    stampcard = user_stampcard.earliest('timestamp')
                else:
                    stampcard = Stampcard(stampcardformat=stamp.stampcardformat, user=request.user)
                    stampcard.save()
    
                stamp.stampcard = stampcard
                stamp.save()
    
                if stampcard.stamp_set.count() == stampcard.stampcardformat.limit:
                    stampcard.done = True
                    stampcard.save()
    
                stampcard_set.add(stampcard)
    
            stampcard_serializer = StampcardSerializer(stampcard_set, many=True)
            return Response(stampcard_serializer.data)
    

    This way it doesn't matter if returning stampcards are already done or not.

    Also note that I move down limit checking lines in your code to be after when stamp was saved because if limit was set to 1, stampcard will have to be set as done immediately after a stamp was added.