├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── example ├── .gitignore ├── README.md ├── __init__.py ├── example_app │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_image_status.py │ │ └── __init__.py │ ├── models.py │ └── wagtail_hooks.py ├── example_project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── instance_selector ├── manage.py └── requirements.txt ├── images ├── creation.png ├── fields.png ├── list_view.png └── post_creation.png ├── instance_selector ├── __init__.py ├── apps.py ├── blocks.py ├── constants.py ├── edit_handlers.py ├── exceptions.py ├── registry.py ├── selectors.py ├── static │ └── instance_selector │ │ ├── instance_selector.css │ │ ├── instance_selector_embed.js │ │ ├── instance_selector_telepath.js │ │ └── instance_selector_widget.js ├── templates │ └── instance_selector │ │ ├── instance_selector_embed.html │ │ ├── instance_selector_widget.html │ │ └── instance_selector_widget_display.html ├── urls.py ├── views.py ├── wagtail_hooks.py └── widgets.py ├── requirements.txt ├── runtests.py ├── setup.py └── tests ├── __init__.py ├── test_instance_selector.py └── test_project ├── __init__.py ├── test_app ├── __init__.py ├── apps.py ├── models.py └── wagtail_hooks.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ### 3.0.1 (03/11/2023) 5 | 6 | - Wagtail 5.0+ - Update get_value_data to assign None value instead of empty string 7 | PR: https://github.com/ixc/wagtail-instance-selector/pull/36 8 | 9 | ### 3.0.0 (27/09/2023) 10 | 11 | - Add support for Wagtail 5 and drop support for all Wagtail versions before 4.1. 12 | PR: https://github.com/ixc/wagtail-instance-selector/pull/28 13 | PR: https://github.com/ixc/wagtail-instance-selector/pull/35 14 | 15 | ### 2.1.2 16 | 17 | - Fix Django 3.2+ compatibility re: default_app_config declarations 18 | PR: https://github.com/ixc/wagtail-instance-selector/pull/24 19 | 20 | ### 2.1.1 21 | 22 | - Fix version check for WidgetWithScript.get_value_data #17 23 | PR: https://github.com/ixc/wagtail-instance-selector/pull/17 24 | 25 | ### 2.1.0 (17/05/2021) 26 | 27 | - Wagtail 2.13 compatiblity. 28 | PR: https://github.com/ixc/wagtail-instance-selector/pull/14 29 | 30 | ### 2.0.1 (01/05/2021) 31 | 32 | - Fix for custom user model compatibility. 33 | PR: https://github.com/ixc/wagtail-instance-selector/pull/13. 34 | 35 | ### 2.0.0 (24/08/2020) 36 | 37 | - Django 3 compatibility: replaced calls to django.conf.urls.url with django.urls.path or re_path. 38 | PR: https://github.com/ixc/wagtail-instance-selector/pull/9. 39 | - Frontend: fixed a bug where the actions container used for the select button might not exist. 40 | PR: https://github.com/ixc/wagtail-instance-selector/pull/8 41 | - Frontend: simplified object selection in list items by consolidating on the 42 | data-instance-selector-pk attribute. 43 | PR: https://github.com/ixc/wagtail-instance-selector/pull/8 44 | 45 | ### 1.2.1 (23/03/2020) 46 | 47 | - Django 3 import fix. 48 | PR: https://github.com/ixc/wagtail-instance-selector/pull/2. 49 | 50 | 51 | ### 1.1.0 (23/09/2019) 52 | 53 | - Added hooks to enable selection on non-standard list views. 54 | 55 | 56 | ### 1.0.1 (06/06/2019) 57 | 58 | - PyPI fix. 59 | 60 | 61 | ### 1.0.0 (06/06/2019) 62 | 63 | - Initial release. 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 The Interaction Consortium 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGELOG.md 3 | include README.md 4 | recursive-include instance_selector * 5 | global-exclude *.pyc 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wagtail-instance-selector 2 | 3 | A widget for Wagtail's admin that allows you to create and select related items. 4 | 5 | - [Features and screenshots](#features-and-screenshots) 6 | - [Installation](#installation) 7 | - [Documentation](#documentation) 8 | - [Using the widget as a field panel](#using-the-widget-as-a-field-panel) 9 | - [Using the widget in a stream field](#using-the-widget-in-a-stream-field) 10 | - [Customizing the widget's display and behaviour](#customizing-the-widgets-display-and-behaviour) 11 | - [Rationale & Credits](#rationale--credits) 12 | - [Development notes](#development-notes) 13 | 14 | 15 | ## Features and screenshots 16 | 17 | ### Customizable widget display 18 | 19 | By default, widgets appear similar to other Wagtail elements, but they can be customised to include images 20 | and other items. 21 | 22 | ![](./images/fields.png) 23 | 24 | 25 | ### Item selection reuses the admin's list views to ensure consistent UIs with filtering. 26 | 27 | ![](./images/list_view.png) 28 | 29 | 30 | ### Inline creation 31 | 32 | Items can be created within the selection widget. 33 | 34 | ![](./images/creation.png) 35 | 36 | After creation, items can be selected from the success message or from the list view. 37 | 38 | ![](./images/post_creation.png) 39 | 40 | 41 | ## Installation 42 | 43 | ``` 44 | pip install wagtail-instance-selector 45 | ``` 46 | 47 | and add `'instance_selector'` to `INSTALLED_APPS`. 48 | 49 | If you're using Django 3+, you will need to change Django's iframe security flag in your settings: 50 | 51 | ```python 52 | X_FRAME_OPTIONS = 'SAMEORIGIN' 53 | ``` 54 | 55 | 56 | ## Documentation 57 | 58 | 59 | ### Using the widget as a field panel 60 | 61 | ```python 62 | from django.db import models 63 | from instance_selector.edit_handlers import InstanceSelectorPanel 64 | 65 | 66 | class Shop(models.Model): 67 | pass 68 | 69 | 70 | class Product(models.Model): 71 | shop = models.ForeignKey(Shop, on_delete=models.CASCADE) 72 | 73 | panels = [InstanceSelectorPanel("shop")] 74 | ``` 75 | 76 | 77 | #### Using the widget in a stream field 78 | 79 | ```python 80 | from django.db import models 81 | from wagtail.admin.panels import FieldPanel 82 | from wagtail.fields import StreamField 83 | from instance_selector.blocks import InstanceSelectorBlock 84 | 85 | 86 | class Product(models.Model): 87 | pass 88 | 89 | 90 | class Service(models.Model): 91 | pass 92 | 93 | 94 | class Shop(models.Model): 95 | content = StreamField([ 96 | ("products", InstanceSelectorBlock(target_model="test_app.Product")), 97 | ("services", InstanceSelectorBlock(target_model="test_app.Service")), 98 | ], use_json_field=True) 99 | 100 | panels = [FieldPanel("content")] 101 | ``` 102 | 103 | To create reusable blocks, you can subclass `InstanceSelectorBlock`. 104 | 105 | ```python 106 | from instance_selector.blocks import InstanceSelectorBlock 107 | 108 | 109 | class ProductBlock(InstanceSelectorBlock): 110 | def __init__(self, *args, **kwargs): 111 | target_model = kwargs.pop("target_model", "my_app.Product") 112 | super().__init__(target_model=target_model, **kwargs) 113 | 114 | class Meta: 115 | icon = "image" 116 | 117 | # ... 118 | 119 | StreamField([ 120 | ("products", ProductBlock()), 121 | ]) 122 | ``` 123 | 124 | 125 | ### Customizing the widget's display and behaviour 126 | 127 | ```python 128 | from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register 129 | from instance_selector.registry import registry 130 | from instance_selector.selectors import ModelAdminInstanceSelector 131 | from .models import MyModel 132 | 133 | 134 | @modeladmin_register 135 | class MyModelAdmin(ModelAdmin): 136 | model = MyModel 137 | 138 | 139 | class MyModelInstanceSelector(ModelAdminInstanceSelector): 140 | model_admin = MyModelAdmin() 141 | 142 | def get_instance_display_title(self, instance): 143 | if instance: 144 | return "some title" 145 | 146 | def get_instance_display_image_url(self, instance): 147 | if instance: 148 | return "/url/to/some/image.jpg" 149 | 150 | def get_instance_display_image_styles(self, instance): 151 | # The `style` properties set on the element, primarily of use 152 | # to work within style+layout patterns 153 | if instance: 154 | return { 155 | 'max-width': '165px', 156 | # ... 157 | } 158 | 159 | def get_instance_display_markup(self, instance): 160 | # Overriding this method allows you to completely control how the 161 | # widget will display the relation to this specific model 162 | return "
...
" 163 | 164 | def get_instance_display_template(self): 165 | # The template used by `get_instance_display_markup` 166 | return "instance_selector/instance_selector_widget_display.html" 167 | 168 | def get_instance_selector_url(self): 169 | # The url that the widget will render within a modal. By default, this 170 | # is the ModelAdmin"s list view 171 | return "/url/to/some/view/" 172 | 173 | def get_instance_edit_url(self, instance): 174 | # The url that the user can edit the instance on. By default, this is 175 | # the ModelAdmin"s edit view 176 | if instance: 177 | return "/url/to/some/view/" 178 | 179 | 180 | registry.register_instance_selector(MyModel, MyModelInstanceSelector()) 181 | ``` 182 | 183 | Note that the `ModelAdminInstanceSelector` is designed for the common case. If your needs 184 | are more specific, you may find some use in `instance_selector.selectors.BaseInstanceSelector`. 185 | 186 | 187 | ## Rationale & Credits 188 | 189 | Largely, this is a rewrite of [neon-jungle/wagtailmodelchooser](https://github.com/neon-jungle/wagtailmodelchooser) 190 | that focuses on reusing the functionality in the ModelAdmins. We had started a large build using wagtailmodelchooser 191 | heavily, but quickly ran into UI problems when users needed to filter the objects or create them inline. After 192 | [neon-jungle/wagtailmodelchooser#11](https://github.com/neon-jungle/wagtailmodelchooser/issues/11) received little 193 | response, the decision was made to piece together parts from the ecosystem and replicate the flexibility of 194 | django's `raw_id_fields`, while preserving the polish in Wagtail's UI. 195 | 196 | Much of this library was built atop of the work of others, specifically: 197 | - https://github.com/neon-jungle/wagtailmodelchooser 198 | - https://github.com/springload/wagtailmodelchoosers 199 | - https://github.com/Naeka/wagtailmodelchooser 200 | 201 | 202 | ## Development notes 203 | 204 | 205 | ### Run tests 206 | 207 | ``` 208 | pip install -r requirements.txt 209 | python runtests.py 210 | ``` 211 | 212 | 213 | ### Formatting 214 | 215 | ``` 216 | pip install -r requirements.txt 217 | black . 218 | ``` 219 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /media 2 | /db.sqlite3 3 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## To run example 2 | 3 | ``` 4 | pip install -r requirements.txt 5 | ./manage.py migrate 6 | ./manage.py runserver 7 | ``` 8 | 9 | Navigate to http://127.0.0.1:8000. 10 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/example/__init__.py -------------------------------------------------------------------------------- /example/example_app/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "%s.apps.AppConfig" % __name__ 2 | -------------------------------------------------------------------------------- /example/example_app/apps.py: -------------------------------------------------------------------------------- 1 | from django import apps 2 | 3 | 4 | class AppConfig(apps.AppConfig): 5 | name = ".".join(__name__.split(".")[:-1]) 6 | label = "example_app" 7 | -------------------------------------------------------------------------------- /example/example_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-06 03:49 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Image", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("title", models.CharField(max_length=1000)), 27 | ("image", models.ImageField(upload_to="example_app_images")), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name="Shop", 32 | fields=[ 33 | ( 34 | "id", 35 | models.AutoField( 36 | auto_created=True, 37 | primary_key=True, 38 | serialize=False, 39 | verbose_name="ID", 40 | ), 41 | ), 42 | ("title", models.CharField(max_length=1000)), 43 | ], 44 | ), 45 | migrations.CreateModel( 46 | name="Product", 47 | fields=[ 48 | ( 49 | "id", 50 | models.AutoField( 51 | auto_created=True, 52 | primary_key=True, 53 | serialize=False, 54 | verbose_name="ID", 55 | ), 56 | ), 57 | ("title", models.CharField(max_length=1000)), 58 | ( 59 | "image", 60 | models.ForeignKey( 61 | blank=True, 62 | null=True, 63 | on_delete=django.db.models.deletion.SET_NULL, 64 | related_name="products", 65 | to="example_app.Image", 66 | ), 67 | ), 68 | ( 69 | "shop", 70 | models.ForeignKey( 71 | on_delete=django.db.models.deletion.CASCADE, 72 | related_name="products", 73 | to="example_app.Shop", 74 | ), 75 | ), 76 | ], 77 | ), 78 | ] 79 | -------------------------------------------------------------------------------- /example/example_app/migrations/0002_image_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-06 04:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("example_app", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="image", 13 | name="status", 14 | field=models.CharField( 15 | choices=[("pending", "Pending"), ("pending", "Approved")], 16 | default="pending", 17 | max_length=1000, 18 | ), 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /example/example_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/example/example_app/migrations/__init__.py -------------------------------------------------------------------------------- /example/example_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from instance_selector.edit_handlers import InstanceSelectorPanel 4 | 5 | from wagtail.admin.panels import FieldPanel 6 | 7 | 8 | class Shop(models.Model): 9 | title = models.CharField(max_length=1000) 10 | 11 | def __str__(self): 12 | return self.title 13 | 14 | 15 | class Product(models.Model): 16 | title = models.CharField(max_length=1000) 17 | shop = models.ForeignKey(Shop, on_delete=models.CASCADE, related_name="products") 18 | image = models.ForeignKey( 19 | "Image", 20 | on_delete=models.SET_NULL, 21 | related_name="products", 22 | blank=True, 23 | null=True, 24 | ) 25 | 26 | panels = [ 27 | FieldPanel("title"), 28 | InstanceSelectorPanel("shop"), 29 | InstanceSelectorPanel("image"), 30 | ] 31 | 32 | def __str__(self): 33 | return self.title 34 | 35 | 36 | class Image(models.Model): 37 | PENDING = "pending" 38 | APPROVED = "pending" 39 | 40 | title = models.CharField(max_length=1000) 41 | image = models.ImageField(upload_to="example_app_images") 42 | status = models.CharField( 43 | max_length=1000, 44 | choices=((PENDING, "Pending"), (APPROVED, "Approved")), 45 | default=PENDING, 46 | ) 47 | 48 | def __str__(self): 49 | return self.title 50 | -------------------------------------------------------------------------------- /example/example_app/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.utils.safestring import mark_safe 2 | from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register 3 | from instance_selector.registry import registry 4 | from instance_selector.selectors import ModelAdminInstanceSelector 5 | from .models import Shop, Product, Image 6 | 7 | 8 | @modeladmin_register 9 | class ShopAdmin(ModelAdmin): 10 | model = Shop 11 | 12 | 13 | @modeladmin_register 14 | class ProductAdmin(ModelAdmin): 15 | model = Product 16 | 17 | 18 | @modeladmin_register 19 | class ImageAdmin(ModelAdmin): 20 | model = Image 21 | list_display = ("__str__", "image_preview") 22 | list_filter = ("status",) 23 | 24 | def image_preview(self, instance): 25 | if instance: 26 | return mark_safe( 27 | '' 28 | % instance.image.url 29 | ) 30 | return "" 31 | 32 | image_preview.short_description = "Image" 33 | 34 | 35 | class ImageInstanceSelector(ModelAdminInstanceSelector): 36 | def get_instance_display_image_url(self, instance): 37 | if instance: 38 | return instance.image.url 39 | 40 | 41 | registry.register_instance_selector( 42 | Image, ImageInstanceSelector(model_admin=ImageAdmin()) 43 | ) 44 | -------------------------------------------------------------------------------- /example/example_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/example/example_project/__init__.py -------------------------------------------------------------------------------- /example/example_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "(#(!pj=@l5e6aiq8($950frv%*_e=s*a5-1ne$qu(u_b053wr(" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "wagtail.admin", 41 | "wagtail", 42 | "wagtail.contrib.modeladmin", 43 | "wagtail.contrib.settings", 44 | "wagtail.users", 45 | "wagtail.documents", 46 | "wagtail.images", 47 | "taggit", 48 | "instance_selector", 49 | "example_app", 50 | ] 51 | 52 | MIDDLEWARE = [ 53 | "django.middleware.security.SecurityMiddleware", 54 | "django.contrib.sessions.middleware.SessionMiddleware", 55 | "django.middleware.common.CommonMiddleware", 56 | "django.middleware.csrf.CsrfViewMiddleware", 57 | "django.contrib.auth.middleware.AuthenticationMiddleware", 58 | "django.contrib.messages.middleware.MessageMiddleware", 59 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 60 | ] 61 | 62 | ROOT_URLCONF = "example_project.urls" 63 | 64 | TEMPLATES = [ 65 | { 66 | "BACKEND": "django.template.backends.django.DjangoTemplates", 67 | "DIRS": [], 68 | "APP_DIRS": True, 69 | "OPTIONS": { 70 | "context_processors": [ 71 | "django.template.context_processors.debug", 72 | "django.template.context_processors.request", 73 | "django.contrib.auth.context_processors.auth", 74 | "django.contrib.messages.context_processors.messages", 75 | ] 76 | }, 77 | } 78 | ] 79 | 80 | WSGI_APPLICATION = "example_project.wsgi.application" 81 | 82 | 83 | # Database 84 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 85 | 86 | DATABASES = { 87 | "default": { 88 | "ENGINE": "django.db.backends.sqlite3", 89 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 90 | } 91 | } 92 | 93 | 94 | # Password validation 95 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 96 | 97 | AUTH_PASSWORD_VALIDATORS = [ 98 | { 99 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 100 | }, 101 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 102 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 103 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 104 | ] 105 | 106 | 107 | # Internationalization 108 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 109 | 110 | LANGUAGE_CODE = "en-us" 111 | 112 | TIME_ZONE = "UTC" 113 | 114 | USE_I18N = True 115 | 116 | USE_L10N = True 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 123 | 124 | STATIC_URL = "/static/" 125 | 126 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 127 | 128 | MEDIA_URL = "/media/" 129 | 130 | WAGTAIL_SITE_NAME = "Instance Selector Example" 131 | 132 | X_FRAME_OPTIONS = "SAMEORIGIN" 133 | -------------------------------------------------------------------------------- /example/example_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import path, include 5 | from wagtail import urls as wagtail_urls 6 | from wagtail.admin import urls as wagtail_admin_urls 7 | 8 | 9 | urlpatterns = [ 10 | path("admin/", include(wagtail_admin_urls)), 11 | path("django-admin/", admin.site.urls), 12 | path("", include(wagtail_urls)), 13 | ] 14 | 15 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 16 | 17 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 18 | -------------------------------------------------------------------------------- /example/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/instance_selector: -------------------------------------------------------------------------------- 1 | ../instance_selector -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.2 2 | wagtail>=4.1 3 | wagtail-instance-selector 4 | -------------------------------------------------------------------------------- /images/creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/images/creation.png -------------------------------------------------------------------------------- /images/fields.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/images/fields.png -------------------------------------------------------------------------------- /images/list_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/images/list_view.png -------------------------------------------------------------------------------- /images/post_creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/images/post_creation.png -------------------------------------------------------------------------------- /instance_selector/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0.1" 2 | -------------------------------------------------------------------------------- /instance_selector/apps.py: -------------------------------------------------------------------------------- 1 | from django import apps 2 | 3 | 4 | class AppConfig(apps.AppConfig): 5 | name = ".".join(__name__.split(".")[:-1]) 6 | label = "instance_selector" 7 | -------------------------------------------------------------------------------- /instance_selector/blocks.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property, lazy 2 | 3 | from instance_selector.widgets import InstanceSelectorWidget 4 | from instance_selector.registry import registry 5 | 6 | from wagtail.blocks import ChooserBlock 7 | from wagtail.coreutils import resolve_model_string 8 | from wagtail.telepath import register 9 | from wagtail.blocks.field_block import FieldBlockAdapter 10 | 11 | 12 | 13 | class InstanceSelectorBlock(ChooserBlock): 14 | class Meta: 15 | icon = "placeholder" 16 | 17 | def __init__(self, target_model, **kwargs): 18 | super().__init__(**kwargs) 19 | 20 | self._target_model = target_model 21 | 22 | if self.meta.icon == "placeholder": 23 | # The models/selectors may not have been registered yet, depending upon 24 | # import orders and things, so get the icon lazily 25 | self.meta.icon = lazy(self.get_instance_selector_icon, str) 26 | 27 | @cached_property 28 | def target_model(self): 29 | return resolve_model_string(self._target_model) 30 | 31 | @cached_property 32 | def widget(self): 33 | return InstanceSelectorWidget(self.target_model) 34 | 35 | def get_form_state(self, value): 36 | return self.widget.get_value_data(value) 37 | 38 | def get_instance_selector_icon(self): 39 | instance_selector = registry.get_instance_selector(self.target_model) 40 | return instance_selector.get_widget_icon() 41 | 42 | def deconstruct(self): 43 | name, args, kwargs = super().deconstruct() 44 | 45 | if args: 46 | args = args[1:] # Remove the args target_model 47 | 48 | kwargs["target_model"] = self.target_model._meta.label_lower 49 | return name, args, kwargs 50 | 51 | def value_from_form(self, value): 52 | # When validation fails, the value might be "" (Wagtail 5.0+) 53 | # and None if succeeds. 54 | if value == "": 55 | value = None 56 | 57 | return super().value_from_form(value) 58 | 59 | class InstanceSelectorBlockAdapter(FieldBlockAdapter): 60 | def js_args(self, block): 61 | name, widget, meta = super().js_args(block) 62 | 63 | # Fix up the 'icon' item in meta so that it's a string that we can serialize, 64 | # rather than a lazy reference 65 | if callable(meta["icon"]): 66 | meta["icon"] = meta["icon"]() 67 | 68 | return [name, widget, meta] 69 | 70 | 71 | register(InstanceSelectorBlockAdapter(), InstanceSelectorBlock) 72 | -------------------------------------------------------------------------------- /instance_selector/constants.py: -------------------------------------------------------------------------------- 1 | OBJECT_PK_PARAM = "object_pk" 2 | -------------------------------------------------------------------------------- /instance_selector/edit_handlers.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import render_to_string 2 | from django.utils.safestring import mark_safe 3 | 4 | from instance_selector.widgets import InstanceSelectorWidget 5 | 6 | from wagtail.admin.panels import FieldPanel 7 | 8 | 9 | class InstanceSelectorPanel(FieldPanel): 10 | model = None 11 | field_name = None 12 | 13 | def get_form_options(self): 14 | opts = super().get_form_options() 15 | 16 | # Use the instance selector widget for this option 17 | opts["widgets"] = {self.field_name: InstanceSelectorWidget(model=self.target_model)} 18 | 19 | return opts 20 | 21 | @property 22 | def target_model(self): 23 | return self.db_field.remote_field.model 24 | 25 | def render_as_field(self): 26 | instance_obj = self.get_chosen_item() 27 | return mark_safe( 28 | render_to_string( 29 | self.field_template, 30 | {"field": self.bound_field, "instance": instance_obj}, 31 | ) 32 | ) 33 | -------------------------------------------------------------------------------- /instance_selector/exceptions.py: -------------------------------------------------------------------------------- 1 | class ModelAdminLookupFailed(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /instance_selector/registry.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from django.conf import settings 3 | from django.apps import apps 4 | from django.contrib.auth import get_user_model 5 | from instance_selector.selectors import ( 6 | ModelAdminInstanceSelector, 7 | WagtailUserInstanceSelector, 8 | ) 9 | 10 | from instance_selector.exceptions import ModelAdminLookupFailed 11 | 12 | __all__ = ("Registry", "registry") 13 | 14 | 15 | class Registry: 16 | def __init__(self): 17 | self._models = { 18 | # ('app_label', 'model_name') => Model 19 | } 20 | self._selectors = { 21 | # Model => InstanceSelector instance 22 | } 23 | 24 | def get_model(self, app_label, model_name): 25 | key = (app_label, model_name) 26 | if key not in self._models: 27 | model = apps.get_model(app_label=app_label, model_name=model_name) 28 | self.register_model(app_label, model_name, model) 29 | return self._models[key] 30 | 31 | def get_instance_selector(self, model): 32 | User = get_user_model() 33 | if model not in self._selectors: 34 | from wagtail.admin.menu import admin_menu, settings_menu 35 | 36 | model_admin = self._find_model_admin_in_menu(admin_menu, model) 37 | if not model_admin: 38 | model_admin = self._find_model_admin_in_menu(settings_menu, model) 39 | 40 | if model_admin: 41 | instance_selector = ModelAdminInstanceSelector(model_admin=model_admin) 42 | self.register_instance_selector(model, instance_selector) 43 | elif model is User: 44 | if "wagtail.users" in settings.INSTALLED_APPS: 45 | # Wagtail uses bespoke functional views for Users, so we apply a preconfigured 46 | # variant at the last opportunity. This reduces setup difficulty, while preserving 47 | # the ability to apply overrides downstream 48 | self.register_instance_selector( 49 | model, WagtailUserInstanceSelector() 50 | ) 51 | else: 52 | raise ModelAdminLookupFailed( 53 | "Cannot find model admin for %s. You may need to register a model with wagtail's admin or " 54 | "register an instance selector" % model 55 | ) 56 | 57 | return self._selectors[model] 58 | 59 | def register_instance_selector(self, model, instance_selector): 60 | if model in self._selectors: 61 | raise Exception( 62 | "%s has already been registered. Cannot register %s -> %s" 63 | % (model, model, instance_selector) 64 | ) 65 | 66 | if inspect.isclass(instance_selector): 67 | raise Exception( 68 | "Expected an instance of a class, but received %s. You may need to call your class before " 69 | "registering it" % instance_selector 70 | ) 71 | 72 | self._selectors[model] = instance_selector 73 | 74 | def register_model(self, app_label, model_name, model): 75 | key = (app_label, model_name) 76 | if key in self._models: 77 | raise Exception( 78 | "%s has already been registered to %s. Cannot register %s -> %s" 79 | % (key, self._models[key], key, model) 80 | ) 81 | self._models[key] = model 82 | 83 | def clear(self): 84 | """ 85 | Forces the registry to re-discover all models and selectors 86 | """ 87 | self._models = {} 88 | self._selectors = {} 89 | 90 | def _find_model_admin_in_menu(self, menu, model): 91 | for item in menu.registered_menu_items: 92 | if hasattr(item, "model_admin"): 93 | model_admin = item.model_admin 94 | if model_admin.model == model: 95 | return model_admin 96 | if hasattr(item, "menu"): 97 | model_admin = self._find_model_admin_in_menu(item.menu, model) 98 | if model_admin: 99 | return model_admin 100 | 101 | 102 | registry = Registry() 103 | -------------------------------------------------------------------------------- /instance_selector/selectors.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from django.urls import reverse 3 | from django.template import loader 4 | from django.utils.safestring import mark_safe 5 | 6 | 7 | class BaseInstanceSelector: 8 | def __init__(self): 9 | self.display_template = loader.get_template( 10 | self.get_instance_display_template() 11 | ) 12 | 13 | def get_instance_display_markup(self, instance): 14 | """ 15 | Markup representing the instance's display in a widget 16 | """ 17 | markup = self.display_template.render( 18 | { 19 | "display_title": self.get_instance_display_title(instance), 20 | "display_image_url": self.get_instance_display_image_url(instance), 21 | "display_image_styles": self.get_instance_display_image_styles( 22 | instance 23 | ), 24 | "edit_url": self.get_instance_edit_url(instance), 25 | } 26 | ) 27 | return mark_safe(markup) 28 | 29 | def get_instance_display_title(self, instance): 30 | """ 31 | A textual representation of the instance 32 | """ 33 | if instance: 34 | return str(instance) 35 | 36 | def get_instance_display_image_url(self, instance): 37 | """ 38 | An optional url to an image representing the instance 39 | """ 40 | return None 41 | 42 | def get_instance_display_image_styles(self, instance): 43 | return {"max-width": "165px", "max-height": "165px"} 44 | 45 | def get_instance_display_template(self): 46 | return "instance_selector/instance_selector_widget_display.html" 47 | 48 | def get_instance_edit_url(self, instance): 49 | """ 50 | The url that the instance can be edited at 51 | """ 52 | raise NotImplementedError 53 | 54 | def get_instance_selector_url(self): 55 | """ 56 | The url of a view that instances can be selected from, typically a list view 57 | """ 58 | raise NotImplementedError 59 | 60 | def get_widget_icon(self): 61 | return "placeholder" 62 | 63 | 64 | class ModelAdminInstanceSelector(BaseInstanceSelector): 65 | model_admin = None 66 | 67 | def __init__(self, model_admin=None): 68 | super().__init__() 69 | 70 | if model_admin is not None: 71 | self.model_admin = model_admin 72 | 73 | if not self.model_admin: 74 | raise Exception( 75 | "`model_admin` must be defined as an attribute on %s or passed to its __init__ method" 76 | % (type(self)) 77 | ) 78 | 79 | if inspect.isclass(self.model_admin): 80 | raise Exception( 81 | "Expected an instance of a model admin class, but received %s. " 82 | "You may need to instantiate your model admin before passing it to the instance selector" 83 | % self.model_admin 84 | ) 85 | 86 | def get_instance_selector_url(self): 87 | index_url = self.model_admin.url_helper.index_url 88 | return index_url 89 | 90 | def get_instance_edit_url(self, instance): 91 | if instance: 92 | return self.model_admin.url_helper.get_action_url("edit", instance.pk) 93 | 94 | 95 | class WagtailUserInstanceSelector(BaseInstanceSelector): 96 | def get_instance_selector_url(self): 97 | return reverse("wagtailusers_users:index") 98 | 99 | def get_instance_edit_url(self, instance): 100 | if instance: 101 | return reverse("wagtailusers_users:edit", args=[instance.pk]) 102 | -------------------------------------------------------------------------------- /instance_selector/static/instance_selector/instance_selector.css: -------------------------------------------------------------------------------- 1 | .is-instance-selector-embed .wrapper { 2 | padding-left: 0; 3 | } 4 | 5 | .is-instance-selector-embed .nav-wrapper { 6 | display: none; 7 | } 8 | 9 | .is-instance-selector-embed .header-title .right { 10 | float: left; 11 | } 12 | 13 | .instance-selector__success-message__select-button { 14 | cursor: pointer; 15 | } 16 | 17 | .is-instance-selector-embed .listing .actions > *:not(.instance-selector__select-button) { 18 | display: none; 19 | } 20 | 21 | .is-instance-selector-embed .instance-selector__select-button { 22 | cursor: pointer; 23 | } 24 | 25 | .instance-selector-widget .unchosen:before { 26 | margin-right: 0; 27 | } 28 | 29 | .instance-selector-widget-modal__content { 30 | padding-bottom: 0; 31 | } 32 | 33 | .instance-selector-widget-modal__body { 34 | height: 90vh; 35 | } 36 | 37 | .instance-selector-widget-modal__embed { 38 | position: absolute; 39 | top: 0; 40 | left: 0; 41 | width: 100%; 42 | height: 100%; 43 | } 44 | 45 | .instance-selector-widget-modal .button.close { 46 | right: 25px; 47 | color: var(--w-color-primary); 48 | } 49 | 50 | .instance-selector-widget__loading-indicator { 51 | display: none; 52 | animation: instance-selector-widget__loading-indicator__spin_animation 1s infinite linear; 53 | } 54 | 55 | @keyframes instance-selector-widget__loading-indicator__spin_animation { 56 | from { 57 | transform:rotate(0deg); 58 | } 59 | to { 60 | transform:rotate(360deg); 61 | } 62 | } 63 | 64 | .instance-selector-widget--is-loading .instance-selector-widget__loading-indicator { 65 | display: inline-block; 66 | } 67 | 68 | .instance-selector-widget--is-loading .instance-selector-widget__actions__trigger { 69 | cursor: not-allowed; 70 | } 71 | 72 | .instance-selector-widget--unselected .instance-selector-widget__display, 73 | .instance-selector-widget--unselected .instance-selector-widget__actions__clear, 74 | .instance-selector-widget--unselected .instance-selector-widget__actions__edit, 75 | .instance-selector-widget--unselected .instance-selector-widget__actions__trigger__choose-another { 76 | display: none; 77 | } 78 | 79 | .instance-selector-widget--unselected .instance-selector-widget__actions__trigger { 80 | margin-left: 0; 81 | } 82 | 83 | .instance-selector-widget--selected .instance-selector-widget__actions__trigger__choose-one { 84 | display: none; 85 | } 86 | 87 | .instance-selector-widget__display { 88 | margin-bottom: 10px; 89 | } 90 | 91 | .instance-selector-widget__display--no-image .instance-selector-widget__display__image-wrap { 92 | display: none; 93 | } 94 | -------------------------------------------------------------------------------- /instance_selector/static/instance_selector/instance_selector_embed.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const IS_EMBEDDED = window.parent && window.parent !== window; 3 | const IS_INSTANCE_SELECTOR_EMBED = IS_EMBEDDED 4 | ? window.parent.location.pathname.indexOf('/instance-selector/') !== -1 5 | : false; 6 | 7 | const SESSION_STORAGE_EMBED_KEY = 'INSTANCE_SELECTOR_EMBED_ID'; 8 | 9 | if (IS_INSTANCE_SELECTOR_EMBED) { 10 | const HASH_EMBED_ID = window.location.hash.split('#instance_selector_embed_id:')[1]; 11 | // Persist embed id across page loads (allows clicking on filters, searching, etc) 12 | const SESSION_EMBED_ID = sessionStorage.getItem(SESSION_STORAGE_EMBED_KEY); 13 | if (HASH_EMBED_ID) { 14 | sessionStorage.setItem(SESSION_STORAGE_EMBED_KEY, HASH_EMBED_ID); 15 | } 16 | 17 | const EMBED_ID = HASH_EMBED_ID || SESSION_EMBED_ID; 18 | if (!EMBED_ID) { 19 | throw new Error('`EMBED_ID` cannot be determined'); 20 | } 21 | 22 | // Poll for earliest access to the body so that we can effect the styles before 23 | // complete page load 24 | setTimeout(_poll_for_body, 0); 25 | function _poll_for_body() { 26 | if (!document.body) { 27 | setTimeout(_poll_for_body, 0); 28 | return; 29 | } 30 | document.body.classList.add('is-instance-selector-embed'); 31 | } 32 | 33 | document.addEventListener('DOMContentLoaded', function() { 34 | $('.listing').find('tbody tr[data-object-pk]').each(function() { 35 | const object_pk = this.getAttribute('data-object-pk'); 36 | const select_action = $( 37 | '
  • ' + 38 | 'Select' + 39 | '
  • ' 40 | ); 41 | let actions = $(this).find('.actions'); 42 | if (!actions.length) { 43 | actions = $(''); 44 | $(this).children('td').first().append(actions); 45 | } 46 | actions.append(select_action); 47 | }); 48 | 49 | $(document.body).on('click', '[data-instance-selector-pk]', function(event) { 50 | event.preventDefault(); 51 | const object_pk = this.getAttribute('data-instance-selector-pk'); 52 | handle_object_pk_selected(object_pk); 53 | }); 54 | 55 | // Allow users to select items referenced in creation success messages 56 | const success_messages = $('.messages .success'); 57 | success_messages.each(function() { 58 | const success_message = $(this); 59 | 60 | const buttons = success_message.find('.buttons a'); 61 | buttons.each(function() { 62 | const button = $(this); 63 | const button_text = button.text().trim(); 64 | if (button_text.toLowerCase() === 'edit') { 65 | const url = button.attr('href'); 66 | const data = get_data_from_url(url); 67 | if (data) { 68 | const select_button = $(` 69 | Select 73 | `); 74 | button.parent().prepend(select_button); 75 | select_button.on('click', function(event) { 76 | event.preventDefault(); 77 | handle_object_pk_selected(data.object_pk); 78 | }); 79 | } 80 | } 81 | }); 82 | }); 83 | 84 | }); 85 | 86 | const index_view_url = window.location.pathname; 87 | function get_data_from_url(url) { 88 | const url_subpath = url.split(index_view_url)[1]; 89 | const url_subpath_tokens = url_subpath.split('/'); 90 | const view_name = url_subpath_tokens[0]; 91 | const object_pk = url_subpath_tokens[1]; 92 | const pk = object_pk ? object_pk : view_name; 93 | 94 | return { 95 | view_name: view_name, 96 | object_pk: pk, 97 | }; 98 | } 99 | 100 | function handle_object_pk_selected(object_pk) { 101 | // Embed session writes persist into the parent's session 102 | sessionStorage.removeItem(SESSION_STORAGE_EMBED_KEY); 103 | 104 | const message = { 105 | 'source': EMBED_ID, 106 | 'object_pk': object_pk, 107 | }; 108 | window.parent.postMessage(message, location.origin); 109 | } 110 | } 111 | })(); 112 | -------------------------------------------------------------------------------- /instance_selector/static/instance_selector/instance_selector_telepath.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | function InstanceSelector(html, config) { 3 | this.html = html; 4 | this.baseConfig = config; 5 | } 6 | InstanceSelector.prototype.render = function(placeholder, name, id, initialState) { 7 | var html = this.html.replace(/__NAME__/g, name).replace(/__ID__/g, id); 8 | placeholder.outerHTML = html; 9 | var config = {}; 10 | // replace __NAME__ and __ID__ placeholders in config with the real name / id 11 | for (prop in this.baseConfig) { 12 | config[prop] = this.baseConfig[prop].replace(/__NAME__/g, name).replace(/__ID__/g, id);; 13 | } 14 | 15 | var chooser = create_instance_selector_widget(config); 16 | chooser.setState(initialState); 17 | return chooser; 18 | }; 19 | 20 | window.telepath.register('wagtailinstanceselector.widgets.InstanceSelector', InstanceSelector); 21 | })(); 22 | -------------------------------------------------------------------------------- /instance_selector/static/instance_selector/instance_selector_widget.js: -------------------------------------------------------------------------------- 1 | function create_instance_selector_widget(opts) { 2 | const widget_root = $('#' + opts.widget_id); 3 | const field_input = $('#' + opts.input_id); 4 | const display_edit_link = widget_root.find('.js-instance-selector-widget-display-edit-link'); 5 | const display_markup_wrap = widget_root.find('.js-instance-selector-widget-display-wrap'); 6 | const trigger_button = widget_root.find('.js-instance-selector-widget-trigger'); 7 | const edit_button = widget_root.find('.js-instance-selector-widget-edit'); 8 | const clear_button = widget_root.find('.js-instance-selector-widget-clear'); 9 | 10 | if (!widget_root.length) throw new Error(`Cannot find instance selector widget \`#${opts.widget_id}\``); 11 | if (!display_markup_wrap.length) throw new Error(`Cannot find instance selector widget display markup wrap in \`#${opts.widget_id}\``); 12 | if (!field_input.length) throw new Error(`Cannot find instance selector widget field input in \`#${opts.widget_id}\``); 13 | if (!trigger_button.length) throw new Error(`Cannot find instance selector widget trigger button in \`#${opts.widget_id}\``); 14 | if (!edit_button.length) throw new Error(`Cannot find instance selector widget edit button in \`#${opts.widget_id}\``); 15 | 16 | let modal; 17 | let is_loading = false; 18 | 19 | trigger_button.on('click', function() { 20 | if (is_loading) { 21 | return; 22 | } 23 | 24 | enter_loading_state(); 25 | 26 | // Remove any previous modals 27 | // $('body > .modal').remove(); 28 | 29 | modal = $(` 30 | 43 | `); 44 | 45 | modal.find('iframe').on('load', () => { 46 | exit_loading_state(); 47 | show_modal(); 48 | }); 49 | 50 | $('body').append(modal); 51 | }); 52 | 53 | clear_button.on('click', () => { 54 | set_value({ 55 | pk: '', 56 | }); 57 | }); 58 | 59 | window.addEventListener('message', message => { 60 | if (message.data && message.data.source === opts.embed_id) { 61 | const object_pk = message.data.object_pk; 62 | if (!object_pk) { 63 | throw new Error('`object_pk` was not provided by embed. Received: ' + JSON.stringify(message.data)); 64 | } 65 | 66 | enter_loading_state(); 67 | hide_modal(); 68 | 69 | const lookup_url = `${opts.lookup_url}?${opts.OBJECT_PK_PARAM}=${object_pk}`; 70 | $.get({ 71 | url: lookup_url, 72 | error: function(err) { 73 | exit_loading_state(); 74 | console.error(err); 75 | }, 76 | success: function(data) { 77 | exit_loading_state(); 78 | set_value(data); 79 | } 80 | }); 81 | } 82 | }); 83 | 84 | function show_modal() { 85 | modal.modal('show'); 86 | } 87 | 88 | function hide_modal() { 89 | modal.modal('hide'); 90 | } 91 | 92 | function enter_loading_state() { 93 | is_loading = true; 94 | widget_root.addClass('instance-selector-widget--is-loading'); 95 | widget_root.removeClass('instance-selector-widget--not-loading'); 96 | } 97 | 98 | function exit_loading_state() { 99 | is_loading = false; 100 | widget_root.removeClass('instance-selector-widget--is-loading'); 101 | widget_root.addClass('instance-selector-widget--not-loading'); 102 | } 103 | 104 | function set_value(data) { 105 | field_input.val(data.pk); 106 | 107 | display_markup_wrap.html(data.display_markup); 108 | display_edit_link.attr('href', data.edit_url || null); 109 | edit_button.attr('href', data.edit_url || null); 110 | 111 | if (data.pk) { 112 | widget_root 113 | .removeClass('instance-selector-widget--unselected') 114 | .addClass('instance-selector-widget--selected'); 115 | } else { 116 | widget_root 117 | .addClass('instance-selector-widget--unselected') 118 | .removeClass('instance-selector-widget--selected'); 119 | } 120 | } 121 | 122 | // define public API functions for the widget: 123 | // https://docs.wagtail.io/en/latest/reference/streamfield/widget_api.html 124 | const widget_api = { 125 | idForLabel: null, 126 | getState: function() { 127 | return { 128 | 'pk': field_input.val(), 129 | 'display_markup': display_markup_wrap.html(), 130 | 'edit_url': edit_button.attr('href'), 131 | }; 132 | }, 133 | getValue: function() { 134 | return field_input.val(); 135 | }, 136 | setState: function(newState) { 137 | set_value(newState); 138 | }, 139 | focus: function() { 140 | trigger_button.focus(); 141 | }, 142 | } 143 | return widget_api; 144 | } 145 | -------------------------------------------------------------------------------- /instance_selector/templates/instance_selector/instance_selector_embed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 17 | 44 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /instance_selector/templates/instance_selector/instance_selector_widget.html: -------------------------------------------------------------------------------- 1 |
    5 |
    6 | {{ display_markup }} 7 |
    8 |
    9 | {% if not widget.is_required %} 10 | 16 | {% endif %} 17 | 25 | {{ widget.link_to_chosen_text }} 30 |
    31 |
    32 | 33 | {{ original_field_html }} 34 | -------------------------------------------------------------------------------- /instance_selector/templates/instance_selector/instance_selector_widget_display.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 8 | 17 | 18 |
    19 |
    20 | 25 | {{ display_title }} 26 | 27 |
    28 |
    29 | -------------------------------------------------------------------------------- /instance_selector/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from instance_selector.views import instance_selector_embed, instance_selector_lookup 3 | 4 | 5 | urlpatterns = [ 6 | re_path( 7 | r"^instance-selector/embed/(?P[\w-]+).(?P[\w-]+)/$", 8 | instance_selector_embed, 9 | name="wagtail_instance_selector_embed", 10 | ), 11 | re_path( 12 | r"^instance-selector/lookup/(?P[\w-]+).(?P[\w-]+)/$", 13 | instance_selector_lookup, 14 | name="wagtail_instance_selector_lookup", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /instance_selector/views.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.http import HttpResponseBadRequest, JsonResponse 3 | from django.core.exceptions import PermissionDenied 4 | from django.template.response import TemplateResponse 5 | from django.utils.text import slugify 6 | from instance_selector.constants import OBJECT_PK_PARAM 7 | from instance_selector.registry import registry 8 | 9 | 10 | def user_can_access_admin(user): 11 | if not user: 12 | return False 13 | return ( 14 | user.is_superuser or user.is_staff or user.has_perm("wagtailadmin.access_admin") 15 | ) 16 | 17 | 18 | def instance_selector_embed(request, app_label, model_name): 19 | if not user_can_access_admin(request.user): 20 | raise PermissionDenied 21 | 22 | model = registry.get_model(app_label, model_name) 23 | instance_selector = registry.get_instance_selector(model) 24 | instance_selector_url = instance_selector.get_instance_selector_url() 25 | embed_id = slugify("%s-%s-%s" % (app_label, model_name, time.time())) 26 | embed_url = "%s#instance_selector_embed_id:%s" % (instance_selector_url, embed_id) 27 | 28 | context = {"embed_url": embed_url, "embed_id": embed_id} 29 | return TemplateResponse( 30 | request, "instance_selector/instance_selector_embed.html", context 31 | ) 32 | 33 | 34 | def instance_selector_lookup(request, app_label, model_name): 35 | if not user_can_access_admin(request.user): 36 | raise PermissionDenied 37 | 38 | object_pk = request.GET.get(OBJECT_PK_PARAM) 39 | if not object_pk: 40 | return HttpResponseBadRequest( 41 | "Param `%s` does have a value defined" % OBJECT_PK_PARAM 42 | ) 43 | 44 | model = registry.get_model(app_label, model_name) 45 | instance = model.objects.get(pk=object_pk) 46 | instance_selector = registry.get_instance_selector(model) 47 | 48 | display_markup = instance_selector.get_instance_display_markup(instance) 49 | edit_url = instance_selector.get_instance_edit_url(instance) 50 | 51 | return JsonResponse( 52 | {"display_markup": display_markup, "edit_url": edit_url, "pk": object_pk} 53 | ) 54 | -------------------------------------------------------------------------------- /instance_selector/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.utils.html import format_html 2 | from django.templatetags.static import static 3 | 4 | from instance_selector import urls 5 | 6 | from wagtail import hooks 7 | 8 | 9 | @hooks.register("register_admin_urls") 10 | def register_instance_selector_urls(): 11 | return urls.urlpatterns 12 | 13 | 14 | @hooks.register("insert_global_admin_css") 15 | def global_admin_css(): 16 | return format_html( 17 | '', 18 | static("instance_selector/instance_selector.css"), 19 | static("instance_selector/instance_selector_embed.js"), 20 | static("instance_selector/instance_selector_widget.js"), 21 | ) 22 | -------------------------------------------------------------------------------- /instance_selector/widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.forms import widgets 3 | from django.template.loader import render_to_string 4 | from django.urls import reverse 5 | from django.utils.translation import gettext_lazy as _ 6 | from instance_selector.constants import OBJECT_PK_PARAM 7 | from instance_selector.registry import registry 8 | 9 | from wagtail.telepath import register 10 | from wagtail.utils.widgets import WidgetWithScript 11 | from wagtail.widget_adapters import WidgetAdapter 12 | 13 | 14 | class InstanceSelectorWidget(WidgetWithScript, widgets.Input): 15 | # when looping over form fields, this one should appear in visible_fields, not hidden_fields 16 | # despite the underlying input being type="hidden" 17 | input_type = "hidden" 18 | is_hidden = False 19 | 20 | def __init__(self, model, **kwargs): 21 | self.target_model = model 22 | 23 | model_name = self.target_model._meta.verbose_name 24 | self.choose_one_text = _("Choose %s") % model_name 25 | self.choose_another_text = _("Choose another %s") % model_name 26 | self.link_to_chosen_text = _("Edit this %s") % model_name 27 | self.clear_choice_text = _("Clear choice") 28 | self.show_edit_link = True 29 | self.show_clear_link = True 30 | 31 | super().__init__(**kwargs) 32 | 33 | def get_value_data(self, value): 34 | # Given a data value (which may be None, a model instance, or a PK here), 35 | # extract the necessary data for rendering the widget with that value. 36 | # In the case of StreamField (in Wagtail >=2.13), this data will be serialised via 37 | # telepath https://wagtail.github.io/telepath/ to be rendered client-side, which means it 38 | # cannot include model instances. Instead, we return the raw values used in rendering - 39 | # namely: pk, display_markup and edit_url 40 | 41 | if not str(value).strip(): # value might be "" (Wagtail 5.0+) 42 | value = None 43 | 44 | if value is None or isinstance(value, self.target_model): 45 | instance = value 46 | else: # assume this is an instance ID 47 | instance = self.target_model.objects.get(pk=value) 48 | 49 | app_label = self.target_model._meta.app_label 50 | model_name = self.target_model._meta.model_name 51 | model = registry.get_model(app_label, model_name) 52 | instance_selector = registry.get_instance_selector(model) 53 | display_markup = instance_selector.get_instance_display_markup(instance) 54 | edit_url = instance_selector.get_instance_edit_url(instance) 55 | 56 | return { 57 | "pk": instance.pk if instance else None, 58 | "display_markup": display_markup, 59 | "edit_url": edit_url, 60 | } 61 | 62 | def render_html(self, name, value, attrs): 63 | value_data = value 64 | 65 | original_field_html = super().render_html(name, value_data["pk"], attrs) 66 | 67 | app_label = self.target_model._meta.app_label 68 | model_name = self.target_model._meta.model_name 69 | 70 | embed_url = reverse( 71 | "wagtail_instance_selector_embed", 72 | kwargs={"app_label": app_label, "model_name": model_name}, 73 | ) 74 | # We use the input name for the embed id so that wagtail's block code will automatically 75 | # replace any `__prefix__` substring with a specific id for the widget instance 76 | embed_id = name 77 | embed_url += "#instance_selector_embed_id:" + embed_id 78 | 79 | lookup_url = reverse( 80 | "wagtail_instance_selector_lookup", 81 | kwargs={"app_label": app_label, "model_name": model_name}, 82 | ) 83 | 84 | return render_to_string( 85 | "instance_selector/instance_selector_widget.html", 86 | { 87 | "name": name, 88 | "is_nonempty": value_data["pk"] is not None, 89 | "widget": self, 90 | "widget_id": "%s-instance-selector-widget" % attrs["id"], 91 | "original_field_html": original_field_html, 92 | "display_markup": value_data["display_markup"], 93 | "edit_url": value_data["edit_url"], 94 | }, 95 | ) 96 | 97 | def get_js_config(self, id_, name): 98 | app_label = self.target_model._meta.app_label 99 | model_name = self.target_model._meta.model_name 100 | 101 | embed_url = reverse( 102 | "wagtail_instance_selector_embed", 103 | kwargs={"app_label": app_label, "model_name": model_name}, 104 | ) 105 | # We use the input name for the embed id so that wagtail's block code will automatically 106 | # replace any `__prefix__` substring with a specific id for the widget instance 107 | embed_id = name 108 | embed_url += "#instance_selector_embed_id:" + embed_id 109 | 110 | lookup_url = reverse( 111 | "wagtail_instance_selector_lookup", 112 | kwargs={"app_label": app_label, "model_name": model_name}, 113 | ) 114 | 115 | return { 116 | "input_id": id_, 117 | "widget_id": "%s-instance-selector-widget" % id_, 118 | "field_name": name, 119 | "embed_url": embed_url, 120 | "embed_id": embed_id, 121 | "lookup_url": lookup_url, 122 | "OBJECT_PK_PARAM": OBJECT_PK_PARAM, 123 | } 124 | 125 | def render_js_init(self, id_, name, value): 126 | config = self.get_js_config(id_, name) 127 | return "create_instance_selector_widget({config});".format( 128 | config=json.dumps(config) 129 | ) 130 | 131 | 132 | class InstanceSelectorAdapter(WidgetAdapter): 133 | js_constructor = "wagtailinstanceselector.widgets.InstanceSelector" 134 | 135 | def js_args(self, widget): 136 | return [ 137 | widget.render_html( 138 | "__NAME__", widget.get_value_data(None), attrs={"id": "__ID__"} 139 | ), 140 | widget.get_js_config("__ID__", "__NAME__"), 141 | ] 142 | 143 | class Media: 144 | js = [ 145 | "instance_selector/instance_selector_telepath.js", 146 | ] 147 | 148 | 149 | register(InstanceSelectorAdapter(), InstanceSelectorWidget) 150 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.2 2 | django-webtest>=1.9.5,<2.0 3 | wagtail>=4.1 4 | black==24.3.0 5 | ipdb>=0.12,<1.0 6 | 7 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | **{ 6 | "DATABASES": { 7 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"} 8 | }, 9 | "INSTALLED_APPS": ( 10 | "instance_selector", 11 | "tests.test_project.test_app", 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.messages", 17 | "wagtail.admin", 18 | "wagtail", 19 | "wagtail.contrib.modeladmin", 20 | "wagtail.contrib.settings", 21 | "wagtail.users", 22 | "wagtail.documents", 23 | "wagtail.images", 24 | "taggit", 25 | ), 26 | "TEMPLATES": [ 27 | { 28 | "BACKEND": "django.template.backends.django.DjangoTemplates", 29 | "DIRS": [], 30 | # 'APP_DIRS': True, # Must not be set when `loaders` is defined 31 | "OPTIONS": { 32 | "context_processors": [ 33 | "django.template.context_processors.debug", 34 | "django.template.context_processors.request", 35 | "django.contrib.auth.context_processors.auth", 36 | "django.contrib.messages.context_processors.messages", 37 | ], 38 | "loaders": [ 39 | "django.template.loaders.filesystem.Loader", 40 | "django.template.loaders.app_directories.Loader", 41 | ], 42 | }, 43 | } 44 | ], 45 | "MIDDLEWARE": ( 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ), 54 | "STATIC_URL": "/static/", 55 | "ROOT_URLCONF": "tests.test_project.urls", 56 | "WAGTAIL_SITE_NAME": "test", 57 | "SECRET_KEY": "fake-key", 58 | "WAGTAILADMIN_BASE_URL": "http://localhost:8000", 59 | } 60 | ) 61 | 62 | import django 63 | 64 | django.setup() 65 | 66 | from django.test.utils import get_runner 67 | 68 | TestRunner = get_runner(settings) 69 | test_runner = TestRunner(verbosity=1, interactive=True) 70 | failures = test_runner.run_tests(["tests"]) 71 | sys.exit(failures) 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import instance_selector 3 | 4 | 5 | install_requires = ["wagtail>=4.1"] 6 | testing_requires = ["django_webtest"] 7 | 8 | setup( 9 | name="wagtail-instance-selector", 10 | version=instance_selector.__version__, 11 | packages=["instance_selector"], 12 | include_package_data=True, 13 | description="A widget for Wagtail's admin that allows you to create and select related items", 14 | long_description="Documentation at https://github.com/ixc/wagtail-instance-selector", 15 | author="The Interaction Consortium", 16 | author_email="admins@interaction.net.au", 17 | url="https://github.com/ixc/wagtail-instance-selector", 18 | install_requires=install_requires, 19 | extras_require={"testing": testing_requires}, 20 | classifiers=[ 21 | "Development Status :: 5 - Production/Stable", 22 | "Environment :: Web Environment", 23 | "Framework :: Django", 24 | "Framework :: Django :: 3.2", 25 | "Framework :: Django :: 4.1", 26 | "Framework :: Django :: 4.2", 27 | "Framework :: Wagtail", 28 | "Framework :: Wagtail :: 4", 29 | "Framework :: Wagtail :: 5", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: 3.10", 37 | "Programming Language :: Python :: 3.11", 38 | "Topic :: Utilities", 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/wagtail-instance-selector/ad7dd971ccabeee06ba92ed421783c58b50db827/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_instance_selector.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django_webtest import WebTest 3 | from django.contrib.auth import get_user_model 4 | 5 | from instance_selector.constants import OBJECT_PK_PARAM 6 | from instance_selector.registry import registry 7 | from instance_selector.selectors import ( 8 | BaseInstanceSelector, 9 | ModelAdminInstanceSelector, 10 | WagtailUserInstanceSelector, 11 | ) 12 | from .test_project.test_app.models import TestModelA, TestModelB, TestModelC 13 | from .test_project.test_app.wagtail_hooks import ( 14 | TestModelAAdmin, 15 | TestModelBAdmin, 16 | ) 17 | 18 | User = get_user_model() 19 | 20 | 21 | class Tests(WebTest): 22 | """ 23 | Commented out tests are failing for the Wagtail 4.0 + releases. 24 | 25 | Im not sure how relevant they are now that the widgets are being rendered 26 | by javascript. 27 | """ 28 | def setUp(self): 29 | TestModelA.objects.all().delete() 30 | TestModelB.objects.all().delete() 31 | TestModelC.objects.all().delete() 32 | self.superuser = User.objects.create_superuser( 33 | "superuser", "superuser@example.com", "test" 34 | ) 35 | registry.clear() 36 | 37 | def test_registry_automatically_discovers_models(self): 38 | self.assertEqual(registry.get_model("test_app", "TestModelB"), TestModelB) 39 | 40 | def test_registry_throws_for_unknown_models(self): 41 | self.assertRaises( 42 | LookupError, lambda: registry.get_model("test_app", "SomeUnknownModel") 43 | ) 44 | 45 | def test_registry_automatically_creates_instance_selectors(self): 46 | selector = registry.get_instance_selector(TestModelB) 47 | self.assertIsInstance(selector.model_admin, TestModelBAdmin) 48 | 49 | def test_custom_model_can_be_registered(self): 50 | class Model: 51 | pass 52 | 53 | registry.register_model("foo", "bar", Model) 54 | self.assertEqual(registry.get_model("foo", "bar"), Model) 55 | 56 | def test_custom_instance_selector_can_be_registered(self): 57 | class TestInstanceSelector(BaseInstanceSelector): 58 | pass 59 | 60 | registry.register_instance_selector(TestModelB, TestInstanceSelector()) 61 | self.assertIsInstance( 62 | registry.get_instance_selector(TestModelB), TestInstanceSelector 63 | ) 64 | 65 | def test_user_instance_selector_is_automatically_created(self): 66 | selector = registry.get_instance_selector(User) 67 | self.assertIsInstance(selector, WagtailUserInstanceSelector) 68 | 69 | def test_widget_renders_during_model_creation(self): 70 | res = self.app.get("/admin/test_app/testmodelb/create/", user=self.superuser) 71 | self.assertIn('/static/instance_selector/instance_selector.css', res.text) 72 | self.assertIn('/static/instance_selector/instance_selector_embed.js', res.text) 73 | self.assertIn('/static/instance_selector/instance_selector_widget.js', res.text) 74 | # leaving these commented out for now, as the widget is now rendered by javascript 75 | # and they may be useful to decide how relevant they are now. 76 | # self.assertIn('class="instance-selector-widget ', res.text) 77 | # self.assertIn("create_instance_selector_widget({", res.text) 78 | 79 | def test_widget_renders_during_model_edit_without_value(self): 80 | b = TestModelB.objects.create() 81 | res = self.app.get( 82 | "/admin/test_app/testmodelb/edit/%s/" % b.pk, user=self.superuser 83 | ) 84 | self.assertIn('/static/instance_selector/instance_selector.css', res.text) 85 | self.assertIn('/static/instance_selector/instance_selector_embed.js', res.text) 86 | self.assertIn('/static/instance_selector/instance_selector_widget.js', res.text) 87 | # leaving these commented out for now, as the widget is now rendered by javascript 88 | # and they may be useful to decide how relevant they are now. 89 | # self.assertIn('class="instance-selector-widget ', res.text) 90 | # self.assertIn("create_instance_selector_widget({", res.text) 91 | 92 | def test_widget_renders_during_model_edit_with_value(self): 93 | a = TestModelA.objects.create() 94 | b = TestModelB.objects.create(test_model_a=a) 95 | res = self.app.get( 96 | "/admin/test_app/testmodelb/edit/%s/" % b.pk, user=self.superuser 97 | ) 98 | self.assertIn( 99 | '' 100 | % a.pk, 101 | res.text, 102 | ) 103 | self.assertIn( 104 | 'TestModelA object (%s)' 105 | % a.pk, 106 | res.text, 107 | ) 108 | 109 | def test_widget_can_render_custom_display_data(self): 110 | class TestInstanceSelector(BaseInstanceSelector): 111 | def get_instance_display_title(self, instance): 112 | return "test display title" 113 | 114 | def get_instance_display_image_url(self, instance): 115 | return "test display image url" 116 | 117 | def get_instance_edit_url(self, instance): 118 | return "test edit url" 119 | 120 | registry.register_instance_selector(TestModelA, TestInstanceSelector()) 121 | 122 | a = TestModelA.objects.create() 123 | b = TestModelB.objects.create(test_model_a=a) 124 | 125 | res = self.app.get( 126 | "/admin/test_app/testmodelb/edit/%s/" % b.pk, user=self.superuser 127 | ) 128 | self.assertIn( 129 | '/static/instance_selector/instance_selector.css', 130 | res.text, 131 | ) 132 | self.assertIn( 133 | '/static/instance_selector/instance_selector_embed.js', 134 | res.text, 135 | ) 136 | self.assertIn( 137 | '/static/instance_selector/instance_selector_widget.js', 138 | res.text, 139 | ) 140 | # leaving these commented out for now, as the widget is now rendered by javascript 141 | # and they may be useful to decide how relevant they are now. 142 | # self.assertIn( 143 | # 'test display title', 144 | # res.text, 145 | # ) 146 | # self.assertIn('src="test display image url"', res.text) 147 | # self.assertIn('href="test edit url"', res.text) 148 | 149 | def test_widget_can_render_custom_display_markup(self): 150 | class TestInstanceSelector(ModelAdminInstanceSelector): 151 | def get_instance_display_markup(self, instance): 152 | return "test display markup" 153 | 154 | registry.register_instance_selector( 155 | TestModelA, TestInstanceSelector(model_admin=TestModelAAdmin()) 156 | ) 157 | 158 | a = TestModelA.objects.create() 159 | b = TestModelB.objects.create(test_model_a=a) 160 | 161 | res = self.app.get( 162 | "/admin/test_app/testmodelb/edit/%s/" % b.pk, user=self.superuser 163 | ) 164 | self.assertIn( 165 | '/static/instance_selector/instance_selector.css', 166 | res.text, 167 | ) 168 | self.assertIn( 169 | '/static/instance_selector/instance_selector_embed.js', 170 | res.text, 171 | ) 172 | self.assertIn( 173 | '/static/instance_selector/instance_selector_widget.js', 174 | res.text, 175 | ) 176 | # leaving this commented out for now, as the widget is now rendered by javascript 177 | # and they may be useful to decide how relevant they are now. 178 | # self.assertIn("test display markup", res.text) 179 | 180 | def test_widget_can_lookup_updated_display_information(self): 181 | selector = registry.get_instance_selector(TestModelA) 182 | 183 | a = TestModelA.objects.create() 184 | 185 | lookup_url = "%s?%s=%s" % ( 186 | reverse( 187 | "wagtail_instance_selector_lookup", 188 | kwargs={"app_label": "test_app", "model_name": "TestModelA"}, 189 | ), 190 | OBJECT_PK_PARAM, 191 | a.pk, 192 | ) 193 | res = self.app.get(lookup_url, user=self.superuser) 194 | self.assertEqual( 195 | res.json, 196 | { 197 | "display_markup": selector.get_instance_display_markup(a), 198 | "edit_url": "/admin/test_app/testmodela/edit/%s/" % a.pk, 199 | "pk": "%s" % a.pk, 200 | }, 201 | ) 202 | 203 | def test_widget_can_lookup_updated_custom_display_information(self): 204 | class TestInstanceSelector(BaseInstanceSelector): 205 | def get_instance_display_markup(self, instance): 206 | return "test display markup" 207 | 208 | def get_instance_edit_url(self, instance): 209 | return "test edit url" 210 | 211 | registry.register_instance_selector(TestModelA, TestInstanceSelector()) 212 | 213 | a = TestModelA.objects.create() 214 | 215 | lookup_url = "%s?%s=%s" % ( 216 | reverse( 217 | "wagtail_instance_selector_lookup", 218 | kwargs={"app_label": "test_app", "model_name": "TestModelA"}, 219 | ), 220 | OBJECT_PK_PARAM, 221 | a.pk, 222 | ) 223 | res = self.app.get(lookup_url, user=self.superuser) 224 | self.assertEqual( 225 | res.json, 226 | { 227 | "display_markup": "test display markup", 228 | "edit_url": "test edit url", 229 | "pk": "%s" % a.pk, 230 | }, 231 | ) 232 | 233 | def test_widget_lookup_without_pk_will_fail(self): 234 | lookup_url = reverse( 235 | "wagtail_instance_selector_lookup", 236 | kwargs={"app_label": "test_app", "model_name": "TestModelA"}, 237 | ) 238 | res = self.app.get(lookup_url, user=self.superuser, expect_errors=True) 239 | self.assertEqual(res.status_code, 400) 240 | 241 | def test_embed_view_renders_selector_url(self): 242 | selector = registry.get_instance_selector(TestModelA) 243 | embed_url = reverse( 244 | "wagtail_instance_selector_embed", 245 | kwargs={"app_label": "test_app", "model_name": "TestModelA"}, 246 | ) 247 | res = self.app.get(embed_url, user=self.superuser) 248 | self.assertIn( 249 | '