Skip to content

Commit 832163d

Browse files
Allow returning Response from admin @action callbacks (#1331)
Problem: The Django docs say that you can "return an HttpResponse (or subclass) from your action", but the stubs currently say that actions can only return `None`. Solution: Update stubs to return `HttpResponse | None` instead. * Fix admin action return type * Use HttpResponseBase to support StreamingHttpResponse * Use `T | None` rather than `Optional[T]` * Add freestanding actions to test * Add negative test cases Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 9874789 commit 832163d

File tree

4 files changed

+36
-9
lines changed

4 files changed

+36
-9
lines changed

django-stubs/contrib/admin/decorators.pyi

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ from django.contrib.admin.sites import AdminSite
66
from django.db.models import Combinable, QuerySet
77
from django.db.models.base import Model
88
from django.db.models.expressions import BaseExpression
9-
from django.http import HttpRequest
9+
from django.http import HttpRequest, HttpResponseBase
1010
from django.utils.functional import _StrOrPromise
1111
from typing_extensions import TypeAlias
1212

@@ -17,20 +17,22 @@ _QuerySet = TypeVar("_QuerySet", bound=QuerySet)
1717
# This is deliberately different from _DisplayT defined in contrib.admin.options
1818
_DisplayCallable: TypeAlias = Union[Callable[[_ModelAdmin, _Model], Any], Callable[[_Model], Any]] # noqa: Y037
1919
_DisplayCallableT = TypeVar("_DisplayCallableT", bound=_DisplayCallable)
20+
_ActionReturn = TypeVar("_ActionReturn", bound=HttpResponseBase | None)
2021

2122
@overload
2223
def action(
23-
function: Callable[[_ModelAdmin, _Request, _QuerySet], None],
24+
function: Callable[[_ModelAdmin, _Request, _QuerySet], _ActionReturn],
2425
permissions: Sequence[str] | None = ...,
2526
description: _StrOrPromise | None = ...,
26-
) -> Callable[[_ModelAdmin, _Request, _QuerySet], None]: ...
27+
) -> Callable[[_ModelAdmin, _Request, _QuerySet], _ActionReturn]: ...
2728
@overload
2829
def action(
2930
*,
3031
permissions: Sequence[str] | None = ...,
3132
description: _StrOrPromise | None = ...,
3233
) -> Callable[
33-
[Callable[[_ModelAdmin, _Request, _QuerySet], None]], Callable[[_ModelAdmin, _Request, _QuerySet], None]
34+
[Callable[[_ModelAdmin, _Request, _QuerySet], _ActionReturn]],
35+
Callable[[_ModelAdmin, _Request, _QuerySet], _ActionReturn],
3436
]: ...
3537
@overload
3638
def display(

django-stubs/contrib/admin/options.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ from django.forms.models import (
2727
)
2828
from django.forms.widgets import Media
2929
from django.http.request import HttpRequest
30-
from django.http.response import HttpResponse, HttpResponseRedirect
30+
from django.http.response import HttpResponse, HttpResponseBase, HttpResponseRedirect
3131
from django.template.response import _TemplateForResponseT
3232
from django.urls.resolvers import URLPattern
3333
from django.utils.datastructures import _ListOrTuple
@@ -131,7 +131,7 @@ class BaseModelAdmin(Generic[_ModelT]):
131131

132132
_DisplayT: TypeAlias = _ListOrTuple[str | Callable[[_ModelT], str | bool]]
133133
_ModelAdmin = TypeVar("_ModelAdmin", bound=ModelAdmin)
134-
_ActionCallable: TypeAlias = Callable[[_ModelAdmin, HttpRequest, QuerySet[_ModelT]], None]
134+
_ActionCallable: TypeAlias = Callable[[_ModelAdmin, HttpRequest, QuerySet[_ModelT]], HttpResponseBase | None]
135135

136136
class ModelAdmin(BaseModelAdmin[_ModelT]):
137137
list_display: _DisplayT

tests/typecheck/contrib/admin/test_decorators.yml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
6161
from django.contrib import admin
6262
from django.db.models import QuerySet
63-
from django.http import HttpRequest
63+
from django.http import FileResponse, HttpRequest, HttpResponse
6464
6565
6666
class MyModel(models.Model): ...
@@ -73,17 +73,42 @@
7373
@admin.action(description="Some text here", permissions=["test"])
7474
def freestanding_action_fancy(modeladmin: "MyModelAdmin", request: HttpRequest, queryset: QuerySet[MyModel]) -> None: ...
7575
76+
@admin.action
77+
def freestanding_action_http_response(modeladmin: "MyModelAdmin", request: HttpRequest, queryset: QuerySet[MyModel]) -> HttpResponse: ...
78+
79+
@admin.action
80+
def freestanding_action_file_response(modeladmin: "MyModelAdmin", request: HttpRequest, queryset: QuerySet[MyModel]) -> FileResponse: ...
81+
82+
@admin.action # E: Value of type variable "_ModelAdmin" of "action" cannot be "int"
83+
def freestanding_action_invalid_bare(modeladmin: int, request: HttpRequest, queryset: QuerySet[MyModel]) -> None: ...
84+
85+
@admin.action(description="Some text here", permissions=["test"]) # E: Value of type variable "_ModelAdmin" of function cannot be "int"
86+
def freestanding_action_invalid_fancy(modeladmin: int, request: HttpRequest, queryset: QuerySet[MyModel]) -> None: ...
7687
7788
@admin.register(MyModel)
7889
class MyModelAdmin(admin.ModelAdmin[MyModel]):
79-
actions = [freestanding_action_bare, freestanding_action_fancy, "method_action_bare", "method_action_fancy"]
90+
actions = [freestanding_action_bare, freestanding_action_fancy, "method_action_bare", "method_action_fancy", freestanding_action_http_response, freestanding_action_file_response]
8091
8192
@admin.action
8293
def method_action_bare(self, request: HttpRequest, queryset: QuerySet[MyModel]) -> None: ...
8394
8495
@admin.action(description="Some text here", permissions=["test"])
8596
def method_action_fancy(self, request: HttpRequest, queryset: QuerySet[MyModel]) -> None: ...
8697
98+
@admin.action(description="Some text here", permissions=["test"])
99+
def method_action_http_response(self, request: HttpRequest, queryset: QuerySet[MyModel]) -> HttpResponse: ...
100+
101+
@admin.action(description="Some text here", permissions=["test"])
102+
def method_action_file_response(self, request: HttpRequest, queryset: QuerySet[MyModel]) -> FileResponse: ...
103+
104+
@admin.action # E: Value of type variable "_QuerySet" of "action" cannot be "int"
105+
def method_action_invalid_bare(self, request: HttpRequest, queryset: int) -> None: ...
106+
107+
@admin.action(description="Some text here", permissions=["test"]) # E: Value of type variable "_QuerySet" of function cannot be "int"
108+
def method_action_invalid_fancy(self, request: HttpRequest, queryset: int) -> None: ...
109+
87110
def method(self) -> None:
88111
reveal_type(self.method_action_bare) # N: Revealed type is "def (django.http.request.HttpRequest, django.db.models.query._QuerySet[main.MyModel, main.MyModel])"
89112
reveal_type(self.method_action_fancy) # N: Revealed type is "def (django.http.request.HttpRequest, django.db.models.query._QuerySet[main.MyModel, main.MyModel])"
113+
reveal_type(self.method_action_http_response) # N: Revealed type is "def (django.http.request.HttpRequest, django.db.models.query._QuerySet[main.MyModel, main.MyModel]) -> django.http.response.HttpResponse"
114+
reveal_type(self.method_action_file_response) # N: Revealed type is "def (django.http.request.HttpRequest, django.db.models.query._QuerySet[main.MyModel, main.MyModel]) -> django.http.response.FileResponse"

tests/typecheck/contrib/admin/test_options.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
pass
141141
142142
class A(admin.ModelAdmin):
143-
actions = [an_action] # E: List item 0 has incompatible type "Callable[[None], None]"; expected "Union[Callable[[Any, HttpRequest, _QuerySet[Any, Any]], None], str]"
143+
actions = [an_action] # E: List item 0 has incompatible type "Callable[[None], None]"; expected "Union[Callable[[Any, HttpRequest, _QuerySet[Any, Any]], Optional[HttpResponseBase]], str]"
144144
- case: errors_for_invalid_model_admin_generic
145145
main: |
146146
from django.contrib.admin import ModelAdmin

0 commit comments

Comments
 (0)