├── .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 | 
23 |
24 |
25 | ### Item selection reuses the admin's list views to ensure consistent UIs with filtering.
26 |
27 | 
28 |
29 |
30 | ### Inline creation
31 |
32 | Items can be created within the selection widget.
33 |
34 | 
35 |
36 | After creation, items can be selected from the success message or from the list view.
37 |
38 | 
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 |
32 |
33 | {{ original_field_html }}
34 |
--------------------------------------------------------------------------------
/instance_selector/templates/instance_selector/instance_selector_widget_display.html:
--------------------------------------------------------------------------------
1 |
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 | '