Ticket #10609: 10609-1.patch

File 10609-1.patch, 16.3 KB (added by Elvard, 2 years ago)

Added new decorators, documentation and tests

  • django/contrib/admin/actions.py

    diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py
    index 2011017..b928a85 100644
    a b  
    22Built-in, globally-available admin actions.
    33"""
    44
     5from django.contrib.auth import REDIRECT_FIELD_NAME
    56from django.core.exceptions import PermissionDenied
    67from django.contrib.admin import helpers
    78from django.contrib.admin.util import get_deleted_objects, model_ngettext
     9from django.contrib.auth.decorators import user_passes_test
    810from django.db import router
    911from django.template.response import TemplateResponse
    1012from django.utils.encoding import force_text
    1113from django.utils.translation import ugettext_lazy, ugettext as _
    1214
     15
     16def action_user_passes_test(test_func, login_url=None,
     17                                   redirect_field_name=REDIRECT_FIELD_NAME):
     18    """
     19    Decorator for admin actions that acts like user_passes_test view
     20    decorator. It checks user permissions, redirecting to the login-page if
     21    necessary. The test_func should be callable that takes the user object
     22    and modeladmin object, returning True if user passes.
     23
     24    """
     25    def decorator(action_func):
     26        def _wrapped_action(modeladmin, request, queryset):
     27            if test_func(request.user, modeladmin):
     28                return action_func(modeladmin, request, queryset)
     29            else:
     30                # Redirect to login page as user_passes_test do.
     31                # Pass dummy function, since we already know the user don't
     32                    # have required permissions.
     33                return user_passes_test(lambda: False, login_url,
     34                                        redirect_field_name)
     35        _wrapped_action.test_func = test_func
     36        return _wrapped_action
     37    return decorator
     38   
     39
     40def action_permission_required(perm, login_url=None, raise_exception=False):
     41    """
     42    Decorator for admin actions that acts like permission_required view
     43    decorator. It checks user permissions, redirecting to the login-page if
     44    necessary.
     45
     46    Permissions which can be used for multiple models can be specified
     47    in glob pattern.
     48
     49    Usage::
     50
     51      # Specific action for given application/model
     52      @action_permission_required('poll.can_close_poll')
     53      def close_poll_action(modeladmin, request, queryset):
     54        ...
     55
     56      # Shared action for multiple applications/models
     57      @action_permission_required('*.delete_*')
     58      def delete_selected_action(...):
     59        ...
     60
     61    """
     62    def check_perms(user, modeladmin):
     63        # First check if the user has the permission (even anon users)
     64        if perm.startswith('*'):
     65            # Strip '*' from the beginning and from the end
     66            partial_perm = ''.join([modeladmin.model._meta.app_label, perm[1:-1]])
     67            # If any of user perms match, return True
     68            all_perms = user.get_all_permissions()
     69            filter_func = lambda p: p.startswith(partial_perm)
     70            if any(filter(filter_func, all_perms)):
     71                return True
     72        else:
     73            if user.has_perm(perm):
     74                return True
     75
     76        # In case the 403 handler should be called raise the exception
     77        if raise_exception:
     78            raise PermissionDenied
     79        return False
     80    return action_user_passes_test(check_perms, login_url)
     81           
     82
     83@action_permission_required('*.delete_*')
    1384def delete_selected(modeladmin, request, queryset):
    1485    """
    1586    Default action which deletes the selected objects.
  • django/contrib/admin/options.py

    diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
    index f4205f2..2586770 100644
    a b class ModelAdmin(BaseModelAdmin): 
    594594
    595595        # get_action might have returned None, so filter any of those out.
    596596        actions = filter(None, actions)
     597       
     598        # filter actions which user don't have permission to trigger
     599        def filter_forbidden(action_tuple):
     600            action = action_tuple[0]
     601            if hasattr(action, 'test_func'):
     602                return action.test_func(request.user, self)
     603            return True
     604        actions = filter(filter_forbidden, actions)
    597605
    598606        # Convert the actions into a SortedDict keyed by name.
    599607        actions = SortedDict([
  • docs/ref/contrib/admin/actions.txt

    diff --git a/docs/ref/contrib/admin/actions.txt b/docs/ref/contrib/admin/actions.txt
    index 3f3b529..9579bfa 100644
    a b Conditionally enabling or disabling actions 
    348348                        del actions['delete_selected']
    349349                return actions
    350350
     351Enabling admin actions for specific user permissions only
     352---------------------------------------------------------
    351353
     354.. versionadded:: 1.5
     355
     356Admin actions can be displayed depending on user permissions using
     357:func:`~action_permissions_required` decorator
     358from ``django.contrib.admin.actions`` module.
     359
     360.. function:: action_permissions_required(permission, [redirect_field_name=REDIRECT_FIELD_NAME, login_url=None])
     361
     362   *permission* is required permission, eg. polls.can_vote.
     363   
     364   If you want to add shared action which is visible site-wide, use glob
     365   pattern, \*.delete\_\*. Other variants (\*.detete_entry or poll.add\_\*)
     366   are not valid.
     367
     368.. function:: action_user_passes_test(test_func, [redirect_field_name=REDIRECT_FIELD_NAME, login_url=None])
     369
     370   This is more general version of :func:`action_permissions_required`, where
     371   *test_func* function accepts user and modeladmin instances. It returns
     372   True, when action should be available and False otherwise.
  • tests/regressiontests/admin_views/admin.py

    diff --git a/tests/regressiontests/admin_views/admin.py b/tests/regressiontests/admin_views/admin.py
    index a5476e9..53f2314 100644
    a b import os 
    66
    77from django import forms
    88from django.contrib import admin
     9from django.contrib.admin.actions import action_permission_required
    910from django.contrib.admin.views.main import ChangeList
    1011from django.core.files.storage import FileSystemStorage
    1112from django.core.mail import EmailMessage
    from .models import (Article, Chapter, Account, Media, Child, Parent, Picture, 
    2728    Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug,
    2829    AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod,
    2930    AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated,
    30     RelatedPrepopulated, UndeletableObject, Simple)
     31    RelatedPrepopulated, UndeletableObject, Simple, RestrictedActionsModel)
    3132
    3233
    3334def callable_year(dt_value):
    class AttributeErrorRaisingAdmin(admin.ModelAdmin): 
    586587    list_display = [callable_on_unknown, ]
    587588
    588589
     590class RestrictedActionsAdmin(admin.ModelAdmin):
     591    actions = ['public_action', 'shared_delete_action', 'specific_add_action']
     592
     593    def public_action(modeladmin, request, queryset):
     594        return HttpResponse('Public action!')
     595
     596    @action_permission_required('*.delete_*')
     597    def shared_delete_action(modeladmin, request, queryset):
     598        return HttpResponse('Shared delete action!')
     599
     600    @action_permission_required('admin_views.add_restrictedactionsmodel')
     601    def specific_add_action(modeladmin, request, queryset):
     602        return HttpResponse('Specific add action!')
     603       
     604
     605
    589606site = admin.AdminSite(name="admin")
    590607site.register(Article, ArticleAdmin)
    591608site.register(CustomArticle, CustomArticleAdmin)
    site.register(AdminOrderedAdminMethod, AdminOrderedAdminMethodAdmin) 
    660677site.register(AdminOrderedCallable, AdminOrderedCallableAdmin)
    661678site.register(Color2, CustomTemplateFilterColorAdmin)
    662679site.register(Simple, AttributeErrorRaisingAdmin)
     680site.register(RestrictedActionsModel, RestrictedActionsAdmin)
    663681
    664682# Register core models we need in our tests
    665683from django.contrib.auth.models import User, Group
  • new file tests/regressiontests/admin_views/fixtures/admin-users-permissions.xml

    diff --git a/tests/regressiontests/admin_views/fixtures/admin-users-permissions.xml b/tests/regressiontests/admin_views/fixtures/admin-users-permissions.xml
    new file mode 100644
    index 0000000..0a8e8ff
    - +  
     1<?xml version="1.0" encoding="utf-8"?>
     2<django-objects version="1.0">
     3    <object pk="141" model="auth.user">
     4        <field type="CharField" name="username">anotheradduser</field>
     5        <field type="CharField" name="first_name">Add</field>
     6        <field type="CharField" name="last_name">User</field>
     7        <field type="CharField" name="email">auser@example.com</field>
     8        <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field>
     9        <field type="BooleanField" name="is_staff">True</field>
     10        <field type="BooleanField" name="is_active">True</field>
     11        <field type="BooleanField" name="is_superuser">False</field>
     12        <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field>
     13        <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field>
     14        <field to="auth.group" name="groups" rel="ManyToManyRel"></field>
     15        <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field>
     16    </object>
     17    <object pk="142" model="auth.user">
     18        <field type="CharField" name="username">anotherdeleteuser</field>
     19        <field type="CharField" name="first_name">Delete</field>
     20        <field type="CharField" name="last_name">User</field>
     21        <field type="CharField" name="email">duser@example.com</field>
     22        <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field>
     23        <field type="BooleanField" name="is_staff">True</field>
     24        <field type="BooleanField" name="is_active">True</field>
     25        <field type="BooleanField" name="is_superuser">False</field>
     26        <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field>
     27        <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field>
     28        <field to="auth.group" name="groups" rel="ManyToManyRel"></field>
     29        <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field>
     30    </object>
     31    <object pk="142" model="contenttypes.contenttype">
     32        <field type="CharField" name="name">anothermodel</field>
     33        <field type="CharField" name="app_label">admin_views</field>
     34        <field type="CharField" name="model">anothermodel</field>
     35    </object>
     36    <object pk="141" model="auth.permission">
     37        <field type="CharField" name="name">Can add AnotherModel</field>
     38        <field type="CharField" name="codename">add_anothermodel</field>
     39        <field to="contenttypes.contenttype" rel="ForeignKey" name="content_type">142</field>
     40    </object>
     41    <object pk="142" model="auth.permission">
     42        <field type="CharField" name="name">Can delete AnotherModel</field>
     43        <field type="CharField" name="codename">delete_anothermodel</field>
     44        <field to="contenttypes.contenttype" rel="ForeignKey" name="content_type">142</field>
     45    </object>
     46    <object pk="142" model="auth.user_user_permissions">
     47        <field to="auth.user" rel="ForeignKey" name="user">101</field>
     48        <field to="auth.permissions" rel="ForeignKey" name="permission">
     49            <natural>add_restrictedactionsmodel</natural>
     50            <natural>admin_views</natural>
     51            <natural>restrictedactionsmodel</natural>
     52        </field>
     53    </object>
     54    <object pk="143" model="auth.user_user_permissions">
     55        <field to="auth.user" rel="ForeignKey" name="user">103</field>
     56        <field to="auth.permissions" rel="ForeignKey" name="permission">
     57            <natural>delete_restrictedactionsmodel</natural>
     58            <natural>admin_views</natural>
     59            <natural>restrictedactionsmodel</natural>
     60        </field>
     61    </object>
     62    <object pk="144" model="auth.user_user_permissions">
     63        <field to="auth.user" rel="ForeignKey" name="user">141</field>
     64        <field to="auth.permissions" rel="ForeignKey" name="permission">141</field>
     65    </object>
     66    <object pk="145" model="auth.user_user_permissions">
     67        <field to="auth.user" rel="ForeignKey" name="user">142</field>
     68        <field to="auth.permissions" rel="ForeignKey" name="permission">142</field>
     69    </object>
     70</django-objects>
  • tests/regressiontests/admin_views/models.py

    diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
    index 2b14300..8a70a64 100644
    a b class Simple(models.Model): 
    656656    """
    657657    Simple model with nothing on it for use in testing
    658658    """
     659
     660
     661class RestrictedActionsModel(models.Model):
     662    """
     663    Just another simple model to use with RestrictedActionsAdmin.
     664    """
  • tests/regressiontests/admin_views/tests.py

    diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
    index 72dc6a3..d9d4e66 100644
    a b from django.contrib.contenttypes.models import ContentType 
    2828from django.forms.util import ErrorList
    2929from django.template.response import TemplateResponse
    3030from django.test import TestCase
     31from django.test.client import RequestFactory
    3132from django.utils import formats, translation, unittest
    3233from django.utils.cache import get_max_age
    3334from django.utils.encoding import iri_to_uri, force_bytes
    from .models import (Article, BarAccount, CustomArticle, EmptyModel, FooAccount, 
    4647    OtherStory, ComplexSortedPerson, Parent, Child, AdminOrderedField,
    4748    AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
    4849    Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject,
    49     Simple, UndeletableObject)
     50    Simple, UndeletableObject, RestrictedActionsModel)
     51
     52from .admin import site
    5053
    5154
    5255ERROR_MESSAGE = "Please enter the correct username and password \
    class AdminViewLogoutTest(TestCase): 
    36933696        self.assertEqual(response.template_name, 'admin/login.html')
    36943697        self.assertEqual(response.request['PATH_INFO'], '/test_admin/admin/')
    36953698        self.assertContains(response, '<input type="hidden" name="next" value="/test_admin/admin/" />')
     3699
     3700class AdminActionPermissionsTest(TestCase):
     3701    """ Test permissions for admin action.
     3702
     3703    Users:
     3704    - adduser - has admin_views.add_restrictedactionsmodel permission
     3705    - deleteuser - has admin_views.delete_restrictedactionsmodel permission
     3706    - addanotheruser - has admin_views.add_anothermodel permission
     3707    - anotherdeleteuser - has admin_views.delete_anothermodel permission
     3708
     3709    Actions:
     3710    - public action - visible for everyone (default)
     3711    - shared_delete_action - visible for any delete_* permission
     3712    - specific_add_action - visible for admin_views.add_restrictedactionsmodel
     3713
     3714    """
     3715    #urls = "regressiontests.admin_views.urls"
     3716    fixtures = ['admin-views-users.xml', 'admin-users-permissions.xml']
     3717
     3718    def setUp(self):
     3719        self.factory = RequestFactory()
     3720        self.modeladmin = site._registry[RestrictedActionsModel]
     3721        self.dummy_request = self.factory.get('/')
     3722
     3723    def test_get_actions_as_superuser(self):
     3724        """
     3725        Superuser should see all actions.
     3726        """
     3727        request = self.dummy_request
     3728
     3729        request.user = User.objects.get(username='super')
     3730        actions = ['delete_selected', 'public_action', 'shared_delete_action',
     3731                   'specific_add_action']
     3732        self.assertEqual(actions, self.modeladmin.get_actions(request).keys())
     3733
     3734    def test_shared_actions(self):
     3735        request = self.dummy_request
     3736
     3737        request.user = User.objects.get(username='deleteuser')
     3738        actions = ['delete_selected', 'public_action', 'shared_delete_action']
     3739        self.assertEqual(actions, self.modeladmin.get_actions(request).keys())
     3740
     3741        request.user = User.objects.get(username='anotherdeleteuser')
     3742        self.assertEqual(actions, self.modeladmin.get_actions(request).keys())
     3743
     3744    def test_specific_actions(self):
     3745        request = self.dummy_request
     3746
     3747        request.user = User.objects.get(username='adduser')
     3748        actions = ['public_action', 'specific_add_action']
     3749        self.assertEqual(actions, self.modeladmin.get_actions(request).keys())
     3750
     3751        request.user = User.objects.get(username='anotheradduser')
     3752        actions = ['public_action']
     3753        self.assertEqual(actions, self.modeladmin.get_actions(request).keys())
Back to Top