Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Django Rest Framework objects referenced using multiple keyword objects

I have a model that has a unique constraint on two fields together:

class Document(models.Model):
    filename = models.CharField(max_length=255)
    publication = models.CharField(max_length=8)

    class Meta:
        constraints = [
                models.UniqueConstraint(
                    fields=['filename', 'publication'], name='document_key')

As per the docsting in the DRF GenericAPIView get_object method:

    """
    Returns the object the view is displaying.
    You may want to override this if you need to provide non-standard
    queryset lookups.  Eg if objects are referenced using multiple
    keyword arguments in the url conf.
    """

Referencing with multiple keyword arguments is exactly what I want to do. I've gotten started on overriding the get_object method

class DocumentViewset(viewsets.ModelViewSet):
    serializer_class = serializers.ActSerializer 
    lookup_fields = ('filename', 'publication')

    def get_object(self):
        """As per the example in the DRF docstring itself,
        objects are referenced using multiple keyword arguments
        in the URL conf. Therefore, we need to override. 
        """
        queryset = self.filter_queryset(self.get_queryset())
        lookup_url_kwargs = self.lookup_fields

        print(lookup_url_kwargs)
        print(self.kwargs)

Which gives me:

('filename', 'publication')
{'pk': '17'}

You can see the prolem is that my lookup_url_kwargs will not be in self.kwargs (which is validated on the next line). If 'lookup_url_kwarg' is set then self.kwargs will be that. But without it, self.kwargs defaults to 'pk'. How do I override this behaviour so that two fields are expected in the URL?? Thank you.

)

like image 981
Neil Avatar asked Oct 19 '25 15:10

Neil


1 Answers

There are a couple of ways you can get the desired behavior, with increasing order of complexity:


First way:

Don't use the DRF router and define the URL endpoints manually like usual. Also, refer to the proper mapping e.g. for the detail URL with URL keyword arguments filename, publication:

urlpatterns = [
    path(
        'documents/<filename:str>/<publication:str>/',
        DocumentViewset.as_view({'get': 'retrieve'}),
        name='document-detail',
    ),
    ...
]

Now you'll get filename and publication keys with respective values in self.kwargs inside retrieve method.

You can add your path converters to have more control over the patterns allowed on each URL keyword match. For example, your URL might be logically better looked if you separate them by - instead of / (as / often means a sub-resource). Here, we're creating two converters for grabbing the portions before and after dash (-):

class BaseDashConverter:  
    def to_python(self, value):
        return value

    def to_url(self, value):
        return value

class BeforeDashConverter(BaseDashConverter):
    regex = '[^-]+'

class AfterDashConverter(BaseDashConverter):
    regex = '[^/]+'

Now, it's time to register these two:

register_converter(BeforeDashConverter, 'before-dash')
register_converter(AfterDashConverter, 'after-dash')

Then in the urlpatterns, you can do:

urlpatterns = [
    path(
        'documents/<filename:before-dash>-<publication:after-dash>/',
        DocumentViewset.as_view({'get': 'retrieve'}),
        name='document-detail',
    ),
    ...
]

You can also use re_path directly with Regex instead of creating converters and using path:

urlpatterns = [
    re_path(
        'documents/(?P<filename>[^-]+)-(?P<publication>[^/]+)/',
        DocumentViewset.as_view({'get': 'retrieve'}),
        name='document-detail',
    ),
    ...
]

FWIW, you need to add URL paths for all the method-action mappings as I did for get-retrieve.


Second way:

Assuming lookup_fields is a custom attribute of yours (viewsets actually use a single field referred by attribute lookup_field), you can name the lookup_kwargs as something like combined and take the filename and publication in the URL separated by - as /documents/<filename>-<publication>/ e.g. /documents/somename-foobar/. DRF Router's detail lookup matches one or more characters apart from . and / after the prefix so this will be matched.

If you do that then inside get_object you can add your custom logic, as an example:

class DocumentViewset(viewsets.ModelViewSet):
    serializer_class = serializers.ActSerializer 

    lookup_fields = ('filename', 'publication')
    lookup_url_kwarg = 'combined'

    def get_object(self):
        queryset = self.filter_queryset(self.get_queryset())

        lookup_url_kwarg = self.lookup_url_kwarg

        assert lookup_url_kwarg in self.kwargs, (
            'Expected view %s to be called with a URL keyword argument '
            'named "%s". Fix your URL conf, or set the `.lookup_field` '
            'attribute on the view correctly.' %
            (self.__class__.__name__, lookup_url_kwarg)
        )

        combined = self.kwargs[lookup_url_kwarg]
        filter_kwargs = dict(zip(self.lookup_fields, combined.partition('-')[::2]))
        obj = get_object_or_404(queryset, **filter_kwargs)

        self.check_object_permissions(self.request, obj)

        return obj

Third way:

Override DRF's Router and add custom Regex in the get_lookup_regex method to match the URL paths including both filename and publication.

The following is an example that like the previous approach will match any pattern in the form /documents/<filename>-<publication>/ (e.g. /documents/somename-foobar/) but now the URL keyword arguments will have two keys: filename and publication. You can change the pattern/format to your likings as you can imagine.

At first, we need to define the custom Router with overridden get_lookup_regex:

from rest_framework.routers import DefaultRouter

class CustomRouter(DefaultRouter):
    def get_lookup_regex(self, viewset, lookup_prefix=''):
        lookup_fields = getattr(viewset, 'lookup_fields', ('filename', 'publication'))
        lookup_url_kwargs = getattr(viewset, 'lookup_url_kwargs', lookup_fields)
        return (
            rf'(?P<{lookup_prefix}{lookup_url_kwargs[0]}>[^-]+)-'
            rf'(?P<{lookup_prefix}{lookup_url_kwargs[1]}>[^/.]+)'
        )

So this will check for lookup_fields and lookup_url_kwargs in ViewSet class to set the Regex keywords based on matched patterns.

Registering viewset would be as usual:

router = CustomRouter()
router.register('documents', DocumentViewSet)

The DocumentViewset leveraging the above Router can be like below with overridden get_queryset and lookup_fields/lookup_url_kwargs set:

class DocumentViewset(viewsets.ModelViewSet):
    serializer_class = serializers.ActSerializer 

    lookup_fields = ('filename', 'publication')
    lookup_url_kwargs = ('filename', 'publication')

    def get_object(self):
        queryset = self.filter_queryset(self.get_queryset())

        lookup_url_kwargs = self.lookup_url_kwargs or self.lookup_fields

        assert all(
            lookup_kwarg in self.kwargs
            for lookup_kwarg in lookup_url_kwargs
        ), (
            'Expected view %s to be called with URL keyword arguments '
            'named "%s". Fix your URL conf, or set the `.lookup_fields` '
            'attribute on the view correctly.' %
            (self.__class__.__name__, ','.join(lookup_url_kwargs))
        )

        field_values = (self.kwargs[lookup_kwarg] for lookup_kwarg in lookup_url_kwargs)
        filter_kwargs = dict(zip(self.lookup_fields, field_values))
        obj = get_object_or_404(queryset, **filter_kwargs)

        # May raise a permission denied
        self.check_object_permissions(self.request, obj)

        return obj

Each one of the above would get you the desired behavior. Pick the one that suits you the best.

N.B: The usage of - as a seaparator in an example, you can pick your own separator. But if you want to use / or . as the separator you need to use either the first or third way as the second one uses DefaultRouter.get_lookup_regex in where the pattern is mathced upto the next / or ..

like image 68
heemayl Avatar answered Oct 21 '25 04:10

heemayl