1
0

Public release

This commit is contained in:
Tomek Wójcik 2021-07-30 21:29:14 +02:00
commit 8eddb50cf1
30 changed files with 1233 additions and 0 deletions

7
.gitignore vendored Normal file
View File

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

19
LICENSE Normal file
View File

@ -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
MANIFEST.in Normal file
View File

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

16
Makefile Normal file
View File

@ -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
README.rst Normal file
View File

@ -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/

View File

@ -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,
)

146
django_changelist_inline/admin.py Executable file
View File

@ -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

View File

@ -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'

View File

@ -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;
}

View File

@ -0,0 +1,36 @@
{% load i18n admin_list %}
{{ inline_admin_formset.formset.management_form }}
{% 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 %}
{% else %}
<p class="paginator">
{{ cl.model_admin.no_results_message }}
</p>
{% endif %}
{% 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 %}
</div>
</div>
</div>
{% endif %}
{% endwith %}

7
requirements-dev.txt Normal file
View File

@ -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
requirements.txt Normal file
View File

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

56
settings.py Normal file
View File

@ -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
setup.cfg Normal file
View File

@ -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
setup.py Executable file
View File

@ -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',
)

2
testing/__init__.py Normal file
View File

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

88
testing/admin.py Normal file
View File

@ -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
testing/apps.py Normal file
View File

@ -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
testing/factories.py Normal file
View File

@ -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})

View File

@ -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)),
],
),
]

View File

@ -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')),
],
),
]

View File

13
testing/models.py Normal file
View File

@ -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
testing/urls.py Normal file
View File

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

0
tests/__init__.py Normal file
View File

0
tests/admin/__init__.py Normal file
View File

View File

@ -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,
)

View File

@ -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)

View File

@ -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))

View File

@ -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)