Browse Source

Public release

Tomek Wójcik 10 months ago
commit
168247b578

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+.pytest_cache/
+build/
+dist/
+django_changelist_inline.egg-info/
+
+.envrc
+django_changelist_inline.sqlite3

+ 19 - 0
LICENSE

@@ -0,0 +1,19 @@
+Copyright (c) 2021-present Tomek Wójcik <contact@bthlabs.pl>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 3 - 0
MANIFEST.in

@@ -0,0 +1,3 @@
+recursive-include django_changelist_inline *.html
+graft django_changelist_inline/static
+include requirements.txt requirements-dev.txt

+ 16 - 0
Makefile

@@ -0,0 +1,16 @@
+PHONY: clean lint test build publish
+
+clean:
+	rm -rf build/ dist/ django_changelist_inline.egg-info/
+
+lint:
+	flake8 django_changelist_inline/ testing/ tests/ settings.py setup.py
+
+test:
+	pytest
+
+build:
+	python setup.py build sdist bdist_wheel
+
+publish:
+	twine upload --repository pypi --skip-existing dist/*

+ 201 - 0
README.rst

@@ -0,0 +1,201 @@
+========================
+django-changelist-inline
+========================
+
+Inline Changelists for Django
+
+Overview
+========
+
+Django Admin comes with inlines_ which allows you to display forms to edit
+related objects from the parent object's change form. While it's technically
+possible to use this infrastructure to display list of related objects, it
+gets hacky pretty fast.
+
+Here's where *django-changelist-inline* comes into play. It allows you to embed
+the standard list of objects as an inline on the parent object's change form.
+Internally, it uses a subclass of ``ModelAdmin``, which means you can customize
+the list as you'd normally do. Additionally, you can display links below the
+list of objects.
+
+Usage Example
+=============
+
+Let's assume you have a Django project with two models - ``Thing`` and
+``RelatedThing`` and that you want to display list of ``RelatedThing`` objects
+as an inline in ``Thing`` change form. The follwing snippet provides an example
+of how to implement that using *django-changelist-inline*:
+
+.. code-block:: python
+
+    # -*- coding: utf-8 -*-
+    from urllib.parse import urlencode
+
+    from django.contrib import admin
+    from django.urls import reverse
+    from django.utils.safestring import mark_safe
+    from django_changelist_inline import (
+        ChangelistInline,
+        ChangelistInlineAdmin,
+        ChangelistInlineModelAdmin,
+    )
+
+    from testing.models import RelatedThing, Thing
+
+
+    @admin.register(RelatedThing)
+    class RelatedThingModelAdmin(admin.ModelAdmin):
+        list_display = ('pk', 'name')
+
+
+    class RelatedThingChangelistInline(ChangelistInline):
+        model = RelatedThing
+
+        class ChangelistModelAdmin(ChangelistInlineModelAdmin):
+            list_display = ('name', 'format_actions')
+            list_display_links = None
+            list_per_page = 5
+
+            def get_queryset(self, request):
+                return RelatedThing.objects.filter(thing=self.parent_instance)
+
+            @mark_safe
+            def format_actions(self, obj=None):
+                if obj is None:
+                    return self.empty_value_display
+
+                change_link_url = reverse(
+                    'admin:{app_label}_{model_name}_change'.format(
+                        app_label=RelatedThing._meta.app_label,
+                        model_name=RelatedThing._meta.model_name,
+                    ),
+                    args=[obj.pk],
+                )
+
+                return (
+                    f'<a class="button" href="{change_link_url}">'
+                        'Edit'
+                    '</a>'
+                )
+            format_actions.short_description = 'Actions'
+
+            @property
+            def title(self):
+                return 'Linked Related Things'
+
+            @property
+            def no_results_message(self):
+                return 'No Related Things?'
+
+            def get_add_url(self, request):
+                result = super().get_add_url(request)
+                if result is not None:
+                    return result + '?' + urlencode({
+                        'thing': self.parent_instance.pk,
+                    })
+
+                return result
+
+            def get_show_all_url(self, request):
+                result = super().get_show_all_url(request)
+                if result is not None:
+                    return result + '?' + urlencode({
+                        'thing': self.parent_instance.pk,
+                    })
+
+                return result
+
+            def get_toolbar_links(self, request):
+                return (
+                    '<a href="https://www.bthlabs.pl/">'
+                        'BTHLabs'
+                    '</a>'
+                )
+
+
+    @admin.register(Thing)
+    class ThingModelAdmin(ChangelistInlineAdmin):
+        inlines = (RelatedThingChangelistInline,)
+
+API
+===
+
+``ChangelistInline`` objects
+----------------------------
+
+The ``ChangelistInline`` class is the center piece of the API. It's
+designed to be used in a ``ModelAdmin``'s ``inlines``.
+
+In order for it to work, you'll need to define the ``model`` property and
+embed ``ChangelistModelAdmin`` class, which should be a subclass of
+``ChangelistInlineModelAdmin``.
+
+``ChangelistInlineModelAdmin`` objects
+--------------------------------------
+
+The ``ChangelistInlineModelAdmin`` is a customized ``ModelAdmin`` subclass
+which provides sane defaults and additional functionality for inline
+changelists.
+
+**Changelist sanitization**
+
+This subclass overrides the following attributes and methods of ``ModelAdmin``
+to provide sane defaults:
+
+* ``list_editable`` - set to empty tuple to disable editing of the list,
+* ``list_filter`` - set to empty tuple to disable filtering of the list,
+* ``search_fields`` - set to empty tuple to disable searching,
+* ``date_hierarchy`` - set to ``None``,
+* ``sortable_by`` - set to empty tuple to disable sorting,
+* ``get_actions()`` - returns empty list to disable actions.
+
+**Additional functionality**
+
+To allow customization and to expose additional functionality,
+``ChangelistInlineModelAdmin`` provides the following additional methods:
+
+* ``title`` property - returns the model's *verbose name* by default.
+* ``no_results_message`` property - returns text to display in place of the
+  table if no objects are fetched from the DB.
+* ``get_add_url(request)`` - returns URL for the model's add form, if the
+  user has the add permission. Return ``None`` to hide the add link.
+* ``get_show_all_url(request)`` - returns URL for the model's changelist, if
+  the user has the view permission. Return ``None`` to hide the show all link.
+* ``get_toolbar_links(request)`` - returns ``None`` by default. Override this
+  to return string with additional ``<a/>`` elements to render in the toolbar.
+  The return value is marked safe in the template.
+
+``ChangelistInlineAdmin`` objects
+---------------------------------
+
+The ``ChangelistInlineAdmin`` class is a base class for ``ModelAdmin``
+subclasses that use inline changelists.
+
+``ChangelistInlineAdminMixin``
+------------------------------
+
+A mixin class that is used to properly configure changelist inlines in the
+parent ``ModelAdmin``. Overrides ``get_inlines(request, obj=None)`` and
+``get_inline_instances(request, obj=None)`` methods.
+
+If you can't use ``ChangelistInlineAdmin`` as you base class, you can use this
+mixin to enable inline changelists:
+
+.. code-block:: python
+
+    @admin.register(Thing)
+    class ThingModelAdmin(ChangelistInlineAdminMixin, MyBaseModelAdmin):
+        ...
+
+Author
+------
+
+*django-changelist-inline* is developed by `Tomek Wójcik`_.
+
+License
+-------
+
+*django-changelist-inline* is licensed under the MIT License.
+
+.. _inlines: https://docs.djangoproject.com/en/3.2/ref/contrib/admin/#inlinemodeladmin-objects
+.. _Tomek Wójcik: https://www.bthlabs.pl/

+ 13 - 0
django_changelist_inline/__init__.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+__version__ = '1.0.0'
+
+default_app_config = (
+    'django_changelist_inline.apps.DjangoChangelistInlineConfig'
+)
+
+from .admin import (  # noqa: F401
+    ChangelistInline,
+    ChangelistInlineAdmin,
+    ChangelistInlineAdminMixin,
+    ChangelistInlineModelAdmin,
+)

+ 137 - 0
django_changelist_inline/admin.py

@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+# django-changelist-inline v1.0.0 | (c) 2021-present Tomek Wójcik | MIT License
+import copy
+
+from django.contrib import admin
+from django.contrib.admin.options import InlineModelAdmin
+from django.contrib.admin.views.main import ChangeList
+from django.http import QueryDict
+from django.urls import reverse
+
+
+class InlineChangeList(ChangeList):
+    def __init__(self, request, *args, **kwargs):
+        super().__init__(request, *args, **kwargs)
+        self.formset = None
+        self.add_url = self.model_admin.get_add_url(request)
+        self.show_all_url = self.model_admin.get_show_all_url(request)
+        self.toolbar_links = self.model_admin.get_toolbar_links(request)
+
+    @property
+    def has_toolbar(self):
+        return any([
+            self.add_url is not None,
+            self.show_all_url is not None,
+            self.toolbar_links is not None,
+        ])
+
+
+class ChangelistInlineModelAdmin(admin.ModelAdmin):
+    def __init__(self, parent_instance, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.parent_instance = parent_instance
+
+        self.list_editable = ()
+        self.list_filter = ()
+        self.search_fields = ()
+        self.date_hierarchy = None
+        self.sortable_by = ()
+        self.show_full_result_count = False
+
+    def get_actions(self, request):
+        return []
+
+    def get_changelist(self, request, **kwargs):
+        return InlineChangeList
+
+    @property
+    def title(self):
+        return self.model._meta.verbose_name_plural
+
+    @property
+    def no_results_message(self):
+        return f'0 {self.model._meta.verbose_name_plural}'
+
+    def get_add_url(self, request):
+        if self.has_add_permission(request):
+            return reverse('admin:{app_label}_{model_name}_add'.format(
+                app_label=self.model._meta.app_label,
+                model_name=self.model._meta.model_name,
+            ))
+
+        return None
+
+    def get_show_all_url(self, request):
+        if self.has_view_permission(request, obj=None):
+            return reverse('admin:{app_label}_{model_name}_changelist'.format(
+                app_label=self.model._meta.app_label,
+                model_name=self.model._meta.model_name,
+            ))
+
+        return None
+
+    def get_toolbar_links(self, request):
+        return None
+
+
+class ChangelistInline(InlineModelAdmin):
+    template = 'django_changelist_inline/changelist_inline.html'
+
+    class Media:
+        css = {
+            'all': [
+                'admin/css/changelists.css',
+                'django_changelist_inline/css/changelist_inline.css',
+            ],
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.request = None
+        self.changelist_model_admin = None
+
+    def bind(self, request, parent_instance):
+        internal_request = copy.copy(request)
+        internal_request.GET = QueryDict(mutable=False)
+        internal_request.POST = QueryDict(mutable=False)
+        self.request = internal_request
+
+        self.changelist_model_admin = self.ChangelistModelAdmin(
+            parent_instance,
+            self.model,
+            self.admin_site,
+        )
+
+    @property
+    def changelist(self):
+        can_view = self.changelist_model_admin.has_view_or_change_permission(
+            self.request, obj=None,
+        )
+        if not can_view:
+            return None
+
+        result = self.changelist_model_admin.get_changelist_instance(
+            self.request,
+        )
+        return result
+
+
+class ChangelistInlineAdminMixin:
+    def get_inline_instances(self, request, obj=None):
+        inline_instances = super().get_inline_instances(request, obj=obj)
+
+        if obj is None:
+            return [
+                inline_instance for inline_instance in inline_instances
+                if not isinstance(inline_instance, ChangelistInline)
+            ]
+        else:
+            for inline_instance in inline_instances:
+                if isinstance(inline_instance, ChangelistInline):
+                    inline_instance.bind(request, obj)
+
+        return inline_instances
+
+
+class ChangelistInlineAdmin(ChangelistInlineAdminMixin, admin.ModelAdmin):
+    pass

+ 9 - 0
django_changelist_inline/apps.py

@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# django-changelist-inline v1.0.0 | (c) 2021-present Tomek Wójcik | MIT License
+from django.apps import AppConfig
+
+
+class DjangoChangelistInlineConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'django_changelist_inline'
+    verbose_name = 'Django Changelist Inline'

+ 18 - 0
django_changelist_inline/static/django_changelist_inline/css/changelist_inline.css

@@ -0,0 +1,18 @@
+/*! django-changelist-inline v1.0.0 | (c) 2021-present Tomek Wójcik | MIT License */
+.django_changelist_inline h2 {
+    text-transform: uppercase;
+}
+
+.django_changelist_inline #toolbar {
+    display: flex;
+    margin-bottom: 0px;
+}
+
+.django_changelist_inline__toolbar a {
+    margin-right: 0.5rem;
+}
+
+.django_changelist_inline__toolbar a:last-child {
+    margin-left: auto;
+    margin-right: 0px;
+}

