In DRF's DefaultRouter url router, it requires a {lookup} parameter to route DELETE requests to the destroy method of a ModelViewSet (so, you'd make your request to delete an object instance to the endpoint {prefix}/{lookup}/).
This is fine for deleting a single instance, but I'd like to extend that functionality to deleting multiple instances on a single request. Let's say the lookup parameter is called uuid and the model is called Product. Here's an extended version of destroy:
def destroy(self, request, uuid=None):
"""
Overridden method allows either url parameter of single UUID
(to delete a single instance), or multiple query parameters `uuids`
to delete multiple instances.
"""
if not uuid:
uuids = request.query_params.get('uuids', None)
if not uuids:
return Response(status=status.HTTP_404_NOT_FOUND)
if len(uuids) != Product.objects.filter(uuid__in=uuids).count():
return Response(status=status.HTTP_404_NOT_FOUND)
Product.objects.filter(uuid__in=uuids).delete()
else:
instance = self.get_object(uuid)
if not instance:
return Response(status=status.HTTP_404_NOT_FOUND)
instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
So this version takes a DELETE request and multiple uuids[] query parameters in the url. Now I just need to route it in urls.py:
from rest_framework.routers import DefaultRouter, Route
class BulkDeleteRouter(DefaultRouter):
"""
a custom URL router for the Product API that correctly routes
DELETE requests with multiple query parameters.
"""
def __init__(self, *args, **kwargs):
super(BulkDeleteRouter, self).__init__(*args, **kwargs)
self.routes += [
Route(
url=r'^{prefix}{trailing_slash}$',
mapping={'delete': 'destroy'},
name='{basename}-delete',
initkwargs={'suffix': 'Delete'}
),
]
bulk_delete_router = BulkDeleteRouter()
bulk_delete_router.register(r'product', ProductViewSet, base_name='product')
This, unfortunately, has killed my url router. It won't resolve GET to the appropriate methods in the viewset, and I don't understand why - isn't my BulkDeleteRouter supposed to extend this functionality from the DefaultRouter? What did I do wrong?
Forgot to add the router urls to the urlpatterns. I must be blind.
urlpatterns += [
url(r'^API/', include(bulk_delete_router.urls, namespace='api')),
]
Adding an additional 'delete': 'destroy' to the 'List route' route will perfectly do the job.
class CustomRouter(DefaultRouter):
"""
a custom URL router for the Product API that correctly routes
DELETE requests with multiple query parameters.
"""
routes = [
# List route.
Route(
url=r'^{prefix}{trailing_slash}$',
mapping={
'get': 'list',
'post': 'create',
'delete': 'destroy', # The magic
},
name='{basename}-list',
detail=False,
initkwargs={'suffix': 'List'}
),
# Dynamically generated list routes. Generated using
# #action(detail=False) decorator on methods of the viewset.
DynamicRoute(
url=r'^{prefix}/{url_path}{trailing_slash}$',
name='{basename}-{url_name}',
detail=False,
initkwargs={}
),
# Detail route.
Route(
url=r'^{prefix}/{lookup}{trailing_slash}$',
mapping={
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
},
name='{basename}-detail',
detail=True,
initkwargs={'suffix': 'Instance'}
),
# Dynamically generated detail routes. Generated using
# #action(detail=True) decorator on methods of the viewset.
DynamicRoute(
url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
name='{basename}-{url_name}',
detail=True,
initkwargs={}
),
]
Then use the router like this:
custom_router = CustomRouter()
custom_router.register(r'your-endpoint', YourViewSet)
urlpatterns = [
url(r'^', include(custom_router.urls)),
]
The viewset:
from rest_framework import viewsets, status
from rest_framework.response import Response
from django.db.models import QuerySet
class MachineSegmentAnnotationViewSet(viewsets.ModelViewSet):
def destroy(self, request, *args, **kwargs):
qs: QuerySet = self.get_queryset(*args, **kwargs)
qs.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Hope this helps.
Related
How to add suffix url in ModelViewSet
Serializer
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = '__all__'
def update(self, instance, validated_data):
...
...
ModelViewSet
I'm doing a custom partial update
class CommentViewSet(viewsets.ModelViewSet):
queryset = Comment.objects.all()
serializer_class = CommentSerializer
http_method_names = ['get', 'patch', 'head', 'options']
def partial_update(self, request, *args, **kwargs):
super(CommentViewSet, self).partial_update(
request, *args, **kwargs)
return Response({
"data": request.data,
...
...
})
Urls
router = routers.DefaultRouter()
router.register(
"comments",
CommentViewSet
)
urlpatterns = [
path('api/v1/', include(router.urls))
]
Currently have this, but I want to add a suffix
url: http://localhost:8000/api/v1/comments/{id}
I want to do something like this
url: http://localhost:8000/api/v1/comments/{id}/update_or_whatever
What you want to do does not follow the REST architecture and popular practice. In REST, each endpoint represents a resource. The actions on the resource are represented by HTTP methods. So if you have the comments resource accessible through this url http://localhost:8000/api/v1/comments/, you can create (POST), get the list (GET) on the list endpoint and edit(PUT and PATCH), fetch a single comment (GET) and delete(DELETE) using the detail endpoint. In this way, you don't need to explicitly name the URL according to the action like http://localhost:8000/api/v1/comments/{id}/update. This is the architecture that DRF is built on and hence why you have this url style. Of course, there are actions like login and others that may not fit into this architecture and that's why DRF provides custom actions. But you should not use it to override the default actions mapped to HTTP methods
Another magic from DFR
https://www.django-rest-framework.org/api-guide/viewsets/#viewset-actions
Only change what u need in the view and add this action decorator.
In your views.py
#action(methods=['get'], detail=True, permission_classes=[IsAuthenticated])
def get_file(self, request, pk=None):
if pk is None:
raise ValueError("Found empty filename")
obj = self.get_queryset().filter(pk=pk).first()
if obj and obj.image_file:
return FileResponse(obj.image_file, content_type="image/jpeg")
return Response(
'Nothing to show',
status=status.HTTP_400_BAD_REQUEST)
I want to have TWO DIFFERENT viewsets (for example, one implements only the GET method, the other implements only the POST method), but which will have the same url:
GET /tournament/ - returns concrete object of the model Tournament;
POST /tournament/ - create object of the model Tournament.
But it is important that they must have the same url /tournament/!
I trying something like this:
models.py
class Tournament(Model):
...
viewsets.py
class PublicTournamentEndpoint(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet
):
queryset = Tournament.objects.all()
authentication_classes = [] # empty!
class PrivateTournamentEndpoint(
mixins.CreateModelMixin,
viewsets.GenericViewSet
):
queryset = Tournament.objects.all()
authentication_classes = ['SomeAuthBackend'] # not empty!
routers.py
class TournamentRouter(SimpleRouter):
routes = [
Route(
url=r'^{prefix}/tournament/$',
mapping={
'get': 'retrieve',
'post': 'create',
},
name='{basename}',
detail=True,
initkwargs={},
),
urls.py
tournament_router = TournamentRouter()
tournament_router.register(
'tournaments',
PublicTournamentEndpoint,
basename='tournaments',
)
tournament_router.register(
'tournaments',
PrivateTournamentEndpoint,
basename='tournaments',
)
urlpatterns += tournament_router.urls
But my urlpatterns has next values:
[
<URLPattern '^tournaments/tournament/$' [name='tournaments']>,
<URLPattern '^tournaments/tournament/$' [name='tournaments']>
]
and so when I send a POST /tournament/ request, I get the following error:
405 "Method \"POST\" not allowed."
because the first match url does not have a POST method, but only GET. How can i resolve this problems?
Thank you!
You can't call 2 views for 1 url. Seem like you only want to allow specific user who has SomeAuthBackend permission can create new Tournament, if so, you could custom your permission class a bit to check permission only on POST requests like so:
from rest_framework import permissions
class SomeAuthBackend(permissions.BasePermission):
protected_methods = ['POST',]
def has_permission(self, request, view):
if request.method in self.protected_methods:
# Check permission here
return True
class TournamentEndpoint(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet
):
queryset = Tournament.objects.all()
authentication_classes = [SomeAuthBackend, ] # empty!
With this, other method still works but when user send a POST request, it require to pass the permission check on SomeAuthBackend.
Here's the viewset
class MobileDeviceViewset(ModelViewSet):
#action(
methods=['post', 'put', 'patch'],
url_path='token',
detail=True,
)
def update_token(self, request, *args, **kwargs) -> Response:
...
#action(
methods=['get'],
url_path='token',
detail=True,
)
def get_token(self, request, *args, **kwargs) -> Response:
...
So what I want to do here is have an endpoint /token/ that the app will send a GET request to to check if there is a token, and get it if there is. I also want to use that same /token/ endpoint to send an updated token to. What happens currently is that I get an error telling me that the POST/PATCH/PUT methods are not allowed on that endpoint, so it appears to only be recognizing the get_token method. The token object here is actually a ManyToMany through model called MobileDeviceUser, so I'm not just trying to update a field on the MobileDevice object.
Since your url_path and detail are the same in both cases, why do you want two separate methods in your views??
Anyway I would recommend this way,
class MobileDeviceViewset(ModelViewSet):
# your code
#action(methods=['get', 'post', 'put', 'patch'], url_path='token', detail=True, )
def my_action(self, request, *args, **kwargs):
if request.method == 'GET':
return self.get_token(request, *args, **kwargs)
else:
return self.update_token(request, *args, **kwargs)
def update_token(self, request, *args, **kwargs):
return Response("update token response--{}".format(request.method))
def get_token(self, request, *args, **kwargs):
return Response("update token response--{}".format(request.method))
Then you have to make some changes in your URL config,
from django.urls import path
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register('mysample', MobileDeviceViewset, base_name='mobile-device')
actions = {
"get": "my_action",
"post": "my_action",
"put": "my_action",
"patch": "my_action"
}
urlpatterns = [
path('mysample/<pk>/token/', MobileDeviceViewset.as_view(actions=actions))
] + router.urls
Hence, your URL will some something like, ..../mysample/3/token/
NOTE
This solution tested under Python 3.6, Django==2.1 and DRF==3.8.2
UPDATE
Why Method Not Allowed error?
When a request comes to Django, it searches for the patterns in the URL configs and sends the request to the corresponding view if a match occurs.
In your case, you've defined two views (yes..it's actions though) with the same URL (as below,).
actions = {
"post": "update_token",
"put": "update_token",
"patch": "update_token"
}
urlpatterns = [
path('mysample/<pk>/token/', MobileDeviceViewset.as_view(actions={"get": "get_token"})),
path('mysample/<pk>/token/', MobileDeviceViewset.as_view(actions=actions))
] + router.urls
In this case, a request comes (let it be a HTTP POST) and the URL dispatcher redirects to the first view which satisfies the URL path. So, the POST request goes into the get_token method, but, it's only allowed for GET method
What is the possible solution?
Method-1:
As I described in the top, use a common action and distingush the HTTP METHODS and call appropriate methods
Method-2:
Use different URL path for both actions, as
actions = {
"post": "my_action",
"put": "my_action",
"patch": "my_action"
}
urlpatterns = [
path('mysample/<pk>/get-token/', MobileDeviceViewset.as_view(actions={"get": "get_token"})),
path('mysample/<pk>/update-token/', MobileDeviceViewset.as_view(actions=actions))
] + router.urls
you can do this via:
class MobileDeviceViewset(ModelViewSet):
#action(
methods=['get'],
url_path='token',
url_name='token',
detail=True,
)
def get_token(self, request, *args, **kwargs) -> Response:
...
#get_token.mapping.post
#get_token.mapping.put
#get_token.mapping.patch
def update_token(self, request, *args, **kwargs) -> Response:
...
https://www.django-rest-framework.org/api-guide/viewsets/#routing-additional-http-methods-for-extra-actions
How can I make a ModelViewSet accept the POST method to create an object? When I attempt to call the endpoint I get a 405 'Method "POST" not allowed.'.
Within views.py:
class AccountViewSet(viewsets.ModelViewSet):
"""An Account ModelViewSet."""
model = Account
serializer_class = AccountSerializer
queryset = Account.objects.all().order_by('name')
Within serializers.py:
class AccountSerializer(serializers.ModelSerializer):
name = serializers.CharField(required=False)
active_until = serializers.DateTimeField()
class Meta:
model = Account
fields = [
'name',
'active_until',
]
def create(self, validated_data):
with transaction.atomic():
Account.objects.create(**validated_data)
within urls.py:
from rest_framework import routers
router = routers.SimpleRouter()
router.register(
prefix=r'v1/auth/accounts',
viewset=AccountViewSet,
base_name='accounts',
)
Do I need to create a specific #action? my attempts to do so have yet to be successful. If that is the case what would the url = reverse('app:accounts-<NAME>') be such that I can call it from tests? I haven't found a full example (urls.py, views.py, serializers.py, and tests etc).
I discovered what the issue was, I had a conflicting route. There was a higher level endpoint registered before the AccountViewSet.
router.register(
prefix=r'v1/auth',
viewset=UserViewSet,
base_name='users',
)
router.register(
prefix=r'v1/auth/accounts',
viewset=AccountViewSet,
base_name='accounts',
)
Django runs through each URL pattern, in order, and stops at the first one that matches the requested URL.. I should have been ordered this way:
router.register(
prefix=r'v1/auth/accounts',
viewset=AccountViewSet,
base_name='accounts',
)
router.register(
prefix=r'v1/auth',
viewset=UserViewSet,
base_name='users',
)
despite the fact that reverse('appname:acccounts-list') worked, the underlying URL router still thought I was calling the UserViewSet.
From the docs:
A ViewSet class is simply a type of class-based View, that does not provide any method handlers such as .get() or .post(), and instead provides actions such as .list() and .create().
And here is a list of supported actions:
def list(self, request):
pass
def create(self, request):
pass
def retrieve(self, request, pk=None):
pass
def update(self, request, pk=None):
pass
def partial_update(self, request, pk=None):
pass
def destroy(self, request, pk=None):
pass
So no post is not directly supported but create is.
So your end point would be v1/auth/accounts/create when using the a router instead v1/auth/accounts/post.
I honestly prefer using class based or function based views when working with DRF. It resembles regular django views more closely and makes more sense to me when working with them. You woul write you views and urls pretty much like regular django urls and views.
I'm using the Django Sitemap Framework
I've no problem retrieving a list of articles from my DB.
class ArticleSitemap(Sitemap):
def items(self):
return articles.objects.filter(tagid=1399).order_by('-publisheddate')
I now want to accept a query parameter to filter by an inputted tag id ie:
sitemap.xml?tagid=1000
I have yet to find an example in the docs or on stack.
It is not possible to access HttpRequest object from Sitemap class. Probably the easiest way is to create your own view(s) for the sitemap(s), do what you need to do with HttpRequest and call Django internal view to do the final rendering of XML.
Setup your sitemap URLs as Django docs says (https://docs.djangoproject.com/en/dev/ref/contrib/sitemaps/#initialization), but use your own view(s).
urls.py:
from my_app.sitemap_views import custom_sitemap_index, custom_sitemap_section
sitemaps = {
"foo": FooSitemap,
"bar": BarSitemap,
}
urlpatterns = [
url(
r"^sitemap\.xml$",
custom_sitemap_index,
{"sitemaps": sitemaps},
name="sitemap_index",
),
url(
r"^sitemap-(?P<section>.+)\.xml$",
custom_sitemap_section,
{"sitemaps": sitemaps},
name="sitemaps",
),
# ...
]
Your custom sitemap views are standard Django views: you can access HttpRequest, database, cache...
sitemap_views.py:
import copy
from django.contrib.sitemaps import views as django_sitemaps_views
from django.contrib.sitemaps.views import x_robots_tag
#x_robots_tag
def custom_sitemap_index(
request,
sitemaps,
template_name="sitemap_index.xml",
content_type="application/xml",
sitemap_url_name="django.contrib.sitemaps.views.sitemap",
):
print("You can access request here.", request)
return django_sitemaps_views.index(
request, template_name, content_type, sitemaps, sitemap_url_name
)
#x_robots_tag
def custom_sitemap_section(
request,
sitemaps,
section=None,
template_name="sitemap.xml",
content_type="application/xml",
):
tag_id = int(request.GET.get("tagid"))
# We do not want to modify global variable "sitemaps"!
# Otherwise sitemap instances would be shared across requests (tag_id should be dynamic).
sitemaps_copy = copy.deepcopy(sitemaps)
for section, site in sitemaps_copy.items():
if callable(site):
sitemaps_copy[section] = site(tag_id=tag_id)
return django_sitemaps_views.sitemap(
request, sitemaps_copy, section, template_name, content_type
)
sitemap.py:
from django.contrib.sitemaps import Sitemap
class FooSitemap(Sitemap):
def __init__(self, tag_id: int):
self.tag_id = tag_id
super().__init__()
def items(self):
return (
Articles.objects.filter(tagid=1399)
.filter(tag_id=self.tag_id)
.order_by("-publisheddate")
)
class BarSitemap(Sitemap):
pass
# ...
# ...
Its in the request's Get-attribute:
the url '.../names/getNames?pattern=Helm' results in a request-object that has as GET : <QueryDict: {'pattern': ['Helm']}>