From 8eddb50cf152c8fcc97fc5af4b2ab37129b44d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomek=20W=C3=B3jcik?= Date: Fri, 30 Jul 2021 21:29:14 +0200 Subject: [PATCH] Public release --- .gitignore | 7 + LICENSE | 19 ++ MANIFEST.in | 3 + Makefile | 16 ++ README.rst | 201 ++++++++++++++++++ django_changelist_inline/__init__.py | 13 ++ django_changelist_inline/admin.py | 146 +++++++++++++ django_changelist_inline/apps.py | 9 + .../css/changelist_inline.css | 19 ++ .../changelist_inline.html | 36 ++++ requirements-dev.txt | 7 + requirements.txt | 1 + settings.py | 56 +++++ setup.cfg | 12 ++ setup.py | 51 +++++ testing/__init__.py | 2 + testing/admin.py | 88 ++++++++ testing/apps.py | 7 + testing/factories.py | 31 +++ testing/migrations/0001_initial.py | 22 ++ testing/migrations/0002_relatedthing.py | 23 ++ testing/migrations/__init__.py | 0 testing/models.py | 13 ++ testing/urls.py | 7 + tests/__init__.py | 0 tests/admin/__init__.py | 0 tests/admin/test_ChangelistInline.py | 106 +++++++++ .../admin/test_ChangelistInlineAdminMixin.py | 71 +++++++ .../admin/test_ChangelistInlineModelAdmin.py | 151 +++++++++++++ tests/admin/test_InlineChangeList.py | 116 ++++++++++ 30 files changed, 1233 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.rst create mode 100755 django_changelist_inline/__init__.py create mode 100755 django_changelist_inline/admin.py create mode 100755 django_changelist_inline/apps.py create mode 100755 django_changelist_inline/static/django_changelist_inline/css/changelist_inline.css create mode 100755 django_changelist_inline/templates/django_changelist_inline/changelist_inline.html create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 settings.py create mode 100644 setup.cfg create mode 100755 setup.py create mode 100644 testing/__init__.py create mode 100644 testing/admin.py create mode 100644 testing/apps.py create mode 100644 testing/factories.py create mode 100644 testing/migrations/0001_initial.py create mode 100644 testing/migrations/0002_relatedthing.py create mode 100644 testing/migrations/__init__.py create mode 100644 testing/models.py create mode 100644 testing/urls.py create mode 100644 tests/__init__.py create mode 100644 tests/admin/__init__.py create mode 100644 tests/admin/test_ChangelistInline.py create mode 100644 tests/admin/test_ChangelistInlineAdminMixin.py create mode 100644 tests/admin/test_ChangelistInlineModelAdmin.py create mode 100644 tests/admin/test_InlineChangeList.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..822a9eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.pytest_cache/ +build/ +dist/ +django_changelist_inline.egg-info/ + +.envrc +django_changelist_inline.sqlite3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9315196 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021-present Tomek Wójcik + +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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..40cc71d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include django_changelist_inline *.html +graft django_changelist_inline/static +include requirements.txt requirements-dev.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f37febc --- /dev/null +++ b/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/* diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..63afbb1 --- /dev/null +++ b/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'' + 'Edit' + '' + ) + 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 ( + '' + 'BTHLabs' + '' + ) + + + @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 ```` 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/ diff --git a/django_changelist_inline/__init__.py b/django_changelist_inline/__init__.py new file mode 100755 index 0000000..4f34f9a --- /dev/null +++ b/django_changelist_inline/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +__version__ = '1.0.2' + +default_app_config = ( + 'django_changelist_inline.apps.DjangoChangelistInlineConfig' +) + +from .admin import ( # noqa: F401 + ChangelistInline, + ChangelistInlineAdmin, + ChangelistInlineAdminMixin, + ChangelistInlineModelAdmin, +) diff --git a/django_changelist_inline/admin.py b/django_changelist_inline/admin.py new file mode 100755 index 0000000..6c9cbd8 --- /dev/null +++ b/django_changelist_inline/admin.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# django-changelist-inline v1.0.2 | (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 has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + 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 diff --git a/django_changelist_inline/apps.py b/django_changelist_inline/apps.py new file mode 100755 index 0000000..36d1128 --- /dev/null +++ b/django_changelist_inline/apps.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# django-changelist-inline v1.0.2 | (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' diff --git a/django_changelist_inline/static/django_changelist_inline/css/changelist_inline.css b/django_changelist_inline/static/django_changelist_inline/css/changelist_inline.css new file mode 100755 index 0000000..f0c9795 --- /dev/null +++ b/django_changelist_inline/static/django_changelist_inline/css/changelist_inline.css @@ -0,0 +1,19 @@ +/*! django-changelist-inline v1.0.2 | (c) 2021-present Tomek Wójcik | MIT License */ +.django_changelist_inline h2 { + text-transform: uppercase; +} + +.django_changelist_inline #toolbar { + display: flex; + font-size: 12px; + margin-bottom: 0px !important; +} + +.django_changelist_inline__toolbar a { + margin-right: 0.5rem; +} + +.django_changelist_inline__toolbar a:last-child { + margin-left: auto; + margin-right: 0px; +} diff --git a/django_changelist_inline/templates/django_changelist_inline/changelist_inline.html b/django_changelist_inline/templates/django_changelist_inline/changelist_inline.html new file mode 100755 index 0000000..282392f --- /dev/null +++ b/django_changelist_inline/templates/django_changelist_inline/changelist_inline.html @@ -0,0 +1,36 @@ +{% load i18n admin_list %} + +{{ inline_admin_formset.formset.management_form }} +{% with cl=inline_admin_formset.opts.changelist %} + {% if cl %} + + {% endif %} +{% endwith %} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6757db7 --- /dev/null +++ b/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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c5c92f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django>=2.2 diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..4c08ccf --- /dev/null +++ b/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/' diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..682d724 --- /dev/null +++ b/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 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..b5057a7 --- /dev/null +++ b/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.2', + 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', +) diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e0ede2c --- /dev/null +++ b/testing/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +default_app_config = 'testing.apps.TestingConfig' diff --git a/testing/admin.py b/testing/admin.py new file mode 100644 index 0000000..0bbc9ea --- /dev/null +++ b/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'' + 'Edit' # noqa: E131 + '' + ) + 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 ( + '' + 'BTHLabs' # noqa: E131 + '' + ) + + +@admin.register(Thing) +class ThingModelAdmin(ChangelistInlineAdmin): + inlines = (RelatedThingChangelistInline,) diff --git a/testing/apps.py b/testing/apps.py new file mode 100644 index 0000000..7d302f0 --- /dev/null +++ b/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' diff --git a/testing/factories.py b/testing/factories.py new file mode 100644 index 0000000..e707530 --- /dev/null +++ b/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}) diff --git a/testing/migrations/0001_initial.py b/testing/migrations/0001_initial.py new file mode 100644 index 0000000..e251617 --- /dev/null +++ b/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)), + ], + ), + ] diff --git a/testing/migrations/0002_relatedthing.py b/testing/migrations/0002_relatedthing.py new file mode 100644 index 0000000..a386b60 --- /dev/null +++ b/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')), + ], + ), + ] diff --git a/testing/migrations/__init__.py b/testing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/models.py b/testing/models.py new file mode 100644 index 0000000..7bd1556 --- /dev/null +++ b/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) diff --git a/testing/urls.py b/testing/urls.py new file mode 100644 index 0000000..679212e --- /dev/null +++ b/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), +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/admin/__init__.py b/tests/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/admin/test_ChangelistInline.py b/tests/admin/test_ChangelistInline.py new file mode 100644 index 0000000..8b5bc41 --- /dev/null +++ b/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, + ) diff --git a/tests/admin/test_ChangelistInlineAdminMixin.py b/tests/admin/test_ChangelistInlineAdminMixin.py new file mode 100644 index 0000000..7c12970 --- /dev/null +++ b/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) diff --git a/tests/admin/test_ChangelistInlineModelAdmin.py b/tests/admin/test_ChangelistInlineModelAdmin.py new file mode 100644 index 0000000..054e412 --- /dev/null +++ b/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)) diff --git a/tests/admin/test_InlineChangeList.py b/tests/admin/test_InlineChangeList.py new file mode 100644 index 0000000..9be4a3b --- /dev/null +++ b/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)