+ 35 - 0
django_changelist_inline/templates/django_changelist_inline/changelist_inline.html

@@ -0,0 +1,35 @@
+{% load i18n admin_list %}
+
+{% with cl=inline_admin_formset.opts.changelist %}
+  {% if cl %}
+    <div class="module django_changelist_inline">
+      <h2>{{ cl.model_admin.title }}</h2>
+      <div id="changelist">
+        <div class="changelist-form-container">
+          {% if cl.result_list %}
+            {% result_list cl %}
+            {% if cl.has_toolbar %}
+              <div id="toolbar" class="django_changelist_inline__toolbar">
+                {% if cl.add_url %}
+                  <a class="addlink" href="{{ cl.add_url }}">
+                    {% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
+                  </a>
+                {% endif %}
+                {% if cl.toolbar_links %}
+                  {{ cl.toolbar_links|safe }}
+                {% endif %}
+                {% if cl.show_all_url %}
+                  <a href="{{ cl.show_all_url }}">{% trans 'Show all' %} &raquo;</a>
+                {% endif %}
+              </div>
+            {% endif %}
+          {% else %}
+            <p class="paginator">
+              {{ cl.model_admin.no_results_message }}
+            </p>
+          {% endif %}
+        </div>
+      </div>
+    </div>
+  {% endif %}
+{% endwith %}

+ 7 - 0
requirements-dev.txt

@@ -0,0 +1,7 @@
+-r requirements.txt
+factory-boy==2.12.0
+flake8==3.8.3
+flake8-commas==2.0.0
+pytest==6.2.3
+pytest-django==4.2.0
+twine==2.0.0

+ 1 - 0
requirements.txt

@@ -0,0 +1 @@
+Django>=2.2

+ 56 - 0
settings.py

@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+import os
+
+BASE_DIR = os.path.abspath(os.path.dirname(__file__))
+
+SECRET_KEY = 'django_changelist_inline'
+DEBUG = True
+ALLOWED_HOSTS = ['*']
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.messages',
+    'django.contrib.sessions',
+    'django.contrib.staticfiles',
+    'django_changelist_inline',
+    'testing',
+]
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(BASE_DIR, 'django_changelist_inline.sqlite3'),
+    },
+}
+
+MIDDLEWARE = [
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+]
+
+AUTHENTICATION_BACKENDS = [
+    'django.contrib.auth.backends.ModelBackend',
+]
+
+ROOT_URLCONF = 'testing.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+STATIC_URL = '/static/'

