Ticket #3400: 3400.2.diff

File 3400.2.diff, 26.3 KB (added by Simon Meers, 9 years ago)
  • django/db/models/related.py

     
     1from django.utils.encoding import smart_unicode
     2from django.db.models.fields import BLANK_CHOICE_DASH
     3
    14class BoundRelatedObject(object):
    25    def __init__(self, related_object, field_mapping, original):
    36        self.relation = related_object
     
    1821        self.name = '%s:%s' % (self.opts.app_label, self.opts.module_name)
    1922        self.var_name = self.opts.object_name.lower()
    2023
     24    def get_choices(self, include_blank=True, blank_choice=BLANK_CHOICE_DASH,
     25                    limit_to_currently_related=False):
     26        """Returns choices with a default blank choices included, for use
     27        as SelectField choices for this field.
     28
     29        Analogue of django.db.models.fields.Field.get_choices, provided
     30        initially for utilisation by RelatedFilterSpec.
     31        """
     32        first_choice = include_blank and blank_choice or []
     33        queryset = self.model._default_manager.all()
     34        if limit_to_currently_related:
     35            queryset = queryset.complex_filter(
     36                {'%s__isnull' % self.parent_model._meta.module_name: False})
     37        lst = [(x._get_pk_val(), smart_unicode(x)) for x in queryset]
     38        return first_choice + lst
     39       
    2140    def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False):
    2241        # Defer to the actual field definition for db prep
    2342        return self.field.get_db_prep_lookup(lookup_type, value,
  • django/contrib/admin/validation.py

     
    5454    # list_filter
    5555    if hasattr(cls, 'list_filter'):
    5656        check_isseq(cls, 'list_filter', cls.list_filter)
    57         for idx, field in enumerate(cls.list_filter):
    58             get_field(cls, model, opts, 'list_filter[%d]' % idx, field)
     57        # strict validation removed; same as search_fields now
    5958
    6059    # list_per_page = 100
    6160    if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
  • django/contrib/admin/util.py

     
    11from django.core.exceptions import ObjectDoesNotExist
    22from django.db import models
     3from django.db.models.sql.constants import LOOKUP_SEP
    34from django.forms.forms import pretty_name
    45from django.utils import formats
    56from django.utils.html import escape
     
    333334        return formats.number_format(value)
    334335    else:
    335336        return smart_unicode(value)
     337
     338   
     339class NotRelationField(Exception):
     340    pass
     341
     342
     343def get_model_from_relation(field):
     344    if isinstance(field, models.related.RelatedObject):
     345        return field.model
     346    elif getattr(field, 'rel'): # or isinstance?
     347        return field.rel.to
     348    else:
     349        raise NotRelationField
     350
     351
     352def reverse_field_path(model, path):
     353    """ Create a reversed field path.
     354
     355    E.g. Given (Order, "user__groups"),
     356    return (Group, "user__order").
     357
     358    Final field must be a related model, not a data field.
     359   
     360    """
     361    reversed_path = []
     362    parent = model
     363    pieces = path.split(LOOKUP_SEP)
     364    for piece in pieces:
     365        field, model, direct, m2m = parent._meta.get_field_by_name(piece)
     366        # skip trailing data field if extant:
     367        if len(reversed_path) == len(pieces)-1: # final iteration
     368            try:
     369                get_model_from_relation(field)
     370            except NotRelationField:
     371                break
     372        if direct:
     373            related_name = field.related_query_name()
     374            parent = field.rel.to
     375        else:
     376            related_name = field.field.name
     377            parent = field.model
     378        reversed_path.insert(0, related_name)
     379    return (parent, LOOKUP_SEP.join(reversed_path))
     380
     381
     382def get_fields_from_path(model, path):
     383    """ Return list of Fields given path relative to model.
     384
     385    e.g. (ModelX, "user__groups__name") -> [
     386        <django.db.models.fields.related.ForeignKey object at 0x...>,
     387        <django.db.models.fields.related.ManyToManyField object at 0x...>,
     388        <django.db.models.fields.CharField object at 0x...>,
     389    ]
     390    """
     391    pieces = path.split(LOOKUP_SEP)
     392    fields = []
     393    for piece in pieces:
     394        if fields:
     395            parent = get_model_from_relation(fields[-1])
     396        else:
     397            parent = model
     398        fields.append(parent._meta.get_field_by_name(piece)[0])
     399    return fields
     400
     401
     402def remove_trailing_data_field(fields):
     403    """ Discard trailing non-relation field if extant. """
     404    try:
     405        get_model_from_relation(fields[-1])
     406    except NotRelationField:
     407        fields = fields[:-1]
     408    return fields
     409   
     410
     411def get_limit_choices_to_from_path(model, path):
     412    """ Return Q object for limiting choices if applicable.
     413
     414    If final model in path is linked via a ForeignKey or ManyToManyField which
     415    has a `limit_choices_to` attribute, return it as a Q object.
     416    """
     417   
     418    fields = get_fields_from_path(model, path)
     419    fields = remove_trailing_data_field(fields)
     420    limit_choices_to = (
     421        fields and hasattr(fields[-1], 'rel') and
     422        getattr(fields[-1].rel, 'limit_choices_to', None))
     423    if not limit_choices_to:
     424        return models.Q() # empty Q
     425    elif isinstance(limit_choices_to, models.Q):
     426        return limit_choices_to # already a Q
     427    else:
     428        return models.Q(**limit_choices_to) # convert dict to Q
  • django/contrib/admin/filterspecs.py

     
    1111from django.utils.translation import ugettext as _
    1212from django.utils.html import escape
    1313from django.utils.safestring import mark_safe
     14from django.contrib.admin.util import get_model_from_relation, \
     15    reverse_field_path, get_limit_choices_to_from_path
    1416import datetime
    1517
    1618class FilterSpec(object):
    1719    filter_specs = []
    18     def __init__(self, f, request, params, model, model_admin):
     20    def __init__(self, f, request, params, model, model_admin,
     21                 field_path=None):
    1922        self.field = f
    2023        self.params = params
    21 
     24        self.field_path = field_path
     25        if field_path is None:
     26            if isinstance(f, models.related.RelatedObject):
     27                self.field_path = f.var_name
     28            else:
     29                self.field_path = f.name
     30               
    2231    def register(cls, test, factory):
    2332        cls.filter_specs.append((test, factory))
    2433    register = classmethod(register)
    2534
    26     def create(cls, f, request, params, model, model_admin):
     35    def create(cls, f, request, params, model, model_admin, field_path=None):
    2736        for test, factory in cls.filter_specs:
    2837            if test(f):
    29                 return factory(f, request, params, model, model_admin)
     38                return factory(f, request, params, model, model_admin,
     39                               field_path=field_path)
    3040    create = classmethod(create)
    3141
    3242    def has_output(self):
     
    5262        return mark_safe("".join(t))
    5363
    5464class RelatedFilterSpec(FilterSpec):
    55     def __init__(self, f, request, params, model, model_admin):
    56         super(RelatedFilterSpec, self).__init__(f, request, params, model, model_admin)
    57         if isinstance(f, models.ManyToManyField):
    58             self.lookup_title = f.rel.to._meta.verbose_name
     65    def __init__(self, f, request, params, model, model_admin,
     66                 field_path=None):
     67        super(RelatedFilterSpec, self).__init__(
     68            f, request, params, model, model_admin, field_path=field_path)
     69
     70        other_model = get_model_from_relation(f)
     71        if isinstance(f, (models.ManyToManyField,
     72                          models.related.RelatedObject)):
     73            # no direct field on this model, get name from other model
     74            self.lookup_title = other_model._meta.verbose_name
    5975        else:
    60             self.lookup_title = f.verbose_name
    61         rel_name = f.rel.get_related_field().name
    62         self.lookup_kwarg = '%s__%s__exact' % (f.name, rel_name)
     76            self.lookup_title = f.verbose_name # use field name
     77        rel_name = other_model._meta.pk.name
     78        self.lookup_kwarg = '%s__%s__exact' % (self.field_path, rel_name)
    6379        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
    6480        self.lookup_choices = f.get_choices(include_blank=False)
    65 
     81       
    6682    def has_output(self):
    6783        return len(self.lookup_choices) > 1
    6884
     
    7894                   'query_string': cl.get_query_string({self.lookup_kwarg: pk_val}),
    7995                   'display': val}
    8096
    81 FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec)
     97FilterSpec.register(lambda f: (
     98        hasattr(f, 'rel') and bool(f.rel) or
     99        isinstance(f, models.related.RelatedObject)), RelatedFilterSpec)
    82100
    83101class ChoicesFilterSpec(FilterSpec):
    84     def __init__(self, f, request, params, model, model_admin):
    85         super(ChoicesFilterSpec, self).__init__(f, request, params, model, model_admin)
    86         self.lookup_kwarg = '%s__exact' % f.name
     102    def __init__(self, f, request, params, model, model_admin,
     103                 field_path=None):
     104        super(ChoicesFilterSpec, self).__init__(f, request, params, model,
     105                                                model_admin,
     106                                                field_path=field_path)
     107        self.lookup_kwarg = '%s__exact' % self.field_path
    87108        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
    88109
    89110    def choices(self, cl):
     
    98119FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec)
    99120
    100121class DateFieldFilterSpec(FilterSpec):
    101     def __init__(self, f, request, params, model, model_admin):
    102         super(DateFieldFilterSpec, self).__init__(f, request, params, model, model_admin)
     122    def __init__(self, f, request, params, model, model_admin,
     123                 field_path=None):
     124        super(DateFieldFilterSpec, self).__init__(f, request, params, model,
     125                                                  model_admin,
     126                                                  field_path=field_path)
    103127
    104         self.field_generic = '%s__' % self.field.name
     128        self.field_generic = '%s__' % self.field_path
    105129
    106130        self.date_params = dict([(k, v) for k, v in params.items() if k.startswith(self.field_generic)])
    107131
     
    111135
    112136        self.links = (
    113137            (_('Any date'), {}),
    114             (_('Today'), {'%s__year' % self.field.name: str(today.year),
    115                        '%s__month' % self.field.name: str(today.month),
    116                        '%s__day' % self.field.name: str(today.day)}),
    117             (_('Past 7 days'), {'%s__gte' % self.field.name: one_week_ago.strftime('%Y-%m-%d'),
    118                              '%s__lte' % f.name: today_str}),
    119             (_('This month'), {'%s__year' % self.field.name: str(today.year),
    120                              '%s__month' % f.name: str(today.month)}),
    121             (_('This year'), {'%s__year' % self.field.name: str(today.year)})
     138            (_('Today'), {'%s__year' % self.field_path: str(today.year),
     139                       '%s__month' % self.field_path: str(today.month),
     140                       '%s__day' % self.field_path: str(today.day)}),
     141            (_('Past 7 days'), {'%s__gte' % self.field_path:
     142                                    one_week_ago.strftime('%Y-%m-%d'),
     143                             '%s__lte' % self.field_path: today_str}),
     144            (_('This month'), {'%s__year' % self.field_path: str(today.year),
     145                             '%s__month' % self.field_path: str(today.month)}),
     146            (_('This year'), {'%s__year' % self.field_path: str(today.year)})
    122147        )
    123148
    124149    def title(self):
     
    133158FilterSpec.register(lambda f: isinstance(f, models.DateField), DateFieldFilterSpec)
    134159
    135160class BooleanFieldFilterSpec(FilterSpec):
    136     def __init__(self, f, request, params, model, model_admin):
    137         super(BooleanFieldFilterSpec, self).__init__(f, request, params, model, model_admin)
    138         self.lookup_kwarg = '%s__exact' % f.name
    139         self.lookup_kwarg2 = '%s__isnull' % f.name
     161    def __init__(self, f, request, params, model, model_admin,
     162                 field_path=None):
     163        super(BooleanFieldFilterSpec, self).__init__(f, request, params, model,
     164                                                     model_admin,
     165                                                     field_path=field_path)
     166        self.lookup_kwarg = '%s__exact' % self.field_path
     167        self.lookup_kwarg2 = '%s__isnull' % self.field_path
    140168        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
    141169        self.lookup_val2 = request.GET.get(self.lookup_kwarg2, None)
    142170
     
    159187# if a field is eligible to use the BooleanFieldFilterSpec, that'd be much
    160188# more appropriate, and the AllValuesFilterSpec won't get used for it.
    161189class AllValuesFilterSpec(FilterSpec):
    162     def __init__(self, f, request, params, model, model_admin):
    163         super(AllValuesFilterSpec, self).__init__(f, request, params, model, model_admin)
    164         self.lookup_val = request.GET.get(f.name, None)
    165         self.lookup_choices = model_admin.queryset(request).distinct().order_by(f.name).values(f.name)
     190    def __init__(self, f, request, params, model, model_admin,
     191                 field_path=None):
     192        super(AllValuesFilterSpec, self).__init__(f, request, params, model,
     193                                                  model_admin,
     194                                                  field_path=field_path)
     195        self.lookup_val = request.GET.get(self.field_path, None)
     196        parent_model, reverse_path = reverse_field_path(model, field_path)
     197        queryset = parent_model._default_manager.all()
     198        # optional feature: limit choices base on existing relationships
     199        # queryset = queryset.complex_filter(
     200        #    {'%s__isnull' % reverse_path: False})
     201        limit_choices_to = get_limit_choices_to_from_path(model, field_path)
     202        queryset = queryset.filter(limit_choices_to)
     203       
     204        self.lookup_choices = \
     205            queryset.distinct().order_by(f.name).values(f.name)
    166206
    167207    def title(self):
    168208        return self.field.verbose_name
    169209
    170210    def choices(self, cl):
    171211        yield {'selected': self.lookup_val is None,
    172                'query_string': cl.get_query_string({}, [self.field.name]),
     212               'query_string': cl.get_query_string({}, [self.field_path]),
    173213               'display': _('All')}
    174214        for val in self.lookup_choices:
    175215            val = smart_unicode(val[self.field.name])
    176216            yield {'selected': self.lookup_val == val,
    177                    'query_string': cl.get_query_string({self.field.name: val}),
     217                   'query_string': cl.get_query_string({self.field_path: val}),
    178218                   'display': val}
    179219FilterSpec.register(lambda f: True, AllValuesFilterSpec)
  • django/contrib/admin/views/main.py

     
    11from django.contrib.admin.filterspecs import FilterSpec
    22from django.contrib.admin.options import IncorrectLookupParameters
    3 from django.contrib.admin.util import quote
     3from django.contrib.admin.util import quote, get_fields_from_path
    44from django.core.paginator import Paginator, InvalidPage
    55from django.db import models
    66from django.db.models.query import QuerySet
     
    6969    def get_filters(self, request):
    7070        filter_specs = []
    7171        if self.list_filter:
    72             filter_fields = [self.lookup_opts.get_field(field_name) for field_name in self.list_filter]
    73             for f in filter_fields:
    74                 spec = FilterSpec.create(f, request, self.params, self.model, self.model_admin)
     72            for filter_name in self.list_filter:
     73                field = get_fields_from_path(self.model, filter_name)[-1]
     74                spec = FilterSpec.create(field, request, self.params,
     75                                         self.model, self.model_admin,
     76                                         field_path=filter_name)
    7577                if spec and spec.has_output():
    7678                    filter_specs.append(spec)
    7779        return filter_specs, bool(filter_specs)
  • tests/regressiontests/admin_views/fixtures/admin-views-books.xml

     
     1<?xml version="1.0" encoding="utf-8"?>
     2<django-objects version="1.0">
     3  <object pk="1" model="admin_views.book">
     4    <field type="CharField" name="name">Book 1</field>
     5  </object>
     6  <object pk="2" model="admin_views.book">
     7    <field type="CharField" name="name">Book 2</field>
     8  </object>
     9  <object pk="1" model="admin_views.promo">
     10    <field type="CharField" name="name">Promo 1</field>
     11    <field type="ForiegnKey" name="book">1</field>
     12  </object>
     13  <object pk="2" model="admin_views.promo">
     14    <field type="CharField" name="name">Promo 2</field>
     15    <field type="ForiegnKey" name="book">2</field>
     16  </object>
     17  <object pk="1" model="admin_views.chapter">
     18    <field type="CharField" name="title">Chapter 1</field>
     19    <field type="TextField" name="content">[ insert contents here ]</field>
     20    <field type="ForiegnKey" name="book">1</field>
     21  </object>
     22  <object pk="2" model="admin_views.chapter">
     23    <field type="CharField" name="title">Chapter 2</field>
     24    <field type="TextField" name="content">[ insert contents here ]</field>
     25    <field type="ForiegnKey" name="book">1</field>
     26  </object>
     27  <object pk="3" model="admin_views.chapter">
     28    <field type="CharField" name="title">Chapter 1</field>
     29    <field type="TextField" name="content">[ insert contents here ]</field>
     30    <field type="ForiegnKey" name="book">2</field>
     31  </object>
     32  <object pk="4" model="admin_views.chapter">
     33    <field type="CharField" name="title">Chapter 2</field>
     34    <field type="TextField" name="content">[ insert contents here ]</field>
     35    <field type="ForiegnKey" name="book">2</field>
     36  </object>
     37  <object pk="1" model="admin_views.chapterxtra1">
     38    <field type="CharField" name="xtra">ChapterXtra1 1</field>
     39    <field type="ForiegnKey" name="chap">1</field>
     40  </object>
     41  <object pk="2" model="admin_views.chapterxtra1">
     42    <field type="CharField" name="xtra">ChapterXtra1 2</field>
     43    <field type="ForiegnKey" name="chap">3</field>
     44  </object>
     45</django-objects>
  • tests/regressiontests/admin_views/tests.py

     
    1717from django.utils.cache import get_max_age
    1818from django.utils.encoding import iri_to_uri
    1919from django.utils.html import escape
     20from django.utils.http import urlencode
    2021from django.utils.translation import get_date_formats, activate, deactivate
     22from django.db.models.sql.constants import LOOKUP_SEP
    2123
    2224# local test models
    2325from models import Article, BarAccount, CustomArticle, EmptyModel, \
    2426    FooAccount, Gallery, ModelWithStringPrimaryKey, \
    2527    Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
    2628    Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
    27     Category, Post, Plot, FunkyTag
     29    Category, Post, Plot, FunkyTag, Chapter, Book, Promo
    2830
    2931
    3032class AdminViewBasicTest(TestCase):
    31     fixtures = ['admin-views-users.xml', 'admin-views-colors.xml', 'admin-views-fabrics.xml']
     33    fixtures = ['admin-views-users.xml', 'admin-views-colors.xml',
     34                'admin-views-fabrics.xml', 'admin-views-books.xml']
    3235
    3336    # Store the bit of the URL where the admin is registered as a class
    3437    # variable. That way we can test a second AdminSite just by subclassing
     
    201204        )
    202205
    203206    def testLimitedFilter(self):
    204         """Ensure admin changelist filters do not contain objects excluded via limit_choices_to."""
     207        """Ensure admin changelist filters do not contain objects excluded via limit_choices_to.
     208        This also tests relation-spanning filters (e.g. 'color__value').
     209        """
    205210        response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit)
    206211        self.failUnlessEqual(response.status_code, 200)
    207212        self.failUnless(
     
    213218            "Changelist filter not correctly limited by limit_choices_to."
    214219        )
    215220
     221    def testRelationSpanningFilters(self):
     222        response = self.client.get('/test_admin/%s/admin_views/chapterxtra1/' %
     223                                   self.urlbit)
     224        self.failUnlessEqual(response.status_code, 200)
     225        self.assertContains(response, '<div id="changelist-filter">')
     226        filters = {
     227            'chap__id__exact': dict(
     228                values=[c.id for c in Chapter.objects.all()],
     229                test=lambda obj, value: obj.chap.id == value),
     230            'chap__title': dict(
     231                values=[c.title for c in Chapter.objects.all()],
     232                test=lambda obj, value: obj.chap.title == value),
     233            'chap__book__id__exact': dict(
     234                values=[b.id for b in Book.objects.all()],
     235                test=lambda obj, value: obj.chap.book.id == value),
     236            'chap__book__name': dict(
     237                values=[b.name for b in Book.objects.all()],
     238                test=lambda obj, value: obj.chap.book.name == value),
     239            'chap__book__promo__id__exact': dict(
     240                values=[p.id for p in Promo.objects.all()],
     241                test=lambda obj, value:
     242                    obj.chap.book.promo_set.filter(id=value).exists()),
     243            'chap__book__promo__name': dict(
     244                values=[p.name for p in Promo.objects.all()],
     245                test=lambda obj, value:
     246                    obj.chap.book.promo_set.filter(name=value).exists()),
     247            }
     248        for filter_path, params in filters.items():
     249            for value in params['values']:
     250                query_string = urlencode({filter_path: value})
     251                # ensure filter link exists
     252                self.assertContains(response, '<a href="?%s">' % query_string)
     253                # ensure link works
     254                filtered_response = self.client.get(
     255                    '/test_admin/%s/admin_views/chapterxtra1/?%s' % (
     256                        self.urlbit, query_string))
     257                self.failUnlessEqual(filtered_response.status_code, 200)
     258                # ensure changelist contains only valid objects
     259                for obj in filtered_response.context['cl'].query_set.all():
     260                    self.assertTrue(params['test'](obj, value))
     261
    216262    def testIncorrectLookupParameters(self):
    217263        """Ensure incorrect lookup parameters are handled gracefully."""
    218264        response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'notarealfield': '5'})
  • tests/regressiontests/admin_views/customadmin.py

     
    3232site.register(models.Section, inlines=[models.ArticleInline])
    3333site.register(models.Thing, models.ThingAdmin)
    3434site.register(models.Fabric, models.FabricAdmin)
     35site.register(models.ChapterXtra1, models.ChapterXtra1Admin)
  • tests/regressiontests/admin_views/models.py

     
    8989class ChapterInline(admin.TabularInline):
    9090    model = Chapter
    9191
     92class ChapterXtra1Admin(admin.ModelAdmin):
     93    list_filter = ('chap',
     94                   'chap__title',
     95                   'chap__book',
     96                   'chap__book__name',
     97                   'chap__book__promo',
     98                   'chap__book__promo__name',)
     99
    92100class ArticleAdmin(admin.ModelAdmin):
    93101    list_display = ('content', 'date', callable_year, 'model_year', 'modeladmin_year')
    94102    list_filter = ('date',)
     
    148156        return self.title
    149157
    150158class ThingAdmin(admin.ModelAdmin):
    151     list_filter = ('color',)
     159    list_filter = ('color', 'color__warm', 'color__value')
    152160
    153161class Fabric(models.Model):
    154162    NG_CHOICES = (
     
    622630# contrib.admin.util's get_deleted_objects function.
    623631admin.site.register(Book, inlines=[ChapterInline])
    624632admin.site.register(Promo)
    625 admin.site.register(ChapterXtra1)
     633admin.site.register(ChapterXtra1, ChapterXtra1Admin)
    626634admin.site.register(Pizza, PizzaAdmin)
    627635admin.site.register(Topping)
  • tests/regressiontests/modeladmin/models.py

     
    677677ImproperlyConfigured: 'ValidationTestModelAdmin.list_filter' must be a list or tuple.
    678678
    679679>>> class ValidationTestModelAdmin(ModelAdmin):
    680 ...     list_filter = ('non_existent_field',)
    681 >>> validate(ValidationTestModelAdmin, ValidationTestModel)
    682 Traceback (most recent call last):
    683 ...
    684 ImproperlyConfigured: 'ValidationTestModelAdmin.list_filter[0]' refers to field 'non_existent_field' that is missing from model 'ValidationTestModel'.
    685 
    686 >>> class ValidationTestModelAdmin(ModelAdmin):
    687680...     list_filter = ('is_active',)
    688681>>> validate(ValidationTestModelAdmin, ValidationTestModel)
    689682
  • docs/ref/contrib/admin/index.txt

     
    461461        list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
    462462        list_filter = ('is_staff', 'is_superuser')
    463463
     464In ``list_filter`` can be defined lookup separator as well::
     465
     466    class UserAdminWithLookup(UserAdmin):
     467        list_filter = ('groups__name')
     468
    464469The above code results in an admin change list page that looks like this:
    465470
    466471    .. image:: _images/users_changelist.png
Back to Top