Skip to content

Commit 3f08e7c

Browse files
authored
Merge pull request #9 from Subhrans/dev
Dev
2 parents 1427ae4 + e211213 commit 3f08e7c

4 files changed

Lines changed: 264 additions & 4 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ pip install django-api-mixins[spectacular]
4040
pip install django-api-mixins[all]
4141
```
4242

43+
**Upgrade to the latest version:**
44+
```bash
45+
pip install --upgrade django-api-mixins
46+
```
47+
4348
## Requirements
4449

4550
**Core dependencies (required):**
@@ -402,6 +407,15 @@ Contributions are welcome! Here’s how to contribute:
402407

403408
7. **Open a Pull Request** from your branch to the main repository’s default branch. Describe what you changed and why; link any related issues.
404409

410+
## Contributors
411+
412+
<!-- Thanks to everyone who has contributed to this project: -->
413+
414+
<!-- Add contributors here, e.g.:
415+
- [@username](https://github.com/username) - Description of contribution
416+
- [Jane Doe](https://github.com/janedoe) - Description of contribution
417+
-->
418+
405419
## License
406420

407421
MIT License - see LICENSE file for details.
@@ -420,4 +434,4 @@ pip install django-api-mixins
420434

421435
**PyPI Project Page**: [https://pypi.org/project/django-api-mixins/](https://pypi.org/project/django-api-mixins/)
422436

423-
**Latest Version**: 0.1.3 (Released: Feb 22, 2026)
437+
**Latest Version**: 0.1.5 (Released: Feb 24, 2026)

django_api_mixins/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from .lookups import FieldLookup
1616

17-
__version__ = "0.1.2"
17+
__version__ = "0.1.5"
1818
__all__ = [
1919
"APIMixin",
2020
"ModelMixin",

django_api_mixins/mixins.py

Lines changed: 247 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
DecimalField,
1515
DurationField, FileField, JSONField
1616
)
17-
from django.core.exceptions import FieldDoesNotExist
17+
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
1818
from django.db.models import Q
1919
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
2023

2124
from .lookups import FieldLookup
2225

@@ -228,6 +231,249 @@ def get_queryset(self):
228231

229232
class ModelFilterFieldsMixin:
230233
"""
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+
"""
231477
View mixin that sets `filterset_fields` from a model that uses ModelMixin
232478
(or any model with a `get_filter_fields()` class method).
233479

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "django-api-mixins"
7-
version = "0.1.3"
7+
version = "0.1.5"
88
description = "Django REST Framework mixins for ViewSets and APIViews - APIMixin, ModelMixin, RelationshipFilterMixin, RoleBasedFilterMixin. Simplify Django API development with reusable mixins."
99
readme = "README.md"
1010
requires-python = ">=3.8"

0 commit comments

Comments
 (0)