+ 12 - 0
setup.cfg

@@ -0,0 +1,12 @@
+[flake8]
+exclude = testing/migrations/*.py
+ignore = E402
+max-line-length = 120
+
+[tool:pytest]
+addopts = --ds=settings
+python_files = test_*.py
+
+[mypy]
+exclude = /(migrations|settings)/
+ignore_missing_imports = True

+ 51 - 0
setup.py

@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+import codecs
+
+from setuptools import find_packages
+from setuptools import setup
+
+with codecs.open('README.rst', 'r', 'utf-8') as readme_f:
+    README = readme_f.read()
+
+with codecs.open('requirements.txt', 'r', 'utf-8') as requirements_f:
+    REQUIREMENTS = [
+        requirement.strip()
+        for requirement
+        in requirements_f.read().strip().split('\n')
+    ]
+
+with codecs.open('requirements-dev.txt', 'r', 'utf-8') as requirements_dev_f:
+    REQUIREMENTS_DEV = [
+        requirement.strip()
+        for requirement
+        in requirements_dev_f.read().strip().split('\n')[1:]
+    ]
+
+PACKAGES = find_packages(
+    include=['django_changelist_inline'], exclude=['testing*', 'tests*'],
+)
+
+setup(
+    name='django-changelist-inline',
+    version='1.0.0',
+    url='https://git.bthlabs.pl/tomekwojcik/django-changelist-inline',
+    license='Other/Proprietary License',
+    author='Tomek Wójcik',
+    author_email='contact@bthlabs.pl',
+    maintainer='BTHLabs',
+    maintainer_email='contact@bthlabs.pl',
+    description='Inline Changelists for Django',
+    long_description=README,
+    classifiers=[
+        'License :: OSI Approved :: MIT License',
+    ],
+    packages=PACKAGES,
+    include_package_data=True,
+    python_requires='>=3.8',
+    install_requires=REQUIREMENTS,
+    extras_require={
+        'dev': REQUIREMENTS_DEV,
+    },
+    zip_safe=False,
+    platforms='any',
+)

+ 2 - 0
testing/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+default_app_config = 'testing.apps.TestingConfig'

+ 88 - 0
testing/admin.py

@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+from urllib.parse import urlencode
+
+from django.contrib import admin
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+from django_changelist_inline import (
+    ChangelistInline,
+    ChangelistInlineAdmin,
+    ChangelistInlineModelAdmin,
+)
+
+from testing.models import RelatedThing, Thing
+
+
+@admin.register(RelatedThing)
+class RelatedThingModelAdmin(admin.ModelAdmin):
+    list_display = ('pk', 'name')
+
+
+class RelatedThingChangelistInline(ChangelistInline):
+    model = RelatedThing
+
+    class ChangelistModelAdmin(ChangelistInlineModelAdmin):
+        list_display = ('name', 'format_actions')
+        list_display_links = None
+        list_per_page = 5
+
+        def get_queryset(self, request):
+            return RelatedThing.objects.filter(thing=self.parent_instance)
+
+        @mark_safe
+        def format_actions(self, obj=None):
+            if obj is None:
+                return self.empty_value_display
+
+            change_link_url = reverse(
+                'admin:{app_label}_{model_name}_change'.format(
+                    app_label=RelatedThing._meta.app_label,
+                    model_name=RelatedThing._meta.model_name,
+                ),
+                args=[obj.pk],
+            )
+
+            return (
+                f'<a class="button" href="{change_link_url}">'
+                    'Edit'  # noqa: E131
+                '</a>'
+            )
+        format_actions.short_description = 'Actions'
+
+        @property
+        def title(self):
+            return 'Linked Related Things'
+
+        @property
+        def no_results_message(self):
+            return 'No Related Things?'
+
+        def get_add_url(self, request):
+            result = super().get_add_url(request)
+            if result is not None:
+                return result + '?' + urlencode({
+                    'thing': self.parent_instance.pk,
+                })
+
+            return result
+
+        def get_show_all_url(self, request):
+            result = super().get_show_all_url(request)
+            if result is not None:
+                return result + '?' + urlencode({
+                    'thing': self.parent_instance.pk,
+                })
+
+            return result
+
+        def get_toolbar_links(self, request):
+            return (
+                '<a href="https://www.bthlabs.pl/">'
+                    'BTHLabs'  # noqa: E131
+                '</a>'
+            )
+
+
+@admin.register(Thing)
+class ThingModelAdmin(ChangelistInlineAdmin):
+    inlines = (RelatedThingChangelistInline,)

+ 7 - 0
testing/apps.py

@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+from django.apps import AppConfig
+
+
+class TestingConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'testing'

+ 31 - 0
testing/factories.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+from django.contrib.auth.models import User
+import factory
+
+from testing.models import RelatedThing, Thing
+
+
+class UserFactory(factory.Factory):
+    class Meta:
+        model = User
+
+    email = factory.Faker('ascii_email')
+    username = factory.Faker('ascii_email')
+    password = factory.Faker('password')
+    is_staff = False
+
+
+class ThingFactory(factory.Factory):
+    class Meta:
+        model = Thing
+
+    name = factory.Faker('words', nb=1)
+    data = factory.LazyAttribute(lambda thing: {'thing': True})
+
+
+class RelatedThingFactory(factory.Factory):
+    class Meta:
+        model = RelatedThing
+
+    name = factory.Faker('words', nb=1)
+    data = factory.LazyAttribute(lambda thing: {'related_thing': True})

+ 22 - 0
testing/migrations/0001_initial.py

@@ -0,0 +1,22 @@
+# Generated by Django 3.2.5 on 2021-07-29 06:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Thing',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('data', models.JSONField(blank=True, default=None, null=True)),
+            ],
+        ),
+    ]

+ 23 - 0
testing/migrations/0002_relatedthing.py

@@ -0,0 +1,23 @@
+# Generated by Django 3.2.5 on 2021-07-29 06:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('testing', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RelatedThing',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('data', models.JSONField(blank=True, default=None, null=True)),
+                ('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testing.thing')),
+            ],
+        ),
+    ]

+ 0 - 0
testing/migrations/__init__.py


+ 13 - 0
testing/models.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+from django.db import models
+
+
+class Thing(models.Model):
+    name = models.CharField(max_length=255, null=False, blank=False)
+    data = models.JSONField(null=True, default=None, blank=True)
+
+
+class RelatedThing(models.Model):
+    thing = models.ForeignKey(Thing, null=False, on_delete=models.CASCADE)
+    name = models.CharField(max_length=255, null=False, blank=False)
+    data = models.JSONField(null=True, default=None, blank=True)

+ 7 - 0
testing/urls.py

@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+from django.contrib import admin
+from django.urls import path
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+]

+ 0 - 0
tests/__init__.py


+ 0 - 0
tests/admin/__init__.py


+ 106 - 0
tests/admin/test_ChangelistInline.py

@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from django.contrib import admin
+from django.http import HttpRequest, QueryDict
+from django.test import TestCase
+
+from django_changelist_inline.admin import (
+    ChangelistInline,
+    ChangelistInlineModelAdmin,
+    InlineChangeList,
+)
+from testing.factories import ThingFactory
+from testing.models import RelatedThing, Thing
+
+
+class RelatedThingChangelistInline(ChangelistInline):
+    model = RelatedThing
+
+    class ChangelistModelAdmin(ChangelistInlineModelAdmin):
+        pass
+
+
+class ChangelistInlineTestCase(TestCase):
+    def test_init(self):
+        # When
+        changelist_inline = RelatedThingChangelistInline(Thing, admin.site)
+
+        # Then
+        self.assertIsNone(changelist_inline.request)
+        self.assertIsNone(changelist_inline.changelist_model_admin)
+
+    def test_bind(self):
+        # Given
+        fake_request = mock.Mock(spec=HttpRequest)
+        fake_request.GET = QueryDict('q=test&data__has_key=test&p=2&o=-1')
+
+        thing = ThingFactory()
+        changelist_inline = RelatedThingChangelistInline(Thing, admin.site)
+
+        with mock.patch.object(changelist_inline, 'ChangelistModelAdmin') as mock_changelist_model_admin:
+            fake_changelist_model_admin = mock.Mock(
+                spec=RelatedThingChangelistInline.ChangelistModelAdmin,
+            )
+            mock_changelist_model_admin.return_value = fake_changelist_model_admin
+
+            # When
+            changelist_inline.bind(fake_request, thing)
+
+            # Then
+            self.assertNotEqual(changelist_inline.request, fake_request)
+            self.assertIsInstance(changelist_inline.request, HttpRequest)
+            self.assertEqual(len(changelist_inline.request.GET), 0)
+            self.assertEqual(len(changelist_inline.request.POST), 0)
+            self.assertEqual(
+                changelist_inline.changelist_model_admin,
+                fake_changelist_model_admin,
+            )
+
+            mock_changelist_model_admin.assert_called_with(
+                thing, RelatedThing, admin.site,
+            )
+
+    def test_changelist_cant_view(self):
+        # Given
+        fake_request = mock.Mock(spec=HttpRequest)
+
+        thing = ThingFactory()
+
+        changelist_inline = RelatedThingChangelistInline(Thing, admin.site)
+        changelist_inline.bind(fake_request, thing)
+
+        with mock.patch.object(changelist_inline.changelist_model_admin, 'has_view_or_change_permission') as mock_has_view_or_change_permission:  # noqa: E501
+            mock_has_view_or_change_permission.return_value = False
+
+            # Then
+            self.assertIsNone(changelist_inline.changelist)
+
+            mock_has_view_or_change_permission.assert_called_with(
+                changelist_inline.request, obj=None,
+            )
+
+    def test_changelist(self):
+        # Given
+        fake_request = mock.Mock(spec=HttpRequest)
+
+        thing = ThingFactory()
+
+        changelist_inline = RelatedThingChangelistInline(Thing, admin.site)
+        changelist_inline.bind(fake_request, thing)
+
+        with mock.patch.object(changelist_inline.changelist_model_admin, 'has_view_or_change_permission') as mock_has_view_or_change_permission:  # noqa: E501
+            with mock.patch.object(changelist_inline.changelist_model_admin, 'get_changelist_instance') as mock_get_changelist_instance:  # noqa: E501
+                fake_changelist_instance = mock.Mock(spec=InlineChangeList)
+
+                mock_has_view_or_change_permission.return_value = True
+                mock_get_changelist_instance.return_value = fake_changelist_instance
+
+                # Then
+                self.assertEqual(
+                    changelist_inline.changelist, fake_changelist_instance,
+                )
+
+                mock_get_changelist_instance.assert_called_with(
+                    changelist_inline.request,
+                )

+ 71 - 0
tests/admin/test_ChangelistInlineAdminMixin.py

@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from django.contrib import admin
+from django.http import HttpRequest
+from django.test import TestCase
+
+from django_changelist_inline import (
+    ChangelistInline,
+    ChangelistInlineAdminMixin,
+    ChangelistInlineModelAdmin,
+)
+from testing.factories import ThingFactory, UserFactory
+from testing.models import RelatedThing, Thing
+
+
+class RelatedThingChangelistInline(ChangelistInline):
+    model = RelatedThing
+
+    class ChangelistModelAdmin(ChangelistInlineModelAdmin):
+        pass
+
+
+class ThingAdmin(ChangelistInlineAdminMixin, admin.ModelAdmin):
+    inlines = [RelatedThingChangelistInline]
+
+
+class ChangelistInlineAdminMixinTestCase(TestCase):
+    def setUp(self):
+        self.fake_user = UserFactory()
+
+        self.fake_request = mock.Mock(spec=HttpRequest)
+        self.fake_request.user = self.fake_user
+
+        self.thing = ThingFactory()
+
+    def test_get_inline_instances_no_obj(self):
+        # Given
+        thing_admin = ThingAdmin(Thing, admin.site)
+
+        with mock.patch.object(self.fake_user, 'has_perm') as mock_has_perm:
+            with mock.patch.object(RelatedThingChangelistInline, 'bind') as mock_bind:
+                mock_has_perm.return_value = True
+
+                # When
+                result = thing_admin.get_inline_instances(
+                    self.fake_request, obj=None,
+                )
+
+                # Then
+                self.assertEqual(len(result), 0)
+                self.assertFalse(mock_bind.called)
+
+    def test_get_inline_instances(self):
+        # Given
+        thing_admin = ThingAdmin(Thing, admin.site)
+
+        with mock.patch.object(self.fake_user, 'has_perm') as mock_has_perm:
+            with mock.patch.object(RelatedThingChangelistInline, 'bind') as mock_bind:
+                mock_has_perm.return_value = True
+
+                # When
+                result = thing_admin.get_inline_instances(
+                    self.fake_request, obj=self.thing,
+                )
+
+                # Then
+                self.assertEqual(len(result), 1)
+                self.assertIsInstance(result[0], RelatedThingChangelistInline)
+
+                mock_bind.assert_called_with(self.fake_request, self.thing)

+ 151 - 0
tests/admin/test_ChangelistInlineModelAdmin.py

@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from django.contrib import admin
+from django.http import HttpRequest
+from django.test import TestCase
+
+from django_changelist_inline import ChangelistInlineModelAdmin
+from django_changelist_inline.admin import InlineChangeList
+from testing.factories import ThingFactory
+from testing.models import RelatedThing
+
+
+class ChangelistInlineModelAdminTestCase(TestCase):
+    def setUp(self):
+        self.thing = ThingFactory()
+        self.fake_request = mock.Mock(spec=HttpRequest)
+
+    def test_init(self):
+        # When
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        # Then
+        self.assertEqual(model_admin.parent_instance, self.thing)
+        self.assertEqual(model_admin.list_editable, ())
+        self.assertEqual(model_admin.list_filter, ())
+        self.assertEqual(model_admin.search_fields, ())
+        self.assertIsNone(model_admin.date_hierarchy)
+        self.assertEqual(model_admin.sortable_by, ())
+
+    @mock.patch('django.contrib.admin.ModelAdmin.get_actions')
+    def test_get_actions(self, mock_get_actions):
+        # Given
+        mock_get_actions.return_value = []
+
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        # When
+        result = model_admin.get_actions(self.fake_request)
+
+        # Then
+        self.assertEqual(result, [])
+        self.assertFalse(mock_get_actions.called)
+
+    def test_get_changelist(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        # When
+        result = model_admin.get_changelist(self.fake_request)
+
+        # Then
+        self.assertEqual(result, InlineChangeList)
+
+    def test_title(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        # Then
+        self.assertEqual(
+            model_admin.title, RelatedThing._meta.verbose_name_plural,
+        )
+
+    def test_no_results_message(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        # Then
+        self.assertEqual(
+            model_admin.no_results_message,
+            f'0 {RelatedThing._meta.verbose_name_plural}',
+        )
+
+    def test_get_add_url_no_add_permission(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        with mock.patch.object(model_admin, 'has_add_permission') as mock_has_add_permission:
+            mock_has_add_permission.return_value = False
+
+            # Then
+            self.assertIsNone(model_admin.get_add_url(self.fake_request))
+
+            mock_has_add_permission.assert_called_with(self.fake_request)
+
+    def test_get_add_url(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        with mock.patch.object(model_admin, 'has_add_permission') as mock_has_add_permission:
+            mock_has_add_permission.return_value = True
+
+            # Then
+            self.assertEqual(
+                model_admin.get_add_url(self.fake_request),
+                '/admin/testing/relatedthing/add/',
+            )
+
+    def test_get_show_all_url_no_view_permission(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        with mock.patch.object(model_admin, 'has_view_permission') as mock_has_view_permission:
+            mock_has_view_permission.return_value = False
+
+            # Then
+            self.assertIsNone(model_admin.get_show_all_url(self.fake_request))
+
+            mock_has_view_permission.assert_called_with(
+                self.fake_request, obj=None,
+            )
+
+    def test_get_show_all_url(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        with mock.patch.object(model_admin, 'has_view_permission') as mock_has_view_permission:
+            mock_has_view_permission.return_value = True
+
+            # Then
+            self.assertEqual(
+                model_admin.get_show_all_url(self.fake_request),
+                '/admin/testing/relatedthing/',
+            )
+
+    def test_get_toolbar_links(self):
+        # Given
+        model_admin = ChangelistInlineModelAdmin(
+            self.thing, RelatedThing, admin.site,
+        )
+
+        # Then
+        self.assertIsNone(model_admin.get_toolbar_links(self.fake_request))

+ 116 - 0
tests/admin/test_InlineChangeList.py

@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from django.contrib import admin
+from django.contrib.auth.models import User
+from django.http import HttpRequest
+from django.test import TestCase
+
+from django_changelist_inline.admin import (
+    ChangelistInlineModelAdmin,
+    InlineChangeList,
+)
+from testing.models import RelatedThing, Thing
+from testing.factories import ThingFactory
+
+
+class RelatedThingInlineModelAdmin(ChangelistInlineModelAdmin):
+    pass
+
+
+class InlineChangeListTestCase(TestCase):
+    def setUp(self):
+        self.fake_user = mock.Mock(spec=User)
+        self.fake_user.has_perm.return_value = True
+
+        self.fake_request = mock.Mock(spec=HttpRequest)
+        self.fake_request.GET = {}
+        self.fake_request.resolver_match = None
+        self.fake_request.user = self.fake_user
+
+        thing = ThingFactory()
+        self.model_admin = RelatedThingInlineModelAdmin(
+            thing, RelatedThing, admin.site,
+        )
+
+    def test_init(self):
+        # When
+        change_list = InlineChangeList(
+            self.fake_request, Thing, ['pk'], ['pk'], [], None, None, None, 5,
+            5, None, self.model_admin, None,
+        )
+
+        # Then
+        self.assertIsNone(change_list.formset)
+        self.assertEqual(
+            change_list.add_url, '/admin/testing/relatedthing/add/',
+        )
+        self.assertEqual(
+            change_list.show_all_url, '/admin/testing/relatedthing/',
+        )
+        self.assertIsNone(change_list.toolbar_links)
+
+    def test_get_filters_params(self):
+        # Give
+        change_list = InlineChangeList(
+            self.fake_request, Thing, ['pk'], ['pk'], [], None, None, None, 5,
+            5, None, self.model_admin, None,
+        )
+
+        # When
+        result = change_list.get_filters_params(params={'q': 'spam'})
+
+        # Then
+        self.assertEqual(result, {})
+
+    def test_has_toolbar_False(self):
+        # Given
+        change_list = InlineChangeList(
+            self.fake_request, Thing, ['pk'], ['pk'], [], None, None, None, 5,
+            5, None, self.model_admin, None,
+        )
+        change_list.add_url = None
+        change_list.show_all_url = None
+        change_list.toolbar_links = None
+
+        # Then
+        self.assertFalse(change_list.has_toolbar)
+
+    def test_has_toolbar_True_with_add_url(self):
+        # Given
+        change_list = InlineChangeList(
+            self.fake_request, Thing, ['pk'], ['pk'], [], None, None, None, 5,
+            5, None, self.model_admin, None,
+        )
+        change_list.add_url = 'add_url'
+        change_list.show_all_url = None
+        change_list.toolbar_links = None
+
+        # Then
+        self.assertTrue(change_list.has_toolbar)
+
+    def test_has_toolbar_True_with_show_all_url(self):
+        # Given
+        change_list = InlineChangeList(
+            self.fake_request, Thing, ['pk'], ['pk'], [], None, None, None, 5,
+            5, None, self.model_admin, None,
+        )
+        change_list.add_url = None
+        change_list.show_all_url = 'show_all_url'
+        change_list.toolbar_links = None
+
+        # Then
+        self.assertTrue(change_list.has_toolbar)
+
+    def test_has_toolbar_True_with_toolbar_links(self):
+        # Given
+        change_list = InlineChangeList(
+            self.fake_request, Thing, ['pk'], ['pk'], [], None, None, None, 5,
+            5, None, self.model_admin, None,
+        )
+        change_list.add_url = None
+        change_list.show_all_url = None
+        change_list.toolbar_links = 'toolbar_links'
+
+        # Then
+        self.assertTrue(change_list.has_toolbar)