Skip to content

Commit

Permalink
Add official support for Django 5.1 (#9514)
Browse files Browse the repository at this point in the history
* Add official support for Django 5.1

Following the supported Python versions:

https://docs.djangoproject.com/en/stable/faq/install/

* Add tests to cover compat with Django's 5.1 LoginRequiredMiddleware

* First pass to create DRF's LoginRequiredMiddleware

* Attempt to fix the tests

* Revert custom middleware implementation

* Disable LoginRequiredMiddleware on DRF views

* Document how to integrate DRF with LoginRequiredMiddleware

* Move login required tests under a separate test case

* Revert redundant change

* Disable LoginRequiredMiddleware on ViewSets

* Add some integrations tests to cover various view types
  • Loading branch information
browniebroke authored Sep 7, 2024
1 parent 125ad42 commit 2ede857
Show file tree
Hide file tree
Showing 10 changed files with 119 additions and 12 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Some reasons you might want to use REST framework:
# Requirements

* Python 3.8+
* Django 5.0, 4.2
* Django 4.2, 5.0, 5.1

We **highly recommend** and only officially support the latest patch release of
each Python and Django series.
Expand Down
7 changes: 7 additions & 0 deletions docs/api-guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ The kind of response that will be used depends on the authentication scheme. Al

Note that when a request may successfully authenticate, but still be denied permission to perform the request, in which case a `403 Permission Denied` response will always be used, regardless of the authentication scheme.

## Django 5.1+ `LoginRequiredMiddleware`

If you're running Django 5.1+ and use the [`LoginRequiredMiddleware`][login-required-middleware], please note that all views from DRF are opted-out of this middleware. This is because the authentication in DRF is based authentication and permissions classes, which may be determined after the middleware has been applied. Additionally, when the request is not authenticated, the middleware redirects the user to the login page, which is not suitable for API requests, where it's preferable to return a 401 status code.

REST framework offers an equivalent mechanism for DRF views via the global settings, `DEFAULT_AUTHENTICATION_CLASSES` and `DEFAULT_PERMISSION_CLASSES`. They should be changed accordingly if you need to enforce that API requests are logged in.

## Apache mod_wsgi specific configuration

Note that if deploying to [Apache using mod_wsgi][mod_wsgi_official], the authorization header is not passed through to a WSGI application by default, as it is assumed that authentication will be handled by Apache, rather than at an application level.
Expand Down Expand Up @@ -484,3 +490,4 @@ More information can be found in the [Documentation](https://django-rest-durin.r
[drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless
[django-rest-authemail]: https://github.com/celiao/django-rest-authemail
[django-rest-durin]: https://github.com/eshaan7/django-rest-durin
[login-required-middleware]: https://docs.djangoproject.com/en/stable/ref/middleware/#django.contrib.auth.middleware.LoginRequiredMiddleware
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ continued development by **[signing up for a paid plan][funding]**.

REST framework requires the following:

* Django (4.2, 5.0)
* Django (4.2, 5.0, 5.1)
* Python (3.8, 3.9, 3.10, 3.11, 3.12)

We **highly recommend** and only officially support the latest patch release of
Expand Down
6 changes: 6 additions & 0 deletions rest_framework/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Provides an APIView class that is the base of all views in REST framework.
"""
from django import VERSION as DJANGO_VERSION
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import connections, models
Expand Down Expand Up @@ -139,6 +140,11 @@ def force_evaluation():
view.cls = cls
view.initkwargs = initkwargs

# Exempt all DRF views from Django's LoginRequiredMiddleware. Users should set
# DEFAULT_PERMISSION_CLASSES to 'rest_framework.permissions.IsAuthenticated' instead
if DJANGO_VERSION >= (5, 1):
view.login_required = False

# Note: session based authentication is explicitly CSRF validated,
# all other authentication is CSRF exempt.
return csrf_exempt(view)
Expand Down
7 changes: 7 additions & 0 deletions rest_framework/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from functools import update_wrapper
from inspect import getmembers

from django import VERSION as DJANGO_VERSION
from django.urls import NoReverseMatch
from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt
Expand Down Expand Up @@ -136,6 +137,12 @@ def view(request, *args, **kwargs):
view.cls = cls
view.initkwargs = initkwargs
view.actions = actions

# Exempt from Django's LoginRequiredMiddleware. Users should set
# DEFAULT_PERMISSION_CLASSES to 'rest_framework.permissions.IsAuthenticated' instead
if DJANGO_VERSION >= (5, 1):
view.login_required = False

return csrf_exempt(view)

def initialize_request(self, request, *args, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def get_version(package):
'Framework :: Django',
'Framework :: Django :: 4.2',
'Framework :: Django :: 5.0',
'Framework :: Django :: 5.1',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
Expand Down
74 changes: 73 additions & 1 deletion tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
import unittest

import django
from django.contrib.auth.models import User
from django.http import HttpRequest
from django.test import override_settings
from django.urls import path
from django.urls import include, path

from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.decorators import action, api_view
from rest_framework.request import is_form_media_type
from rest_framework.response import Response
from rest_framework.routers import SimpleRouter
from rest_framework.test import APITestCase
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet


class PostView(APIView):
def post(self, request):
return Response(data=request.data, status=200)


class GetAPIView(APIView):
def get(self, request):
return Response(data="OK", status=200)


@api_view(['GET'])
def get_func_view(request):
return Response(data="OK", status=200)


class ListViewSet(GenericViewSet):

def list(self, request, *args, **kwargs):
response = Response()
response.view = self
return response

@action(detail=False, url_path='list-action')
def list_action(self, request, *args, **kwargs):
response = Response()
response.view = self
return response


router = SimpleRouter()
router.register(r'view-set', ListViewSet, basename='view_set')

urlpatterns = [
path('auth', APIView.as_view(authentication_classes=(TokenAuthentication,))),
path('post', PostView.as_view()),
path('get', GetAPIView.as_view()),
path('get-func', get_func_view),
path('api/', include(router.urls)),
]


Expand Down Expand Up @@ -74,3 +111,38 @@ def test_middleware_can_access_request_post_when_processing_response(self):

response = self.client.post('/post', {'foo': 'bar'}, format='json')
assert response.status_code == 200


@unittest.skipUnless(django.VERSION >= (5, 1), 'Only for Django 5.1+')
@override_settings(
ROOT_URLCONF='tests.test_middleware',
MIDDLEWARE=(
# Needed for AuthenticationMiddleware
'django.contrib.sessions.middleware.SessionMiddleware',
# Needed for LoginRequiredMiddleware
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.LoginRequiredMiddleware',
),
)
class TestLoginRequiredMiddlewareCompat(APITestCase):
"""
Django's 5.1+ LoginRequiredMiddleware should NOT apply to DRF views.
Instead, users should put IsAuthenticated in their
DEFAULT_PERMISSION_CLASSES setting.
"""
def test_class_based_view(self):
response = self.client.get('/get')
assert response.status_code == status.HTTP_200_OK

def test_function_based_view(self):
response = self.client.get('/get-func')
assert response.status_code == status.HTTP_200_OK

def test_viewset_list(self):
response = self.client.get('/api/view-set/')
assert response.status_code == status.HTTP_200_OK

def test_viewset_list_action(self):
response = self.client.get('/api/view-set/list-action/')
assert response.status_code == status.HTTP_200_OK
12 changes: 12 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import copy
import unittest

from django import VERSION as DJANGO_VERSION
from django.test import TestCase

from rest_framework import status
Expand Down Expand Up @@ -136,3 +138,13 @@ def test_get_exception_handler(self):
response = self.view(request)
assert response.status_code == 400
assert response.data == {'error': 'SyntaxError'}


@unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+')
class TestLoginRequiredMiddlewareCompat(TestCase):
def test_class_based_view_opted_out(self):
class_based_view = BasicView.as_view()
assert class_based_view.login_required is False

def test_function_based_view_opted_out(self):
assert basic_view.login_required is False
7 changes: 7 additions & 0 deletions tests/test_viewsets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import unittest
from functools import wraps

import pytest
from django import VERSION as DJANGO_VERSION
from django.db import models
from django.test import TestCase, override_settings
from django.urls import include, path
Expand Down Expand Up @@ -196,6 +198,11 @@ def test_viewset_action_attr_for_extra_action(self):
assert get.view.action == 'list_action'
assert head.view.action == 'list_action'

@unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+')
def test_login_required_middleware_compat(self):
view = ActionViewSet.as_view(actions={'get': 'list'})
assert view.login_required is False


class GetExtraActionsTests(TestCase):

Expand Down
13 changes: 4 additions & 9 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[tox]
envlist =
{py38,py39}-{django42}
{py310}-{django42,django50,djangomain}
{py311}-{django42,django50,djangomain}
{py312}-{django42,django50,djangomain}
{py310}-{django42,django50,django51,djangomain}
{py311}-{django42,django50,django51,djangomain}
{py312}-{django42,django50,django51,djangomain}
base
dist
docs
Expand All @@ -17,6 +17,7 @@ setenv =
deps =
django42: Django>=4.2,<5.0
django50: Django>=5.0,<5.1
django51: Django>=5.1,<5.2
djangomain: https://github.com/django/django/archive/main.tar.gz
-rrequirements/requirements-testing.txt
-rrequirements/requirements-optionals.txt
Expand All @@ -42,12 +43,6 @@ deps =
-rrequirements/requirements-testing.txt
-rrequirements/requirements-documentation.txt

[testenv:py38-djangomain]
ignore_outcome = true

[testenv:py39-djangomain]
ignore_outcome = true

[testenv:py310-djangomain]
ignore_outcome = true

Expand Down

0 comments on commit 2ede857

Please sign in to comment.