diff --git a/.gitattributes b/.gitattributes
index 3d432e0..6ed19dc 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
+# Auto detect text files and perform LF normalization
/CHANGELOG.md merge=union
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..b18f6ca
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,26 @@
+language: python
+cache: pip
+
+matrix:
+ include:
+ - env: TOXENV=lint
+ python: 3.6
+ - env: TOXENV=py27-dj18-wag113
+ python: 2.7
+ - env: TOXENV=py27-dj111-wag113
+ python: 2.7
+ - env: TOXENV=py36-dj18-wag113
+ python: 3.6
+ - env: TOXENV=py36-dj111-wag113
+ python: 3.6
+ - env: TOXENV=py36-dj20-wag20
+ python: 3.6
+
+install:
+ pip install tox coveralls
+
+script:
+ tox
+
+after_success:
+ coveralls
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..fc1fa37
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE
+include README.md
+global-include *.html
diff --git a/README.md b/README.md
index f6dc35a..def6de5 100644
--- a/README.md
+++ b/README.md
@@ -1,106 +1,121 @@
-#### CFPB Open Source Project Template Instructions
+# Wagtail-TreeModelAdmin
-1. Create a new project.
-2. [Copy these files into the new project](#installation)
-3. Update the README, replacing the contents below as prescribed.
-4. Add any libraries, assets, or hard dependencies whose source code will be included
- in the project's repository to the _Exceptions_ section in the [TERMS](TERMS.md).
- - If no exceptions are needed, remove that section from TERMS.
-5. If working with an existing code base, answer the questions on the [open source checklist](opensource-checklist.md)
-6. Delete these instructions and everything up to the _Project Title_ from the README.
-7. Write some great software and tell people about it.
+[![Build Status](https://travis-ci.org/cfpb/wagtail-treemodeladmin.svg?branch=master)](https://travis-ci.org/cfpb/wagtail-treemodeladmin)
+[![Coverage Status](https://coveralls.io/repos/github/cfpb/wagtail-treemodeladmin/badge.svg?branch=master)](https://coveralls.io/github/cfpb/wagtail-treemodeladmin?branch=master)
-> Keep the README fresh! It's the first thing people see and will make the initial impression.
+Wagtail-TreeModelAdmin is an extension for Wagtail's [ModelAdmin](http://docs.wagtail.io/en/latest/reference/contrib/modeladmin/) that allows for a page explorer-like navigation of Django model relationships within the Wagtail admin.
+
+- [Dependencies](#dependencies)
+- [Installation](#installation)
+- [Concepts](#concepts)
+- [Usage](#usage)
+ - [Quickstart](#quickstart)
+- [API](#api)
+- [Getting help](#getting-help)
+- [Getting involved](#getting-involved)
+- [Licensing](#licensing)
+- [Credits and references](#credits-and-references)
+
+## Dependencies
+
+- Django 1.8+ (including Django 2.0)
+- Wagtail 1.13+ (including Wagtail 2.0)
+- Python 2.7+, 3.6+
## Installation
-To install all of the template files, run the following script from the root of your project's directory:
+1. Install wagtail-treemodeladmin:
-```
-bash -c "$(curl -s https://raw.githubusercontent.com/CFPB/development/master/open-source-template.sh)"
+```shell
+pip install wagtail-treemodeladmin
```
-----
+2. Add `treemodeladmin` (and `wagtail.contrib.modeladmin` if it's not already) as an installed app in your Django `settings.py`:
-# Project Title
-
-**Description**: Put a meaningful, short, plain-language description of what
-this project is trying to accomplish and why it matters.
-Describe the problem(s) this project solves.
-Describe how this software can improve the lives of its audience.
+ ```python
+ INSTALLED_APPS = (
+ ...
+ 'wagtail.contrib.modeladmin',
+ 'treemodeladmin',
+ ...
+ )
+```
-Other things to include:
+## Concepts
- - **Technology stack**: Indicate the technological nature of the software, including primary programming language(s) and whether the software is intended as standalone or as a module in a framework or other ecosystem.
- - **Status**: Alpha, Beta, 1.1, etc. It's OK to write a sentence, too. The goal is to let interested people know where this project is at. This is also a good place to link to the [CHANGELOG](CHANGELOG.md).
- - **Links to production or demo instances**
- - Describe what sets this apart from related-projects. Linking to another doc or page is OK if this can't be expressed in a sentence or two.
+Wagtail-TreeModelAdmin allows for a Wagtail page explorer-like navigation of Django one-to-many relationships within the Wagtail admin. In doing this, it conceptualizes the Django [`ForeignKey`](https://docs.djangoproject.com/en/2.0/ref/models/fields/#django.db.models.ForeignKey) relationship as one of parents-to-children. The parent is the destination `to` of the `ForeignKey` relationship, the child is the source of the relationship.
+Wagtail-TreeModelAdmin is an extension of [Wagtail's ModelAdmin](http://docs.wagtail.io/en/latest/reference/contrib/modeladmin/index.html). It is intended to be used exactly like `ModelAdmin`.
-**Screenshot**: If the software has visual components, place a screenshot after the description; e.g.,
+## Usage
-![](https://raw.githubusercontent.com/cfpb/open-source-project-template/master/screenshot.png)
+### Quickstart
+To use Wagtail-TreeModelAdmin you first need to define some models that will be exposed in the Wagtail Admin.
-## Dependencies
+```
+# libraryapp/models.py
-Describe any dependencies that must be installed for this software to work.
-This includes programming languages, databases or other storage mechanisms, build tools, frameworks, and so forth.
-If specific versions of other software are required, or known not to work, call that out.
+from django.db import models
-## Installation
-Detailed instructions on how to install, configure, and get the project running.
-This should be frequently tested to ensure reliability. Alternatively, link to
-a separate [INSTALL](INSTALL.md) document.
+class Author(models.Model):
+ name = models.CharField(max_length=255)
-## Configuration
+class Book(models.Model):
+ author = models.ForeignKey(Author, on_delete=models.PROTECT)
+ title = models.CharField(max_length=255)
+```
-If the software is configurable, describe it in detail, either here or in other documentation to which you link.
+Then create the `TreeModelAdmin` subclasses and register the root the tree using `modeladmin_register`:
-## Usage
+```python
+# libraryapp/wagtail_hooks.py
+from wagtail.contrib.modeladmin.options import modeladmin_register
-Show users how to use the software.
-Be specific.
-Use appropriate formatting when showing code snippets.
+from treemodeladmin.options import TreeModelAdmin
+from libraryapp.models import Author, Book
-## How to test the software
-If the software includes automated tests, detail how to run those tests.
+class BookModelAdmin(TreeModelAdmin):
+ model = Book
+ parent_field = 'author'
-## Known issues
-Document any known significant shortcomings with the software.
+@modeladmin_register
+class AuthorModelAdmin(TreeModelAdmin):
+ menu_label = 'Library'
+ menu_icon = 'list-ul'
+ model = Author
+ child_field = 'book_set'
+ child_model_admin = BookModelAdmin
+```
-## Getting help
+Then visit the Wagtail admin. `Library` will be in the menu, and will give you a list of authors, and each author will have a link that will take you to their books.
-Instruct users how to get help with this software; this might include links to an issue tracker, wiki, mailing list, etc.
+## API
-**Example**
+Wagtail-TreeModelAdmin uses three new attributes on ModelAdmin subclasses to express parent/child relationships:
-If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker.
+- `parent_field`: The name of the Django [`ForeignKey`](https://docs.djangoproject.com/en/2.0/ref/models/fields/#django.db.models.ForeignKey) on a child model.
+- `child_field`: The [`related_name`](https://docs.djangoproject.com/en/2.0/ref/models/fields/#django.db.models.ForeignKey.related_name) on a Django `ForeignKey`.
+- `child_model_admin`
-## Getting involved
+Any `TreeModelAdmin` subclass can specify both parent and child relationships. The root of the tree (either the `TreeModelAdmin` included in a `ModelAdminGroup` or the `@modeladmin_register`ed `TreeModelAdmin` subclass) should only include `child_*` fields.
-This section should detail why people should get involved and describe key areas you are
-currently focusing on; e.g., trying to get feedback on features, fixing certain bugs, building
-important pieces, etc.
+## Getting help
-General instructions on _how_ to contribute should be stated with a link to [CONTRIBUTING](CONTRIBUTING.md).
+Please add issues to the [issue tracker](https://github.com/cfpb/wagtail-treemodeladmin/issues).
+## Getting involved
-----
+General instructions on _how_ to contribute can be found in [CONTRIBUTING](CONTRIBUTING.md).
-## Open source licensing info
+## Licensing
1. [TERMS](TERMS.md)
2. [LICENSE](LICENSE)
3. [CFPB Source Code Policy](https://github.com/cfpb/source-code-policy/)
-
-----
-
## Credits and references
-1. Projects that inspired you
-2. Related projects
-3. Books, papers, talks, or other sources that have meaningful impact or influence on this project
+1. Forked from [cfgov-refresh](https://github.com/cfpb/cfgov-refresh)
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..ec285bd
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,51 @@
+from setuptools import find_packages, setup
+
+
+with open('README.md') as f:
+ long_description = f.read()
+
+
+install_requires = [
+ 'Django>=1.8,<2.1',
+ 'wagtail>=1.13,<2.1',
+]
+
+
+testing_extras = [
+ 'mock>=2.0.0',
+ 'coverage>=3.7.0',
+]
+
+
+setup(
+ name='wagtail-treemodeladmin',
+ url='https://github.com/cfpb/wagtail-treemodeladmin',
+ author='CFPB',
+ author_email='tech@cfpb.gov',
+ description='TreeModelAdmin for Wagtail',
+ long_description=long_description,
+ license='CC0',
+ version='0.1.0',
+ include_package_data=True,
+ packages=find_packages(),
+ install_requires=install_requires,
+ extras_require={
+ 'testing': testing_extras,
+ },
+ classifiers=[
+ 'Framework :: Django',
+ 'Framework :: Django :: 1.11',
+ 'Framework :: Django :: 1.8',
+ 'Framework :: Django :: 2.0',
+ 'Framework :: Wagtail',
+ 'Framework :: Wagtail :: 1',
+ 'Framework :: Wagtail :: 2',
+ 'License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication',
+ 'License :: Public Domain',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.6',
+ ]
+)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..1cc5af3
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,61 @@
+[tox]
+skipsdist=True
+envlist=
+ lint,
+ py{27,36}-dj{18,111}-wag{113},
+ py{36}-dj{20}-wag{20}
+
+[testenv]
+install_command=pip install -e ".[testing]" -U {opts} {packages}
+commands=
+ coverage erase
+ coverage run --source='treemodeladmin' {envbindir}/django-admin.py test {posargs}
+ coverage report -m
+setenv=
+ DJANGO_SETTINGS_MODULE=treemodeladmin.tests.settings
+
+basepython=
+ py27: python2.7
+ py36: python3.6
+
+deps=
+ dj18: Django>=1.8,<1.9
+ dj111: Django>=1.11,<1.12
+ dj20: Django>=2.0,<2.1
+ wag113: wagtail>=1.13,<1.14
+ wag20: wagtail>=2.0,<2.1
+
+[testenv:lint]
+basepython=python3.6
+deps=
+ flake8>=2.2.0
+ isort>=4.2.15
+commands=
+ flake8 .
+ isort --check-only --diff --recursive treemodeladmin
+
+[flake8]
+ignore=E731,W503
+exclude=
+ .tox,
+ __pycache__,
+ treemodeladmin/migrations/*,
+ treemodeladmin/tests/treemodeladmintest/migrations/*
+
+[isort]
+combine_as_imports=1
+lines_after_imports=2
+include_trailing_comma=1
+multi_line_output=3
+skip=.tox,migrations
+not_skip=__init__.py
+use_parentheses=1
+known_django=django
+known_wagtail=wagtail
+known_future_library=future,six
+default_section=THIRDPARTY
+sections=FUTURE,STDLIB,DJANGO,WAGTAIL,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
+
+[coverage:run]
+omit =
+ treemodeladmin/tests/*
diff --git a/treemodeladmin/__init__.py b/treemodeladmin/__init__.py
new file mode 100644
index 0000000..2e03ad6
--- /dev/null
+++ b/treemodeladmin/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'treemodeladmin.apps.WagtailTreeModelAdminAppConfig'
diff --git a/treemodeladmin/apps.py b/treemodeladmin/apps.py
new file mode 100644
index 0000000..785fc89
--- /dev/null
+++ b/treemodeladmin/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+from django.utils.translation import ugettext_lazy as _
+
+
+class WagtailTreeModelAdminAppConfig(AppConfig):
+ name = 'treemodeladmin'
+ label = 'wagtailtreemodeladmin'
+ verbose_name = _("Wagtail TreeModelAdmin")
diff --git a/treemodeladmin/helpers.py b/treemodeladmin/helpers.py
new file mode 100644
index 0000000..e20d639
--- /dev/null
+++ b/treemodeladmin/helpers.py
@@ -0,0 +1,9 @@
+from wagtail.contrib.modeladmin.helpers import ButtonHelper
+
+
+class TreeButtonHelper(ButtonHelper):
+
+ def add_button(self, classnames_add=None, classnames_exclude=None):
+ return super(TreeButtonHelper, self).add_button(
+ classnames_add=['button-small']
+ )
diff --git a/treemodeladmin/options.py b/treemodeladmin/options.py
new file mode 100644
index 0000000..b4ec84f
--- /dev/null
+++ b/treemodeladmin/options.py
@@ -0,0 +1,55 @@
+from wagtail.contrib.modeladmin.options import ModelAdmin
+
+from treemodeladmin.helpers import TreeButtonHelper
+from treemodeladmin.views import TreeIndexView
+
+
+class TreeModelAdmin(ModelAdmin):
+ child_field = None
+ child_model_admin = None
+ child_instance = None
+ parent_field = None
+ index_view_class = TreeIndexView
+ index_template_name = 'treemodeladmin/index.html'
+ button_helper_class = TreeButtonHelper
+
+ def __init__(self, parent=None):
+ super(TreeModelAdmin, self).__init__(parent=parent)
+
+ if self.has_child():
+ self.child_instance = self.child_model_admin(parent=self)
+
+ def has_child(self):
+ return (
+ (self.child_field is not None) and
+ (self.child_model_admin is not None) and
+ hasattr(self.model, self.child_field)
+ )
+
+ def has_parent(self):
+ return (self.parent is not None and
+ isinstance(self.parent, TreeModelAdmin))
+
+ def get_child_field(self):
+ if self.has_child():
+ return self.child_field
+
+ def get_child_name(self):
+ if self.has_child():
+ return self.child_instance.model._meta.verbose_name
+
+ def get_child_name_plural(self):
+ if self.has_child():
+ return self.child_instance.model._meta.verbose_name_plural
+
+ def get_parent_field(self):
+ if self.has_parent():
+ return self.parent_field
+
+ def get_admin_urls_for_registration(self, parent=None):
+ urls = super(TreeModelAdmin, self).get_admin_urls_for_registration()
+
+ if self.has_child():
+ urls = urls + self.child_instance.get_admin_urls_for_registration()
+
+ return urls
diff --git a/treemodeladmin/templates/treemodeladmin/includes/breadcrumb.html b/treemodeladmin/templates/treemodeladmin/includes/breadcrumb.html
new file mode 100644
index 0000000..09d74b5
--- /dev/null
+++ b/treemodeladmin/templates/treemodeladmin/includes/breadcrumb.html
@@ -0,0 +1,8 @@
+{% load i18n %}
+
diff --git a/treemodeladmin/templates/treemodeladmin/includes/header.html b/treemodeladmin/templates/treemodeladmin/includes/header.html
new file mode 100644
index 0000000..fc399e0
--- /dev/null
+++ b/treemodeladmin/templates/treemodeladmin/includes/header.html
@@ -0,0 +1,24 @@
+{% load i18n modeladmin_tags %}
+
+ {% include "treemodeladmin/includes/breadcrumb.html" %}
+
+
diff --git a/treemodeladmin/templates/treemodeladmin/includes/tree_result_list.html b/treemodeladmin/templates/treemodeladmin/includes/tree_result_list.html
new file mode 100644
index 0000000..27de270
--- /dev/null
+++ b/treemodeladmin/templates/treemodeladmin/includes/tree_result_list.html
@@ -0,0 +1,28 @@
+{% load i18n modeladmin_tags treemodeladmin_tags %}
+{% if results %}
+
+
+
+ {% for header in result_headers %}
+
+ {% if header.sortable %}{% endif %}
+ |
+ {% endfor %}
+ {% if view.has_child_admin %}
+ |
+ {% endif %}
+
+
+
+ {% for result in results %}
+ {% tree_result_row_display forloop.counter0 %}
+ {% endfor %}
+
+
+{% else %}
+
+
{% blocktrans with view.verbose_name_plural as name %}Sorry, there are no {{ name }} matching your search parameters.{% endblocktrans %}
+
+{% endif %}
diff --git a/treemodeladmin/templates/treemodeladmin/includes/tree_result_row.html b/treemodeladmin/templates/treemodeladmin/includes/tree_result_row.html
new file mode 100644
index 0000000..a7b58e8
--- /dev/null
+++ b/treemodeladmin/templates/treemodeladmin/includes/tree_result_row.html
@@ -0,0 +1,15 @@
+{% load modeladmin_tags %}
+
+ {% for item in result %}
+ {% result_row_value_display forloop.counter0 %}
+ {% endfor %}
+ {% if children.count > 0 %}
+
+ Explore {{ obj }}'s {{ view.child_name_plural }}
+ |
+ {% else %}
+
+ Add a {{ obj }} {{ view.child_name }}
+ |
+ {% endif %}
+
diff --git a/treemodeladmin/templates/treemodeladmin/index.html b/treemodeladmin/templates/treemodeladmin/index.html
new file mode 100644
index 0000000..850ec09
--- /dev/null
+++ b/treemodeladmin/templates/treemodeladmin/index.html
@@ -0,0 +1,77 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n modeladmin_tags treemodeladmin_tags %}
+
+{% block titletag %}{{ view.get_meta_title }}{% endblock %}
+
+{% block css %}
+ {{ block.super }}
+ {{ view.media.css }}
+{% endblock %}
+
+{% block extra_js %}
+ {{ view.media.js }}
+{% endblock %}
+
+{% block content %}
+ {% block header %}
+ {% include "treemodeladmin/includes/header.html" %}
+ {% endblock %}
+
+ {% block content_main %}
+
+
+ {% block content_cols %}
+
+ {% block filters %}
+ {% if view.has_filters and all_count %}
+
+
{% trans 'Filter' %}
+ {% for spec in view.filter_specs %}{% admin_list_filter view spec %}{% endfor %}
+
+ {% endif %}
+ {% endblock %}
+
+
+ {% block result_list %}
+ {% if not all_count %}
+
+ {% if no_valid_parents %}
+
{% blocktrans with view.verbose_name_plural as name %}No {{ name }} have been created yet. One of the following must be created before you can add any {{ name }}:{% endblocktrans %}
+
+ {% for type in required_parent_types %}- {{ type|title }}
{% endfor %}
+
+ {% else %}
+
{% blocktrans with view.verbose_name_plural as name %}No {{ name }} have been created yet.{% endblocktrans %}
+ {% if user_can_create %}
+ {% blocktrans with view.create_url as url %}
+ Why not add one?
+ {% endblocktrans %}
+ {% endif %}
+ {% endif %}
+
+ {% elif view.has_child %}
+ {% tree_result_list %}
+ {% else %}
+ {% result_list %}
+ {% endif %}
+ {% endblock %}
+
+
+ {% block pagination %}
+
+
{% blocktrans with page_obj.number as current_page and paginator.num_pages as num_pages %}Page {{ current_page }} of {{ num_pages }}.{% endblocktrans %}
+ {% if paginator.num_pages > 1 %}
+
+ {% pagination_link_previous page_obj view %}
+ {% pagination_link_next page_obj view %}
+
+ {% endif %}
+
+ {% endblock %}
+
+ {% endblock %}
+
+
+ {% endblock %}
+
+{% endblock %}
diff --git a/treemodeladmin/templatetags/__init__.py b/treemodeladmin/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/treemodeladmin/templatetags/treemodeladmin_tags.py b/treemodeladmin/templatetags/treemodeladmin_tags.py
new file mode 100644
index 0000000..ecb7e5f
--- /dev/null
+++ b/treemodeladmin/templatetags/treemodeladmin_tags.py
@@ -0,0 +1,36 @@
+from django.contrib.admin.utils import quote
+from django.template import Library
+
+from wagtail.contrib.modeladmin.templatetags.modeladmin_tags import (
+ result_list,
+ result_row_display,
+)
+
+
+register = Library()
+
+
+@register.inclusion_tag("treemodeladmin/includes/tree_result_list.html",
+ takes_context=True)
+def tree_result_list(context):
+ """ Displays the headers and data list together with a link to children """
+ context = result_list(context)
+ return context
+
+
+@register.inclusion_tag(
+ "treemodeladmin/includes/tree_result_row.html", takes_context=True)
+def tree_result_row_display(context, index):
+ context = result_row_display(context, index)
+ obj = context['object_list'][index]
+ view = context['view']
+
+ if view.has_child_admin:
+ context.update({
+ 'children': view.get_children(obj),
+ 'child_index_url': view.child_url_helper.index_url,
+ 'child_filter': view.get_child_filter(quote(obj.pk)),
+ 'child_create_url': view.child_url_helper.create_url,
+ })
+
+ return context
diff --git a/treemodeladmin/tests/__init__.py b/treemodeladmin/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/treemodeladmin/tests/settings.py b/treemodeladmin/tests/settings.py
new file mode 100644
index 0000000..8eec3cc
--- /dev/null
+++ b/treemodeladmin/tests/settings.py
@@ -0,0 +1,140 @@
+from __future__ import absolute_import, unicode_literals
+
+import os
+
+import django
+
+import wagtail
+
+
+DEBUG = True
+
+ALLOWED_HOSTS = ['*']
+
+SECRET_KEY = 'not needed'
+
+ROOT_URLCONF = 'treemodeladmin.tests.urls'
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': os.environ.get(
+ 'DATABASE_ENGINE',
+ 'django.db.backends.sqlite3'
+ ),
+ 'NAME': os.environ.get('DATABASE_NAME', 'treemodeladmin.sqlite'),
+ 'USER': os.environ.get('DATABASE_USER', None),
+ 'PASSWORD': os.environ.get('DATABASE_PASS', None),
+ 'HOST': os.environ.get('DATABASE_HOST', None),
+
+ 'TEST': {
+ 'NAME': os.environ.get('DATABASE_NAME', None),
+ },
+ },
+}
+
+if wagtail.VERSION >= (2, 0):
+ WAGTAIL_APPS = (
+ 'wagtail.contrib.forms',
+ 'wagtail.contrib.modeladmin',
+ 'wagtail.contrib.settings',
+ 'wagtail.tests.testapp',
+ 'wagtail.admin',
+ 'wagtail.core',
+ 'wagtail.documents',
+ 'wagtail.images',
+ 'wagtail.sites',
+ 'wagtail.users',
+ )
+
+ WAGTAIL_MIDDLEWARE = (
+ 'wagtail.core.middleware.SiteMiddleware',
+ )
+
+ WAGTAILADMIN_RICH_TEXT_EDITORS = {
+ 'default': {
+ 'WIDGET': 'wagtail.admin.rich_text.DraftailRichTextArea'
+ },
+ 'custom': {
+ 'WIDGET': 'wagtail.tests.testapp.rich_text.CustomRichTextArea'
+ },
+ }
+else:
+ WAGTAIL_APPS = (
+ 'wagtail.contrib.modeladmin',
+ 'wagtail.contrib.settings',
+ 'wagtail.tests.testapp',
+ 'wagtail.wagtailadmin',
+ 'wagtail.wagtailcore',
+ 'wagtail.wagtaildocs',
+ 'wagtail.wagtailforms',
+ 'wagtail.wagtailimages',
+ 'wagtail.wagtailsites',
+ 'wagtail.wagtailusers',
+ )
+
+ WAGTAIL_MIDDLEWARE = (
+ 'wagtail.wagtailcore.middleware.SiteMiddleware',
+ )
+
+ WAGTAILADMIN_RICH_TEXT_EDITORS = {
+ 'default': {
+ 'WIDGET': 'wagtail.wagtailadmin.rich_text.HalloRichTextArea',
+ },
+ 'custom': {
+ 'WIDGET': 'wagtail.tests.testapp.rich_text.CustomRichTextArea'
+ },
+ }
+
+if django.VERSION >= (1, 10):
+ MIDDLEWARE = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ ) + WAGTAIL_MIDDLEWARE
+else:
+ MIDDLEWARE_CLASSES = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ ) + WAGTAIL_MIDDLEWARE
+
+INSTALLED_APPS = (
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.staticfiles',
+ 'taggit',
+) + WAGTAIL_APPS + (
+ 'treemodeladmin',
+ 'treemodeladmin.tests.treemodeladmintest',
+)
+
+STATIC_URL = '/static/'
+
+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',
+ 'django.template.context_processors.request',
+ ],
+ 'debug': True,
+ },
+ },
+]
+
+WAGTAIL_SITE_NAME = 'Test Site'
diff --git a/treemodeladmin/tests/test_options.py b/treemodeladmin/tests/test_options.py
new file mode 100644
index 0000000..1cf922f
--- /dev/null
+++ b/treemodeladmin/tests/test_options.py
@@ -0,0 +1,107 @@
+from django.test import TestCase
+
+from treemodeladmin.options import TreeModelAdmin
+from treemodeladmin.tests.treemodeladmintest.models import Author, Book
+
+
+class BookHasParentModelAdmin(TreeModelAdmin):
+ model = Book
+ parent_field = 'author'
+
+
+class AuthorHasChildModelAdmin(TreeModelAdmin):
+ model = Author
+ child_field = 'book_set'
+ child_model_admin = BookHasParentModelAdmin
+
+
+class AuthorPlainModelAdmin(TreeModelAdmin):
+ model = Author
+
+
+class TestTreeModelAdmin(TestCase):
+
+ def setUp(self):
+ self.author_model_admin = AuthorHasChildModelAdmin()
+ self.book_model_admin = self.author_model_admin.child_instance
+ self.plain_model_admin = AuthorPlainModelAdmin()
+
+ def test_has_child(self):
+ self.assertTrue(self.author_model_admin.has_child())
+ self.assertFalse(self.book_model_admin.has_child())
+
+ def test_has_child_no_child_field(self):
+ self.assertFalse(self.plain_model_admin.has_child())
+
+ def test_has_child_no_child_model_admin(self):
+ self.plain_model_admin.child_field = 'book_set'
+ self.assertFalse(self.plain_model_admin.has_child())
+
+ def test_has_child_no_child_field_on_model(self):
+ self.plain_model_admin.child_field = 'authored_books'
+ self.plain_model_admin.child_model_admin = BookHasParentModelAdmin
+ self.assertFalse(self.plain_model_admin.has_child())
+
+ def test_has_parent(self):
+ self.assertFalse(self.author_model_admin.has_parent())
+ self.assertTrue(self.book_model_admin.has_parent())
+
+ def test_get_child_field(self):
+ self.assertEqual(
+ self.author_model_admin.get_child_field(),
+ 'book_set'
+ )
+
+ def test_get_child_field_none(self):
+ self.assertEqual(
+ self.book_model_admin.get_child_field(),
+ None
+ )
+
+ def test_get_child_name(self):
+ self.assertEqual(
+ self.author_model_admin.get_child_name(),
+ 'book'
+ )
+
+ def test_get_child_name_none(self):
+ self.assertEqual(
+ self.book_model_admin.get_child_field(),
+ None
+ )
+
+ def test_get_child_name_plural(self):
+ self.assertEqual(
+ self.author_model_admin.get_child_name_plural(),
+ 'books'
+ )
+
+ def test_get_child_name_plural_none(self):
+ self.assertEqual(
+ self.book_model_admin.get_child_field(),
+ None
+ )
+
+ def test_get_parent_field(self):
+ self.assertEqual(
+ self.book_model_admin.get_parent_field(),
+ 'author'
+ )
+
+ def test_get_parent_field_none(self):
+ self.assertEqual(
+ self.author_model_admin.get_parent_field(),
+ None
+ )
+
+ def test_get_admin_urls_for_registration_child(self):
+ self.assertEqual(
+ len(self.author_model_admin.get_admin_urls_for_registration()),
+ 8
+ )
+
+ def test_get_admin_urls_for_registration_no_child(self):
+ self.assertEqual(
+ len(self.book_model_admin.get_admin_urls_for_registration()),
+ 4
+ )
diff --git a/treemodeladmin/tests/test_views.py b/treemodeladmin/tests/test_views.py
new file mode 100644
index 0000000..cc783f4
--- /dev/null
+++ b/treemodeladmin/tests/test_views.py
@@ -0,0 +1,69 @@
+from django.test import TestCase
+
+from wagtail.tests.utils import WagtailTestUtils
+
+
+class TestAuthorIndexView(TestCase, WagtailTestUtils):
+ fixtures = ['treemodeladmin_test.json']
+
+ def setUp(self):
+ self.user = self.login()
+
+ def get(self, **params):
+ return self.client.get('/admin/treemodeladmintest/author/', params)
+
+ def test_author_listing(self):
+ response = self.get()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context['result_count'], 4)
+ self.assertContains(response, "Explore J. R. R. Tolkien's books")
+ self.assertContains(response, "Add a J. R. Hartley book")
+
+ def test_has_child_admin(self):
+ response = self.get()
+ self.assertTrue(response.context['view'].has_child_admin)
+
+ def test_breadcrumbs(self):
+ resposne = self.get()
+ self.assertEqual(
+ list(resposne.context['view'].breadcrumbs),
+ [('/admin/treemodeladmintest/author/', 'authors')]
+ )
+
+
+class TestBookIndexView(TestCase, WagtailTestUtils):
+ fixtures = ['treemodeladmin_test.json']
+
+ def setUp(self):
+ self.user = self.login()
+
+ def get(self, **params):
+ return self.client.get('/admin/treemodeladmintest/book/', params)
+
+ def test_book_listing(self):
+ response = self.get()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context['result_count'], 4)
+
+ def test_book_listing_filtered(self):
+ response = self.get(author=1)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context['result_count'], 2)
+ self.assertEqual(
+ response.context['view'].get_page_title(),
+ 'J. R. R. Tolkien'
+ )
+
+ def test_has_child_admin(self):
+ response = self.get(author=1)
+ self.assertFalse(response.context['view'].has_child_admin)
+
+ def test_breadcrumbs(self):
+ resposne = self.get(author=1)
+ self.assertEqual(
+ list(resposne.context['view'].breadcrumbs),
+ [
+ ('/admin/treemodeladmintest/author/', 'authors'),
+ ('/admin/treemodeladmintest/book/', 'books')
+ ]
+ )
diff --git a/treemodeladmin/tests/treemodeladmintest/__init__.py b/treemodeladmin/tests/treemodeladmintest/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/treemodeladmin/tests/treemodeladmintest/apps.py b/treemodeladmin/tests/treemodeladmintest/apps.py
new file mode 100644
index 0000000..75b3cf5
--- /dev/null
+++ b/treemodeladmin/tests/treemodeladmintest/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+from django.utils.translation import ugettext_lazy as _
+
+
+class TreeModelAdminTestAppConfig(AppConfig):
+ name = 'treemodeladmin.tests.treemodeladmintest'
+ label = 'test_treemodeladmintest'
+ verbose_name = _("Test Tree Model Admin")
diff --git a/treemodeladmin/tests/treemodeladmintest/fixtures/treemodeladmin_test.json b/treemodeladmin/tests/treemodeladmintest/fixtures/treemodeladmin_test.json
new file mode 100644
index 0000000..482c589
--- /dev/null
+++ b/treemodeladmin/tests/treemodeladmintest/fixtures/treemodeladmin_test.json
@@ -0,0 +1,62 @@
+[
+ {
+ "pk": 1,
+ "model": "treemodeladmintest.author",
+ "fields": {
+ "name": "J. R. R. Tolkien"
+ }
+ },
+ {
+ "pk": 2,
+ "model": "treemodeladmintest.author",
+ "fields": {
+ "name": "Roald Dahl"
+ }
+ },
+ {
+ "pk": 3,
+ "model": "treemodeladmintest.author",
+ "fields": {
+ "name": "C. S. Lewis"
+ }
+ },
+ {
+ "pk": 4,
+ "model": "treemodeladmintest.author",
+ "fields": {
+ "name": "J. R. Hartley"
+ }
+ },
+ {
+ "pk": 1,
+ "model": "treemodeladmintest.book",
+ "fields": {
+ "title": "The Lord of the Rings",
+ "author_id": 1
+ }
+ },
+ {
+ "pk": 2,
+ "model": "treemodeladmintest.book",
+ "fields": {
+ "title": "The Hobbit",
+ "author_id": 1
+ }
+ },
+ {
+ "pk": 3,
+ "model": "treemodeladmintest.book",
+ "fields": {
+ "title": "Charlie and the Chocolate Factory",
+ "author_id": 2
+ }
+ },
+ {
+ "pk": 4,
+ "model": "treemodeladmintest.book",
+ "fields": {
+ "title": "The Chronicles of Narnia",
+ "author_id": 3
+ }
+ }
+]
diff --git a/treemodeladmin/tests/treemodeladmintest/migrations/0001_initial.py b/treemodeladmin/tests/treemodeladmintest/migrations/0001_initial.py
new file mode 100644
index 0000000..ec120a7
--- /dev/null
+++ b/treemodeladmin/tests/treemodeladmintest/migrations/0001_initial.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Author',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('name', models.CharField(max_length=255)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Book',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('title', models.CharField(max_length=255)),
+ ('author', models.ForeignKey(to='treemodeladmintest.Author', on_delete=django.db.models.deletion.PROTECT)),
+ ],
+ ),
+ ]
diff --git a/treemodeladmin/tests/treemodeladmintest/migrations/__init__.py b/treemodeladmin/tests/treemodeladmintest/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/treemodeladmin/tests/treemodeladmintest/models.py b/treemodeladmin/tests/treemodeladmintest/models.py
new file mode 100644
index 0000000..71de471
--- /dev/null
+++ b/treemodeladmin/tests/treemodeladmintest/models.py
@@ -0,0 +1,16 @@
+from django.db import models
+
+
+class Author(models.Model):
+ name = models.CharField(max_length=255)
+
+ def __str__(self):
+ return self.name
+
+
+class Book(models.Model):
+ author = models.ForeignKey(Author, on_delete=models.PROTECT)
+ title = models.CharField(max_length=255)
+
+ def __str__(self):
+ return self.title
diff --git a/treemodeladmin/tests/treemodeladmintest/wagtail_hooks.py b/treemodeladmin/tests/treemodeladmintest/wagtail_hooks.py
new file mode 100644
index 0000000..2763419
--- /dev/null
+++ b/treemodeladmin/tests/treemodeladmintest/wagtail_hooks.py
@@ -0,0 +1,25 @@
+from wagtail.contrib.modeladmin.options import (
+ ModelAdminGroup,
+ modeladmin_register,
+)
+
+from treemodeladmin.options import TreeModelAdmin
+from treemodeladmin.tests.treemodeladmintest.models import Author, Book
+
+
+class BookModelAdmin(TreeModelAdmin):
+ model = Book
+ parent_field = 'author'
+
+
+class AuthorModelAdmin(TreeModelAdmin):
+ model = Author
+ child_field = 'book_set'
+ child_model_admin = BookModelAdmin
+
+
+@modeladmin_register
+class TreeModelAdminTestGroup(ModelAdminGroup):
+ menu_label = 'TreeModelAdmin Test'
+ menu_icon = 'list-ul'
+ items = (AuthorModelAdmin,)
diff --git a/treemodeladmin/tests/urls.py b/treemodeladmin/tests/urls.py
new file mode 100644
index 0000000..30ea3a4
--- /dev/null
+++ b/treemodeladmin/tests/urls.py
@@ -0,0 +1,12 @@
+from django.conf.urls import include, url
+
+
+try:
+ from wagtail.admin import urls as wagtailadmin_urls
+except ImportError:
+ from wagtail.wagtailadmin import urls as wagtailadmin_urls
+
+
+urlpatterns = [
+ url(r'^admin/', include(wagtailadmin_urls)),
+]
diff --git a/treemodeladmin/views.py b/treemodeladmin/views.py
new file mode 100644
index 0000000..ec6dfba
--- /dev/null
+++ b/treemodeladmin/views.py
@@ -0,0 +1,123 @@
+from django.contrib.admin.utils import unquote
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.utils.encoding import force_text
+from django.utils.functional import cached_property
+
+from wagtail.contrib.modeladmin.views import IndexView
+
+
+class TreeIndexView(IndexView):
+ parent_instance = None
+ parent_model = None
+ parent_model_admin = None
+ parent_opts = None
+ parent_pk = None
+
+ def __init__(self, model_admin):
+ super(TreeIndexView, self).__init__(model_admin)
+
+ if self.model_admin.has_parent():
+ self.parent_model_admin = self.model_admin.parent
+ self.parent_model = self.parent_model_admin.model
+ self.parent_opts = self.parent_model._meta
+
+ @method_decorator(login_required)
+ def dispatch(self, request, *args, **kwargs):
+ # Only continue if logged in user has list permission
+ if not self.permission_helper.user_can_list(request.user):
+ raise PermissionDenied
+
+ self.params = dict(request.GET.items())
+ if self.model_admin.has_parent():
+ parent_filter_name = self.model_admin.parent_field
+ if parent_filter_name in self.params:
+ self.parent_pk = unquote(self.params[parent_filter_name])
+ filter_kwargs = {self.parent_opts.pk.attname: self.parent_pk}
+ parent_qs = self.parent_model._default_manager.get_queryset(
+ ).filter(**filter_kwargs)
+ self.parent_instance = get_object_or_404(parent_qs)
+
+ return super(TreeIndexView, self).dispatch(request, *args, **kwargs)
+
+ def get_queryset(self, request=None):
+ qs = super(TreeIndexView, self).get_queryset(request=request)
+
+ if self.parent_instance is not None:
+ parent_filter = {
+ self.model_admin.parent_field: self.parent_instance
+ }
+ qs = qs.filter(**parent_filter)
+ return qs
+
+ def get_page_title(self):
+ if self.parent_instance is not None:
+ return str(self.parent_instance)
+ return super(TreeIndexView, self).get_page_title()
+
+ def get_parent_edit_button(self):
+ if self.parent_instance is None:
+ return None
+ parent_button_helper_class = \
+ self.parent_model_admin.get_button_helper_class()
+ parent_button_helper = parent_button_helper_class(self, self.request)
+ return parent_button_helper.edit_button(
+ self.parent_pk,
+ classnames_add=['button-secondary', 'button-small']
+ )
+
+ def get_child_filter(self, parent_pk):
+ if self.has_child:
+ return (
+ self.child_model_admin.parent_field + '=' +
+ str(parent_pk)
+ )
+
+ def get_children(self, obj):
+ if self.has_child:
+ return getattr(obj, self.model_admin.get_child_field())
+
+ @cached_property
+ def has_child_admin(self):
+ return self.model_admin.child_instance is not None
+
+ @cached_property
+ def child_model_admin(self):
+ return self.model_admin.child_instance
+
+ @cached_property
+ def has_child(self):
+ return self.model_admin.has_child()
+
+ @cached_property
+ def child_name(self):
+ return self.model_admin.get_child_name()
+
+ @cached_property
+ def child_name_plural(self):
+ return self.model_admin.get_child_name_plural()
+
+ @cached_property
+ def child_url_helper(self):
+ if self.has_child:
+ return self.model_admin.child_instance.url_helper
+
+ @property
+ def breadcrumbs(self):
+ model_admin = self.model_admin
+ breadcrumbs = []
+
+ while model_admin is not None:
+ breadcrumbs.append((
+ model_admin.url_helper.index_url,
+ force_text(model_admin.model._meta.verbose_name_plural)
+ ))
+
+ if model_admin.has_parent():
+ model_admin = model_admin.parent
+ else:
+ model_admin = None
+
+ return reversed(breadcrumbs)