Public release
This commit is contained in:
commit
8eddb50cf1
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.pytest_cache/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
django_changelist_inline.egg-info/
|
||||||
|
|
||||||
|
.envrc
|
||||||
|
django_changelist_inline.sqlite3
|
19
LICENSE
Normal file
19
LICENSE
Normal 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
3
MANIFEST.in
Normal 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
16
Makefile
Normal 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
201
README.rst
Normal 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/
|
13
django_changelist_inline/__init__.py
Executable file
13
django_changelist_inline/__init__.py
Executable 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
146
django_changelist_inline/admin.py
Executable 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
|
9
django_changelist_inline/apps.py
Executable file
9
django_changelist_inline/apps.py
Executable 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'
|
|
@ -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;
|
||||||
|
}
|
|
@ -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' %} »</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
7
requirements-dev.txt
Normal file
7
requirements-dev.txt
Normal 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
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Django>=2.2
|
56
settings.py
Normal file
56
settings.py
Normal 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
12
setup.cfg
Normal 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
51
setup.py
Executable 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
2
testing/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
default_app_config = 'testing.apps.TestingConfig'
|
88
testing/admin.py
Normal file
88
testing/admin.py
Normal 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
7
testing/apps.py
Normal 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
31
testing/factories.py
Normal 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})
|
22
testing/migrations/0001_initial.py
Normal file
22
testing/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
23
testing/migrations/0002_relatedthing.py
Normal file
23
testing/migrations/0002_relatedthing.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
testing/migrations/__init__.py
Normal file
0
testing/migrations/__init__.py
Normal file
13
testing/models.py
Normal file
13
testing/models.py
Normal 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
7
testing/urls.py
Normal 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
0
tests/__init__.py
Normal file
0
tests/admin/__init__.py
Normal file
0
tests/admin/__init__.py
Normal file
106
tests/admin/test_ChangelistInline.py
Normal file
106
tests/admin/test_ChangelistInline.py
Normal 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,
|
||||||
|
)
|
71
tests/admin/test_ChangelistInlineAdminMixin.py
Normal file
71
tests/admin/test_ChangelistInlineAdminMixin.py
Normal 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)
|
151
tests/admin/test_ChangelistInlineModelAdmin.py
Normal file
151
tests/admin/test_ChangelistInlineModelAdmin.py
Normal 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))
|
116
tests/admin/test_InlineChangeList.py
Normal file
116
tests/admin/test_InlineChangeList.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user