From ffb6551eb423ed9627a235be5f2e27044e508e5f Mon Sep 17 00:00:00 2001 From: proxy <51172302+3n-k1@users.noreply.github.com> Date: Tue, 27 Oct 2020 18:02:43 -0400 Subject: [PATCH] make BaseModelAdmin generic to properly type methods dealing with models (#504) * make BaseModelAdmin generic to properly type the `obj` argument of ModelAdmin.delete_model closes #482 * turn BaseModelAdmin into bound generic, run black * add test for generic ModelAdmin --- django-stubs/contrib/admin/options.pyi | 101 +++++++++++------- .../typecheck/contrib/admin/test_options.yml | 12 ++- 2 files changed, 70 insertions(+), 43 deletions(-) diff --git a/django-stubs/contrib/admin/options.pyi b/django-stubs/contrib/admin/options.pyi index 29bb25f..086066b 100644 --- a/django-stubs/contrib/admin/options.pyi +++ b/django-stubs/contrib/admin/options.pyi @@ -1,5 +1,20 @@ from collections import OrderedDict -from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Type, Union, Mapping, TypeVar +from typing import ( + Any, + Callable, + Dict, + Generic, + Iterator, + List, + Optional, + Sequence, + Set, + Tuple, + Type, + Union, + Mapping, + TypeVar, +) from django.forms.forms import BaseForm from django.forms.formsets import BaseFormSet @@ -57,7 +72,11 @@ _T = TypeVar("_T") _ListOrTuple = Union[Tuple[_T, ...], List[_T]] _FieldsetSpec = _ListOrTuple[Tuple[Optional[str], _FieldOpts]] -class BaseModelAdmin: +# Generic type specifically for models, for use in BaseModelAdmin and subclasses +# https://github.com/typeddjango/django-stubs/issues/482 +_ModelT = TypeVar("_ModelT", bound=Model) + +class BaseModelAdmin(Generic[_ModelT]): autocomplete_fields: Sequence[str] = ... raw_id_fields: Sequence[str] = ... fields: Sequence[Union[str, Sequence[str]]] = ... @@ -69,7 +88,7 @@ class BaseModelAdmin: radio_fields: Mapping[str, _Direction] = ... prepopulated_fields: Mapping[str, Sequence[str]] = ... formfield_overrides: Mapping[Type[Field], Mapping[str, Any]] = ... - readonly_fields: Sequence[Union[str, Callable[[Model], Any]]] = ... + readonly_fields: Sequence[Union[str, Callable[[_ModelT], Any]]] = ... ordering: Sequence[str] = ... sortable_by: Sequence[str] = ... view_on_site: bool = ... @@ -92,28 +111,28 @@ class BaseModelAdmin: self, db_field: ManyToManyField, request: Optional[HttpRequest], **kwargs: Any ) -> ModelMultipleChoiceField: ... def get_autocomplete_fields(self, request: HttpRequest) -> Tuple: ... - def get_view_on_site_url(self, obj: Optional[Model] = ...) -> Optional[str]: ... + def get_view_on_site_url(self, obj: Optional[_ModelT] = ...) -> Optional[str]: ... def get_empty_value_display(self) -> SafeText: ... - def get_exclude(self, request: HttpRequest, obj: Optional[Model] = ...) -> Any: ... - def get_fields(self, request: HttpRequest, obj: Optional[Model] = ...) -> Sequence[Union[Callable, str]]: ... + def get_exclude(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> Any: ... + def get_fields(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> Sequence[Union[Callable, str]]: ... def get_fieldsets( - self, request: HttpRequest, obj: Optional[Model] = ... + self, request: HttpRequest, obj: Optional[_ModelT] = ... ) -> List[Tuple[Optional[str], Dict[str, Any]]]: ... def get_ordering(self, request: HttpRequest) -> Union[List[str], Tuple]: ... - def get_readonly_fields(self, request: HttpRequest, obj: Optional[Model] = ...) -> Union[List[str], Tuple]: ... - def get_prepopulated_fields(self, request: HttpRequest, obj: Optional[Model] = ...) -> Dict[str, Tuple[str]]: ... + def get_readonly_fields(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> Union[List[str], Tuple]: ... + def get_prepopulated_fields(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> Dict[str, Tuple[str]]: ... def get_queryset(self, request: HttpRequest) -> QuerySet: ... def get_sortable_by(self, request: HttpRequest) -> Union[List[Callable], List[str], Tuple]: ... def lookup_allowed(self, lookup: str, value: str) -> bool: ... def to_field_allowed(self, request: HttpRequest, to_field: str) -> bool: ... def has_add_permission(self, request: HttpRequest) -> bool: ... - def has_change_permission(self, request: HttpRequest, obj: Optional[Model] = ...) -> bool: ... - def has_delete_permission(self, request: HttpRequest, obj: Optional[Model] = ...) -> bool: ... - def has_view_permission(self, request: HttpRequest, obj: Optional[Model] = ...) -> bool: ... + def has_change_permission(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> bool: ... + def has_delete_permission(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> bool: ... + def has_view_permission(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> bool: ... def has_module_permission(self, request: HttpRequest) -> bool: ... -class ModelAdmin(BaseModelAdmin): - list_display: Sequence[Union[str, Callable[[Model], Any]]] = ... +class ModelAdmin(BaseModelAdmin[_ModelT]): + list_display: Sequence[Union[str, Callable[[_ModelT], Any]]] = ... list_display_links: Optional[Sequence[Union[str, Callable]]] = ... list_filter: Sequence[Union[str, Type[ListFilter], Tuple[str, Type[ListFilter]]]] = ... list_select_related: Union[bool, Sequence[str]] = ... @@ -140,24 +159,24 @@ class ModelAdmin(BaseModelAdmin): actions_on_top: bool = ... actions_on_bottom: bool = ... actions_selection_counter: bool = ... - model: Type[Model] = ... + model: Type[_ModelT] = ... opts: Options = ... admin_site: AdminSite = ... - def __init__(self, model: Type[Model], admin_site: Optional[AdminSite]) -> None: ... - def get_inline_instances(self, request: HttpRequest, obj: Optional[Model] = ...) -> List[InlineModelAdmin]: ... + def __init__(self, model: Type[_ModelT], admin_site: Optional[AdminSite]) -> None: ... + def get_inline_instances(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> List[InlineModelAdmin]: ... def get_urls(self) -> List[URLPattern]: ... @property def urls(self) -> List[URLPattern]: ... @property def media(self) -> Media: ... def get_model_perms(self, request: HttpRequest) -> Dict[str, bool]: ... - def get_form(self, request: Any, obj: Optional[Any] = ..., change: bool = ..., **kwargs: Any): ... + def get_form(self, request: Any, obj: Optional[_ModelT] = ..., change: bool = ..., **kwargs: Any): ... def get_changelist(self, request: HttpRequest, **kwargs: Any) -> Type[ChangeList]: ... def get_changelist_instance(self, request: HttpRequest) -> ChangeList: ... - def get_object(self, request: HttpRequest, object_id: str, from_field: None = ...) -> Optional[Model]: ... + def get_object(self, request: HttpRequest, object_id: str, from_field: None = ...) -> Optional[_ModelT]: ... def get_changelist_form(self, request: Any, **kwargs: Any): ... def get_changelist_formset(self, request: Any, **kwargs: Any): ... - def get_formsets_with_inlines(self, request: HttpRequest, obj: Optional[Model] = ...) -> Iterator[Any]: ... + def get_formsets_with_inlines(self, request: HttpRequest, obj: Optional[_ModelT] = ...) -> Iterator[Any]: ... def get_paginator( self, request: HttpRequest, @@ -166,10 +185,10 @@ class ModelAdmin(BaseModelAdmin): orphans: int = ..., allow_empty_first_page: bool = ..., ) -> Paginator: ... - def log_addition(self, request: HttpRequest, object: Model, message: Any) -> LogEntry: ... - def log_change(self, request: HttpRequest, object: Model, message: Any) -> LogEntry: ... - def log_deletion(self, request: HttpRequest, object: Model, object_repr: str) -> LogEntry: ... - def action_checkbox(self, obj: Model) -> SafeText: ... + def log_addition(self, request: HttpRequest, object: _ModelT, message: Any) -> LogEntry: ... + def log_change(self, request: HttpRequest, object: _ModelT, message: Any) -> LogEntry: ... + def log_deletion(self, request: HttpRequest, object: _ModelT, object_repr: str) -> LogEntry: ... + def action_checkbox(self, obj: _ModelT) -> SafeText: ... def get_actions(self, request: HttpRequest) -> OrderedDict: ... def get_action_choices( self, request: HttpRequest, default_choices: List[Tuple[str, str]] = ... @@ -198,8 +217,8 @@ class ModelAdmin(BaseModelAdmin): fail_silently: bool = ..., ) -> None: ... def save_form(self, request: Any, form: Any, change: Any): ... - def save_model(self, request: Any, obj: Any, form: Any, change: Any) -> None: ... - def delete_model(self, request: HttpRequest, obj: Model) -> None: ... + def save_model(self, request: Any, obj: _ModelT, form: Any, change: Any) -> None: ... + def delete_model(self, request: HttpRequest, obj: _ModelT) -> None: ... def delete_queryset(self, request: HttpRequest, queryset: QuerySet) -> None: ... def save_formset(self, request: Any, form: Any, formset: Any, change: Any) -> None: ... def save_related(self, request: Any, form: Any, formsets: Any, change: Any) -> None: ... @@ -210,19 +229,19 @@ class ModelAdmin(BaseModelAdmin): add: bool = ..., change: bool = ..., form_url: str = ..., - obj: Optional[Any] = ..., + obj: Optional[_ModelT] = ..., ): ... def response_add( - self, request: HttpRequest, obj: Model, post_url_continue: Optional[str] = ... + self, request: HttpRequest, obj: _ModelT, post_url_continue: Optional[str] = ... ) -> HttpResponse: ... - def response_change(self, request: HttpRequest, obj: Model) -> HttpResponse: ... - def response_post_save_add(self, request: HttpRequest, obj: Model) -> HttpResponseRedirect: ... - def response_post_save_change(self, request: HttpRequest, obj: Model) -> HttpResponseRedirect: ... + def response_change(self, request: HttpRequest, obj: _ModelT) -> HttpResponse: ... + def response_post_save_add(self, request: HttpRequest, obj: _ModelT) -> HttpResponseRedirect: ... + def response_post_save_change(self, request: HttpRequest, obj: _ModelT) -> HttpResponseRedirect: ... def response_action(self, request: HttpRequest, queryset: QuerySet) -> Optional[HttpResponseBase]: ... def response_delete(self, request: HttpRequest, obj_display: str, obj_id: int) -> HttpResponse: ... def render_delete_form(self, request: Any, context: Any): ... def get_inline_formsets( - self, request: HttpRequest, formsets: List[Any], inline_instances: List[Any], obj: Optional[Model] = ... + self, request: HttpRequest, formsets: List[Any], inline_instances: List[Any], obj: Optional[_ModelT] = ... ) -> List[Any]: ... def get_changeform_initial_data(self, request: HttpRequest) -> Dict[str, str]: ... def changeform_view( @@ -246,8 +265,8 @@ class ModelAdmin(BaseModelAdmin): def delete_view(self, request: HttpRequest, object_id: str, extra_context: None = ...) -> Any: ... def history_view(self, request: HttpRequest, object_id: str, extra_context: None = ...) -> HttpResponse: ... -class InlineModelAdmin(BaseModelAdmin): - model: Type[Model] = ... +class InlineModelAdmin(BaseModelAdmin[_ModelT]): + model: Type[_ModelT] = ... fk_name: str = ... formset: BaseFormSet = ... extra: int = ... @@ -263,13 +282,13 @@ class InlineModelAdmin(BaseModelAdmin): parent_model: Any = ... opts: Any = ... has_registered_model: Any = ... - def __init__(self, parent_model: Union[Type[Model], Model], admin_site: AdminSite) -> None: ... + def __init__(self, parent_model: Union[Type[_ModelT], _ModelT], admin_site: AdminSite) -> None: ... @property def media(self) -> Media: ... - def get_extra(self, request: HttpRequest, obj: Optional[Model] = ..., **kwargs: Any) -> int: ... - def get_min_num(self, request: HttpRequest, obj: Optional[Model] = ..., **kwargs: Any) -> Optional[int]: ... - def get_max_num(self, request: HttpRequest, obj: Optional[Model] = ..., **kwargs: Any) -> Optional[int]: ... - def get_formset(self, request: Any, obj: Optional[Any] = ..., **kwargs: Any): ... + def get_extra(self, request: HttpRequest, obj: Optional[_ModelT] = ..., **kwargs: Any) -> int: ... + def get_min_num(self, request: HttpRequest, obj: Optional[_ModelT] = ..., **kwargs: Any) -> Optional[int]: ... + def get_max_num(self, request: HttpRequest, obj: Optional[_ModelT] = ..., **kwargs: Any) -> Optional[int]: ... + def get_formset(self, request: Any, obj: Optional[_ModelT] = ..., **kwargs: Any): ... -class StackedInline(InlineModelAdmin): ... -class TabularInline(InlineModelAdmin): ... +class StackedInline(InlineModelAdmin[_ModelT]): ... +class TabularInline(InlineModelAdmin[_ModelT]): ... diff --git a/test-data/typecheck/contrib/admin/test_options.yml b/test-data/typecheck/contrib/admin/test_options.yml index f544278..dee3d5e 100644 --- a/test-data/typecheck/contrib/admin/test_options.yml +++ b/test-data/typecheck/contrib/admin/test_options.yml @@ -14,7 +14,10 @@ def an_action(modeladmin: admin.ModelAdmin, request: HttpRequest, queryset: QuerySet) -> None: pass - class A(admin.ModelAdmin): + class TestModel(models.Model): + pass + + class A(admin.ModelAdmin[TestModel]): # BaseModelAdmin autocomplete_fields = ("strs",) raw_id_fields = ["strs"] @@ -71,6 +74,11 @@ actions_selection_counter = True admin_site = AdminSite() + # test generic ModelAdmin + # https://github.com/typeddjango/django-stubs/pull/504 + # this will fail if `model` has a type other than the generic specified in the class declaration + model = TestModel + def a_method_action(self, request, queryset): pass @@ -127,4 +135,4 @@ pass class A(admin.ModelAdmin): - actions = [an_action] # E: List item 0 has incompatible type "Callable[[None], None]"; expected "Union[Callable[[ModelAdmin, HttpRequest, QuerySet[Any]], None], str]" + actions = [an_action] # E: List item 0 has incompatible type "Callable[[None], None]"; expected "Union[Callable[[ModelAdmin[Any], HttpRequest, QuerySet[Any]], None], str]"