Khi ViewSet đã có đủ CRUD nhưng bạn cần thêm một thao tác riêng — ví dụ xuất bản bài viết hay liệt kê bài nổi bật — đừng tạo thêm ViewSet hay APIView rời rạc. Chỉ cần gắn decorator @action lên một method trong ViewSet: DRF tự sinh route và giữ nguyên permission, serializer, queryset của ViewSet đó. Cách này dùng cho mọi thao tác mà CRUD chuẩn không có, cả trên một object lẫn trên danh sách.

Vấn đề

Một ModelViewSet chỉ cho sẵn năm thao tác: list, retrieve, create, update và destroy. Muốn thêm nút “xuất bản”, nhiều người viết một APIView riêng rồi đăng ký route thủ công:

class PublishPostView(APIView):
def post(self, request, pk):
post = Post.objects.get(pk=pk)
post.is_published = True
post.save()
return Response({"is_published": True})

Cách này chạy được nhưng nhanh chóng rối: queryset, permission và serializer phải viết lại, route đăng ký tay nên dễ lệch chuẩn, và logic của cùng một tài nguyên bị phân tán ở hai nơi. Mỗi thao tác mới lại thêm một class, một route, một chỗ phải nhớ cập nhật khi đổi permission.

Giải pháp

Đặt thao tác ngay trong ViewSet và đánh dấu bằng @action. detail=True nghĩa là thao tác trên một object, detail=False là thao tác trên danh sách:

from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import viewsets
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
post = self.get_object()
post.is_published = True
post.save(update_fields=["is_published"])
return Response(self.get_serializer(post).data)
@action(detail=False, methods=["get"])
def featured(self, request):
posts = self.get_queryset().filter(is_featured=True)
return Response(self.get_serializer(posts, many=True).data)

Chỉ cần đăng ký ViewSet với router như bình thường, DRF tự sinh hai route: POST /posts/{pk}/publish/ và GET /posts/featured/. Ba lợi ích đi kèm: self.get_object() và self.get_queryset() tái sử dụng đúng queryset của ViewSet, kể cả khi bạn đã ghi đè get_queryset() để lọc theo user; permission và authentication của ViewSet áp dụng luôn cho action, khỏi khai báo lại; và tên method chính là tên route, nên nhìn ViewSet là thấy hết mọi endpoint của tài nguyên đó.

Hai chi tiết hay bị quên. Với action chỉ đọc, đặt methods=["get"]; với action thay đổi dữ liệu, dùng post, patch hoặc delete cho đúng ngữ nghĩa, đừng dùng get để sửa dữ liệu. Nếu tên method không hợp làm URL, truyền url_path="ten-khac" vào decorator để đặt lại đường dẫn mà không đổi tên method.

Áp dụng vào dự án của bạn

Mở ViewSet đang nhiều APIView phụ trợ nhất và rà soát: các endpoint kiểu approve, archive, duplicate hay export có đang nằm ngoài ViewSet không? Gom chúng về thành @action, mỗi action chỉ làm một việc và trả về đúng serializer của tài nguyên. Đẩy logic nghiệp vụ vào method của model, ví dụ post.publish(), để action chỉ lo lấy object, gọi method và trả response.

Bài viết liên quan

Câu hỏi thường gặp

@action khác gì ghi đè create hoặc update?

Ghi đè create/update chỉ thay đổi hành vi của thao tác CRUD có sẵn, còn @action tạo thêm endpoint hoàn toàn mới. Khi bạn cần một động từ riêng như publish hay archive, @action là công cụ đúng; đừng nhét logic đó vào update rồi phân biệt bằng cờ trong body.

detail=True và detail=False khác nhau thế nào?

detail=True tạo route có {pk}, lấy object bằng self.get_object(); detail=False tạo route trên collection, không có {pk}, làm việc với self.get_queryset(). Nhầm hai giá trị này là lỗi phổ biến nhất.

Có đổi tên URL của action được không?

Được. Truyền url_path vào decorator, ví dụ @action(detail=True, methods=["post"], url_path="xuat-ban") tạo route /posts/{pk}/xuat-ban/ trong khi tên method vẫn là publish.

Action có kế thừa permission của ViewSet không?

Có, mặc định action dùng chung permission_classes của ViewSet. Nếu một action cần nới hoặc siết riêng, truyền permission_classes=[...] ngay trong decorator @action — nó chỉ áp dụng cho action đó, không ảnh hưởng các endpoint còn lại.

💬 Bạn đã thử cách này trong dự án của mình chưa? Nếu gặp lỗi hoặc có cách làm gọn hơn, để lại bình luận bên dưới — mình đọc hết và sẽ bổ sung những ý hay vào bài.

Nguồn tham khảo