|
14 | 14 | DecimalField, |
15 | 15 | DurationField, FileField, JSONField |
16 | 16 | ) |
17 | | -from django.core.exceptions import FieldDoesNotExist |
| 17 | +from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured |
18 | 18 | from django.db.models import Q |
19 | 19 | from django.db.models.fields.related import ForeignKey |
| 20 | +from django.shortcuts import get_object_or_404 |
| 21 | +from rest_framework import status |
| 22 | +from rest_framework.response import Response |
20 | 23 |
|
21 | 24 | from .lookups import FieldLookup |
22 | 25 |
|
@@ -228,6 +231,249 @@ def get_queryset(self): |
228 | 231 |
|
229 | 232 | class ModelFilterFieldsMixin: |
230 | 233 | """ |
| 234 | + View mixin that requires `filterset_fields` to be defined on the view for filtering. |
| 235 | +
|
| 236 | + **Requires**: `django-filter` package and that the view defines `filterset_fields`, e.g.: |
| 237 | + filterset_fields = ["id", "name"] |
| 238 | +
|
| 239 | + Install django-filter with: |
| 240 | + pip install django-filter |
| 241 | + Or install with optional dependencies: |
| 242 | + pip install django-api-mixins[filters] |
| 243 | +
|
| 244 | + Works with: |
| 245 | + - rest_framework.views.APIView (set `model` on the view; get_queryset is auto-provided as model.objects.all() if not defined) |
| 246 | + - rest_framework.generics.GenericAPIView / ListAPIView etc. (uses queryset.model) |
| 247 | + - rest_framework.viewsets.ViewSet / ModelViewSet (uses queryset.model) |
| 248 | +
|
| 249 | + When used with APIView and the view does not define get_queryset(), the mixin |
| 250 | + provides get_queryset() returning model.objects.all(). |
| 251 | + Use get_filtered_queryset() in get() for the list branch instead of |
| 252 | + filter_queryset(self.get_queryset()). |
| 253 | + A default get(request, *args, **kwargs) is provided: if a detail key (e.g. pk) |
| 254 | + is present in URL kwargs, it returns the single object (404 if not found); |
| 255 | + otherwise it returns the filtered list (no pagination). |
| 256 | + To override get() but reuse mixin behavior (e.g. add pagination for list only): |
| 257 | + - For detail: return self.get_detail_response(request, *args, **kwargs) |
| 258 | + - For list: return self.get_list_response(request, *args, **kwargs) or, after |
| 259 | + paginating, return self.get_list_response(..., queryset=page) or wrap in |
| 260 | + get_paginated_response(serializer.data). |
| 261 | + Detail lookup uses lookup_url_kwarg (default 'pk') and lookup_field (default 'pk'). |
| 262 | + Set detail_not_found_message to customize the 404 response body (string -> {"error": "..."}). |
| 263 | +
|
| 264 | + Usage: |
| 265 | +
|
| 266 | + class UnitViewSet(ModelFilterFieldsMixin, ModelViewSet): |
| 267 | + queryset = Unit.objects.all() |
| 268 | + serializer_class = UnitSerializer |
| 269 | + filterset_fields = ["id", "name"] |
| 270 | +
|
| 271 | + class UnitAPIView(ModelFilterFieldsMixin, APIView): |
| 272 | + model = Unit |
| 273 | + serializer_class = UnitSerializer |
| 274 | + filterset_fields = ["id", "name"] |
| 275 | + # optional: detail_not_found_message = "Unit not found" |
| 276 | + # optional: override get() to add pagination for the list branch |
| 277 | + """ |
| 278 | + |
| 279 | + filterset_model = None # optional: use this model for filter fields instead of queryset.model |
| 280 | + lookup_url_kwarg = "pk" |
| 281 | + lookup_field = "pk" |
| 282 | + detail_not_found_message = "Not found" |
| 283 | + |
| 284 | + def _resolve_model(self, queryset=None): |
| 285 | + """ |
| 286 | + Resolve model for filterset_fields. |
| 287 | + Priority: |
| 288 | + 1. filterset_model |
| 289 | + 2. queryset.model |
| 290 | + 3. self.model (for APIView) |
| 291 | + """ |
| 292 | + if self.filterset_model: |
| 293 | + return self.filterset_model |
| 294 | + |
| 295 | + if queryset is not None and hasattr(queryset, "model"): |
| 296 | + return queryset.model |
| 297 | + |
| 298 | + return getattr(self, "model", None) |
| 299 | + |
| 300 | + def __init_subclass__(cls, **kwargs): |
| 301 | + super().__init_subclass__(**kwargs) |
| 302 | + # Check for django-filter dependency |
| 303 | + try: |
| 304 | + import django_filters |
| 305 | + except ImportError: |
| 306 | + from django.core.exceptions import ImproperlyConfigured |
| 307 | + raise ImproperlyConfigured( |
| 308 | + f"{cls.__name__} uses ModelFilterFieldsMixin which requires 'django-filter' package. " |
| 309 | + "Install it with: pip install django-filter\n" |
| 310 | + "Or install with optional dependencies: pip install django-api-mixins[filters]" |
| 311 | + ) |
| 312 | + super().__init_subclass__(**kwargs) |
| 313 | + |
| 314 | + if "filterset_fields" not in cls.__dict__ or getattr(cls, "filterset_fields", None) is None: |
| 315 | + from django.core.exceptions import ImproperlyConfigured |
| 316 | + raise ImproperlyConfigured( |
| 317 | + f"{cls.__name__} uses ModelFilterFieldsMixin but does not define 'filterset_fields'. " |
| 318 | + "Set it on the view, e.g. filterset_fields = ['id', 'name']." |
| 319 | + ) |
| 320 | + |
| 321 | + |
| 322 | + def get_queryset(self): |
| 323 | + try: |
| 324 | + queryset = super().get_queryset() |
| 325 | + except AttributeError: |
| 326 | + # No get_queryset in parent (e.g. plain APIView); use view.model so developers don't have to define it |
| 327 | + model = getattr(self, "model", None) |
| 328 | + queryset = getattr(self, "queryset", None) |
| 329 | + if model is None and queryset is None: |
| 330 | + raise ImproperlyConfigured( |
| 331 | + f"{self.__class__.__name__} must set either 'model' or queryset when using ModelFilterFieldsMixin with APIView " |
| 332 | + "and not defining get_queryset()." |
| 333 | + ) |
| 334 | + if model and queryset is None: |
| 335 | + queryset = model.objects.all() |
| 336 | + return queryset |
| 337 | + |
| 338 | + def get_filter_backends(self): |
| 339 | + """Return the list of filter backend classes. For APIView; GenericAPIView overrides this.""" |
| 340 | + # Ensure django-filter is available |
| 341 | + try: |
| 342 | + from django_filters.rest_framework import DjangoFilterBackend |
| 343 | + except ImportError: |
| 344 | + from django.core.exceptions import ImproperlyConfigured |
| 345 | + raise ImproperlyConfigured( |
| 346 | + f"{self.__class__.__name__} uses ModelFilterFieldsMixin which requires 'django-filter' package. " |
| 347 | + "Install it with: pip install django-filter\n" |
| 348 | + "Or install with optional dependencies: pip install django-api-mixins[filters]" |
| 349 | + ) |
| 350 | + from rest_framework.settings import api_settings |
| 351 | + return getattr(self, "filter_backends", None) or api_settings.DEFAULT_FILTER_BACKENDS or [] |
| 352 | + |
| 353 | + def filter_queryset(self, queryset): |
| 354 | + """Apply filter backends to the queryset. For APIView; GenericAPIView overrides this.""" |
| 355 | + for backend in self.get_filter_backends(): |
| 356 | + queryset = backend().filter_queryset(self.request, queryset, self) |
| 357 | + return queryset |
| 358 | + |
| 359 | + def get_filtered_queryset(self): |
| 360 | + """ |
| 361 | + Return the base queryset with all filter backends applied. |
| 362 | + Use this in get() (or other list handlers) instead of repeating |
| 363 | + filter_queryset(self.get_queryset()). |
| 364 | + """ |
| 365 | + return self.filter_queryset(self.get_queryset()) |
| 366 | + |
| 367 | + def get_object(self, pk=None): |
| 368 | + """ |
| 369 | + Return the model instance for the given pk (from arg or URL kwargs). |
| 370 | + Raises Http404 if not found. Used by get() for the detail branch. |
| 371 | + """ |
| 372 | + if pk is None: |
| 373 | + pk = self.kwargs.get(self.lookup_url_kwarg) |
| 374 | + if pk is None: |
| 375 | + from django.http import Http404 |
| 376 | + raise Http404(self.detail_not_found_message) |
| 377 | + queryset = self.filter_queryset(self.get_queryset()) |
| 378 | + return get_object_or_404(queryset, **{self.lookup_field: pk}) |
| 379 | + |
| 380 | + def get_serializer_class(self): |
| 381 | + """Resolve serializer_class for use in get_detail_response / get_list_response.""" |
| 382 | + serializer_class = getattr(self, "serializer_class", None) |
| 383 | + if serializer_class is None and hasattr(self, "get_serializer_class"): |
| 384 | + serializer_class = self.get_serializer_class() |
| 385 | + if serializer_class is None: |
| 386 | + raise ImproperlyConfigured( |
| 387 | + f"{self.__class__.__name__} must set 'serializer_class' (or implement get_serializer_class) " |
| 388 | + "for the default get() to work." |
| 389 | + ) |
| 390 | + return serializer_class |
| 391 | + |
| 392 | + def get_detail_data(self, request, *args, **kwargs): |
| 393 | + """ |
| 394 | + Return (body, status_code) for the single-object (detail) GET. |
| 395 | + Body is serialized data on success, or an error dict on 404. |
| 396 | + Use get_detail_response() to get a Response, or call this and build Response yourself. |
| 397 | + """ |
| 398 | + from django.http import Http404 |
| 399 | + |
| 400 | + serializer_class = self.get_serializer_class() |
| 401 | + try: |
| 402 | + obj = self.get_object(pk=kwargs.get(self.lookup_url_kwarg)) |
| 403 | + except Http404: |
| 404 | + msg = self.detail_not_found_message |
| 405 | + body = msg if isinstance(msg, dict) else {"error": msg} |
| 406 | + return body, status.HTTP_404_NOT_FOUND |
| 407 | + if hasattr(self, "get_serializer"): |
| 408 | + serializer = self.get_serializer(obj) |
| 409 | + else: |
| 410 | + serializer = serializer_class(obj) |
| 411 | + return serializer.data, status.HTTP_200_OK |
| 412 | + |
| 413 | + # def get_detail_response(self, request, *args, **kwargs): |
| 414 | + # """ |
| 415 | + # Return a Response for the single-object (detail) GET. |
| 416 | + # Uses get_detail_data(); returns 404 body if not found. |
| 417 | + # Override get() and call this for the detail branch to reuse mixin behavior. |
| 418 | + # """ |
| 419 | + # body, status_code = self.get_detail_data(request, *args, **kwargs) |
| 420 | + # return Response(body, status=status_code) |
| 421 | + |
| 422 | + def get_list_data(self, request, *args, queryset=None, **kwargs): |
| 423 | + """ |
| 424 | + Return serialized list data (no Response). |
| 425 | + If queryset is provided, use it (e.g. a paginated page); otherwise use get_filtered_queryset(). |
| 426 | + Use get_list_response() to get a Response, or call this and build Response yourself. |
| 427 | + """ |
| 428 | + serializer_class = self.get_serializer_class() |
| 429 | + if queryset is None: |
| 430 | + queryset = self.get_filtered_queryset() |
| 431 | + if hasattr(self, "get_serializer"): |
| 432 | + serializer = self.get_serializer(queryset, many=True) |
| 433 | + else: |
| 434 | + serializer = serializer_class(queryset, many=True) |
| 435 | + return serializer.data, status.HTTP_200_OK |
| 436 | + |
| 437 | + # def get_list_response(self, request, *args, queryset=None, **kwargs): |
| 438 | + # """ |
| 439 | + # Return a Response for the list GET (filtered queryset, serialized; no pagination). |
| 440 | + # If queryset is provided, use it (e.g. a paginated page); otherwise use get_filtered_queryset(). |
| 441 | + # Override get() and call this for the list branch to reuse mixin behavior, optionally |
| 442 | + # after paginating: e.g. page = self.paginate_queryset(self.get_filtered_queryset()); |
| 443 | + # return self.get_list_response(..., queryset=page) or wrap in get_paginated_response(). |
| 444 | + # """ |
| 445 | + # data, _status = self.get_list_data(request, *args, queryset=queryset, **kwargs) |
| 446 | + # return Response(data, status=_status) |
| 447 | + |
| 448 | + # def get(self, request, *args, **kwargs): |
| 449 | + # """ |
| 450 | + # List or detail GET: if lookup_url_kwarg (e.g. pk) is in kwargs, return |
| 451 | + # single object (404 if not found); else return filtered list. No pagination. |
| 452 | + # Override get() and delegate to get_detail_data() / get_list_data() to reuse |
| 453 | + # behavior (e.g. add pagination for list only). |
| 454 | + # """ |
| 455 | + # pk = kwargs.get(self.lookup_url_kwarg) |
| 456 | + # if pk is not None: |
| 457 | + # data, status_code = self.get_detail_data(request, *args, **kwargs) |
| 458 | + # # return self.get_detail_response(request, *args, **kwargs) |
| 459 | + # data,status_code = self.get_list_data(request, *args, **kwargs) |
| 460 | + # return Response(data, status_code) |
| 461 | + # # return self.get_list_response(request, *args, **kwargs) |
| 462 | + |
| 463 | + def get(self, request, *args, **kwargs): |
| 464 | + """ |
| 465 | + List or detail GET: if lookup_url_kwarg (e.g. pk) is in kwargs, return |
| 466 | + single object (404 if not found); else return filtered list. No pagination. |
| 467 | + Override get() and delegate to get_detail_data() / get_list_data() to reuse |
| 468 | + behavior (e.g. add pagination for list only). |
| 469 | + """ |
| 470 | + pk = kwargs.get(self.lookup_url_kwarg) |
| 471 | + if pk is not None: |
| 472 | + data, status_code = self.get_detail_data(request, *args, **kwargs) |
| 473 | + else: |
| 474 | + data,status_code = self.get_list_data(request, *args, **kwargs) |
| 475 | + return Response(data, status_code) |
| 476 | + """ |
231 | 477 | View mixin that sets `filterset_fields` from a model that uses ModelMixin |
232 | 478 | (or any model with a `get_filter_fields()` class method). |
233 | 479 |
|
|
0 commit comments