ImageBlock for StreamField (rebase of #11791) (#12416)

Co-authored-by: Chiemezuo <chiemezuoakujobi@gmail.com>
Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
pull/12433/head
Matt Westcott 2024-10-21 16:11:34 +01:00 zatwierdzone przez GitHub
rodzic 105338d0d6
commit 93f8600c31
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
17 zmienionych plików z 835 dodań i 56 usunięć

Wyświetl plik

@ -5,6 +5,7 @@ Changelog
~~~~~~~~~~~~~~~~
* Add formal support for Django 5.1 (Matt Westcott)
* Add `ImageBlock` with alt text support (Chiemezuo Akujobi for Google Summer of Code, mentored by Storm Heg, Saptak Sengupta, Thibaud Colas and Matt Westcott)
* Formalize support for MariaDB (Sage Abdullah, Daniel Black)
* Redirect to the last viewed listing page after deleting form submissions (Matthias Brück)
* Provide `getTextLabel` method on date / time StreamField blocks (Vaughn Dickson)

Wyświetl plik

@ -0,0 +1,25 @@
class ImageBlockDefinition extends window.wagtailStreamField.blocks
.StructBlockDefinition {
render(placeholder, prefix, initialState, initialError) {
const block = super.render(placeholder, prefix, initialState, initialError);
const altTextField = document.getElementById(`${prefix}-alt_text`);
const isDecorativeField = document.getElementById(`${prefix}-decorative`);
const updateStateInput = () => {
if (isDecorativeField.checked) {
altTextField.setAttribute('disabled', true);
} else {
altTextField.removeAttribute('disabled');
}
};
updateStateInput();
isDecorativeField.addEventListener('change', updateStateInput);
return block;
}
}
window.telepath.register(
'wagtail.images.blocks.ImageBlock',
ImageBlockDefinition,
);

Wyświetl plik

@ -60,6 +60,7 @@ module.exports = function exports(env, argv) {
'image-chooser',
'image-chooser-modal',
'image-chooser-telepath',
'image-block',
],
'documents': [
'document-chooser',

Wyświetl plik

@ -25,7 +25,7 @@ This document details the block types provided by Wagtail for use in [StreamFiel
body = StreamField([
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
], block_counts={
'heading': {'min_num': 1},
'image': {'max_num': 5},
@ -338,7 +338,18 @@ All block definitions accept the following optional keyword arguments:
A control to allow the editor to select an existing document object, or upload a new one. The following additional keyword argument is accepted:
:param required: If true (the default), the field cannot be left blank.
```
(streamfield_imageblock)=
```{eval-rst}
.. autoclass:: wagtail.images.blocks.ImageBlock
:show-inheritance:
An accessibility-focused control to allow the editor to select an existing image, or upload a new one. This has provision for adding alt text, indicating whether images are purely decorative, and is the Wagtail-recommended approach to uploading images. The following additional keyword argument is accepted:
:param required: If true (the default), the field cannot be left blank.
.. autoclass:: wagtail.images.blocks.ImageChooserBlock
:show-inheritance:
@ -412,7 +423,7 @@ All block definitions accept the following optional keyword arguments:
('person', blocks.StructBlock([
('first_name', blocks.CharBlock()),
('surname', blocks.CharBlock()),
('photo', ImageChooserBlock(required=False)),
('photo', ImageBlock(required=False)),
('biography', blocks.RichTextBlock()),
], icon='user')),
])
@ -426,7 +437,7 @@ All block definitions accept the following optional keyword arguments:
class PersonBlock(blocks.StructBlock):
first_name = blocks.CharBlock()
surname = blocks.CharBlock()
photo = ImageChooserBlock(required=False)
photo = ImageBlock(required=False)
biography = blocks.RichTextBlock()
class Meta:
@ -442,7 +453,7 @@ All block definitions accept the following optional keyword arguments:
body = StreamField([
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
('person', PersonBlock()),
])
@ -504,7 +515,7 @@ All block definitions accept the following optional keyword arguments:
# ...
('carousel', blocks.StreamBlock(
[
('image', ImageChooserBlock()),
('image', ImageBlock()),
('quotation', blocks.StructBlock([
('text', blocks.TextBlock()),
('author', blocks.CharBlock()),
@ -521,7 +532,7 @@ All block definitions accept the following optional keyword arguments:
.. code-block:: python
class CarouselBlock(blocks.StreamBlock):
image = ImageChooserBlock()
image = ImageBlock()
quotation = blocks.StructBlock([
('text', blocks.TextBlock()),
('author', blocks.CharBlock()),

Wyświetl plik

@ -19,6 +19,10 @@ This release adds formal support for Python 3.13.
This release adds formal support for Django 5.1.
### `ImageBlock` with alt text support
This release introduces a new block type [`ImageBlock`](streamfield_imageblock), which improves upon `ImageChooserBlock` by allowing editors to specify alt text tailored to the context in which the image is used. This can be used as a direct replacement for `ImageChooserBlock`, and is the new recommended block type for all images that are not purely decorative. This feature was developed by Chiemezuo Akujobi as part of the Google Summer of Code program with mentoring support from Storm Heg, Saptak Sengupta, Thibaud Colas and Matt Westcott.
### Incremental dashboard enhancements
The Wagtail dashboard design evolves towards providing more information and navigation features. Mobile support is much improved.

Wyświetl plik

@ -19,7 +19,7 @@ from wagtail.models import Page
from wagtail.fields import StreamField
from wagtail import blocks
from wagtail.admin.panels import FieldPanel
from wagtail.images.blocks import ImageChooserBlock
from wagtail.images.blocks import ImageBlock
class BlogPage(Page):
author = models.CharField(max_length=255)
@ -27,7 +27,7 @@ class BlogPage(Page):
body = StreamField([
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
])
content_panels = Page.content_panels + [
@ -118,12 +118,12 @@ body = StreamField([
('person', blocks.StructBlock([
('first_name', blocks.CharBlock()),
('surname', blocks.CharBlock()),
('photo', ImageChooserBlock(required=False)),
('photo', ImageBlock(required=False)),
('biography', blocks.RichTextBlock()),
])),
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
])
```
@ -153,7 +153,7 @@ Placing a StructBlock's list of child blocks inside a `StreamField` definition c
class PersonBlock(blocks.StructBlock):
first_name = blocks.CharBlock()
surname = blocks.CharBlock()
photo = ImageChooserBlock(required=False)
photo = ImageBlock(required=False)
biography = blocks.RichTextBlock()
```
@ -164,7 +164,7 @@ body = StreamField([
('person', PersonBlock()),
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
])
```
@ -181,12 +181,12 @@ body = StreamField([
('person', blocks.StructBlock([
('first_name', blocks.CharBlock()),
('surname', blocks.CharBlock()),
('photo', ImageChooserBlock(required=False)),
('photo', ImageBlock(required=False)),
('biography', blocks.RichTextBlock()),
], icon='user')),
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
])
```
@ -196,7 +196,7 @@ body = StreamField([
class PersonBlock(blocks.StructBlock):
first_name = blocks.CharBlock()
surname = blocks.CharBlock()
photo = ImageChooserBlock(required=False)
photo = ImageBlock(required=False)
biography = blocks.RichTextBlock()
class Meta:
@ -213,10 +213,10 @@ For a list of icons available out of the box, see our [icons overview](icons). P
:emphasize-lines: 2
body = StreamField([
('gallery', blocks.ListBlock(ImageChooserBlock())),
('gallery', blocks.ListBlock(ImageBlock())),
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
])
```
@ -247,12 +247,12 @@ When reading back the content of a StreamField (such as when rendering a templat
body = StreamField([
('carousel', blocks.StreamBlock([
('image', ImageChooserBlock()),
('image', ImageBlock()),
('video', EmbedBlock()),
])),
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
])
```
@ -260,7 +260,7 @@ body = StreamField([
```python
class CarouselBlock(blocks.StreamBlock):
image = ImageChooserBlock()
image = ImageBlock()
video = EmbedBlock()
class Meta:
@ -273,7 +273,7 @@ A StreamBlock subclass defined in this way can also be passed to a `StreamField`
class CommonContentBlock(blocks.StreamBlock):
heading = blocks.CharBlock(form_classname="title")
paragraph = blocks.RichTextBlock()
image = ImageChooserBlock()
image = ImageBlock()
class BlogPage(Page):
@ -310,7 +310,7 @@ By default, a StreamField can contain an unlimited number of blocks. The `min_nu
body = StreamField([
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
], min_num=2, max_num=5)
```
@ -320,7 +320,7 @@ Or equivalently:
class CommonContentBlock(blocks.StreamBlock):
heading = blocks.CharBlock(form_classname="title")
paragraph = blocks.RichTextBlock()
image = ImageChooserBlock()
image = ImageBlock()
class Meta:
min_num = 2
@ -333,7 +333,7 @@ The `block_counts` option can be used to set a minimum or maximum count for spec
body = StreamField([
('heading', blocks.CharBlock(form_classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('image', ImageBlock()),
], block_counts={
'heading': {'min_num': 1, 'max_num': 3},
})
@ -345,7 +345,7 @@ Or equivalently:
class CommonContentBlock(blocks.StreamBlock):
heading = blocks.CharBlock(form_classname="title")
paragraph = blocks.RichTextBlock()
image = ImageChooserBlock()
image = ImageBlock()
class Meta:
block_counts = {
@ -364,7 +364,7 @@ By default, each block is rendered using simple, minimal HTML markup, or no mark
[
('first_name', blocks.CharBlock()),
('surname', blocks.CharBlock()),
('photo', ImageChooserBlock(required=False)),
('photo', ImageBlock(required=False)),
('biography', blocks.RichTextBlock()),
],
template='myapp/blocks/person.html',
@ -378,7 +378,7 @@ Or, when defined as a subclass of StructBlock:
class PersonBlock(blocks.StructBlock):
first_name = blocks.CharBlock()
surname = blocks.CharBlock()
photo = ImageChooserBlock(required=False)
photo = ImageBlock(required=False)
biography = blocks.RichTextBlock()
class Meta:

Wyświetl plik

@ -39,17 +39,17 @@ from wagtail.blocks import (
StructBlock,
)
from wagtail.embeds.blocks import EmbedBlock
from wagtail.images.blocks import ImageChooserBlock
from wagtail.images.blocks import ImageBlock
class ImageBlock(StructBlock):
image = ImageChooserBlock(required=True)
class CaptionedImageBlock(StructBlock):
image = ImageBlock(required=True)
caption = CharBlock(required=False)
attribution = CharBlock(required=False)
class Meta:
icon = "image"
template = "base/blocks/image_block.html"
template = "base/blocks/captioned_image_block.html"
class HeadingBlock(StructBlock):
@ -73,7 +73,7 @@ class HeadingBlock(StructBlock):
class BaseStreamBlock(StreamBlock):
heading_block = HeadingBlock()
paragraph_block = RichTextBlock(icon="pilcrow")
image_block = ImageBlock()
image_block = CaptionedImageBlock()
embed_block = EmbedBlock(
help_text="Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks",
icon="media",
@ -82,21 +82,21 @@ class BaseStreamBlock(StreamBlock):
In the preceding code, you created reusable Wagtail custom blocks for different content types in your general-purpose app. You can use these blocks across your site in any order. Let's take a closer look at each of these blocks.
First, `ImageBlock` is a block that editors can use to add images to a StreamField section.
First, `CaptionedImageBlock` is a block that editors can use to add images to a StreamField section.
```python
class ImageBlock(StructBlock):
image = ImageChooserBlock(required=True)
class CaptionedImageBlock(StructBlock):
image = ImageBlock(required=True)
caption = CharBlock(required=False)
attribution = CharBlock(required=False)
class Meta:
icon = "image"
template = "base/blocks/image_block.html"
template = "base/blocks/captioned_image_block.html"
```
`ImageBlock` inherits from `StructBlock`. With `StructBlock`, you can group several child blocks together under a single parent block. Your `ImageBlock` has three child blocks. The first child block, `Image`, uses the `ImageChooserBlock` field block type. With `ImageChooserBlock`, editors can select an existing image or upload a new one. Its `required` argument has a value of `true`, which means that you must provide an image for the block to work. The `caption` and `attribution` child blocks use the `CharBlock` field block type, which provides single-line text inputs for adding captions and attributions to your images. Your `caption` and `attribution` child blocks have their `required` attributes set to `false`. That means you can leave them empty in your [admin interface](https://guide.wagtail.org/en-latest/concepts/wagtail-interfaces/#admin-interface) if you want to.
`CaptionedImageBlock` inherits from `StructBlock`. With `StructBlock`, you can group several child blocks together under a single parent block. Your `CaptionedImageBlock` has three child blocks. The first child block, `Image`, uses the `ImageBlock` field block type. With `ImageBlock`, editors can select an existing image or upload a new one. Its `required` argument has a value of `true`, which means that you must provide an image for the block to work. The `caption` and `attribution` child blocks use the `CharBlock` field block type, which provides single-line text inputs for adding captions and attributions to your images. Your `caption` and `attribution` child blocks have their `required` attributes set to `false`. That means you can leave them empty in your [admin interface](https://guide.wagtail.org/en-latest/concepts/wagtail-interfaces/#admin-interface) if you want to.
Just like `ImageBlock`, your `HeadingBlock` also inherits from `StructBlock`. It has two child blocks. Let's look at those.
Just like `CaptionedImageBlock`, your `HeadingBlock` also inherits from `StructBlock`. It has two child blocks. Let's look at those.
```python
class HeadingBlock(StructBlock):
@ -124,24 +124,24 @@ Your `BaseStreamBlock` class inherits from `StreamBlock`. `StreamBlock` defines
class BaseStreamBlock(StreamBlock):
heading_block = HeadingBlock()
paragraph_block = RichTextBlock(icon="pilcrow")
image_block = ImageBlock()
image_block = CaptionedImageBlock()
embed_block = EmbedBlock(
help_text="Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks",
icon="media",
)
```
Your `BaseStreamBlock` has four child blocks. The `heading_block` uses the previously defined `HeadingBlock`. `paragraph_block` uses `RichTextBlock`, which provides a WYSIWYG editor for creating formatted text. `image_block` uses the previously defined `ImageBlock` class. `embed_block` is a block for embedding external content like videos. It uses the Wagtail `EmbedBlock`. To discover more field block types that you can use, read the [documentation on Field block types](field_block_types).
Your `BaseStreamBlock` has four child blocks. The `heading_block` uses the previously defined `HeadingBlock`. `paragraph_block` uses `RichTextBlock`, which provides a WYSIWYG editor for creating formatted text. `image_block` uses the previously defined `CaptionedImageBlock` class. `embed_block` is a block for embedding external content like videos. It uses the Wagtail `EmbedBlock`. To discover more field block types that you can use, read the [documentation on Field block types](field_block_types).
Also, you defined a `Meta` class within your `ImageBlock` and `HeadingBlock` blocks. The `Meta` classes provide metadata for the blocks, including icons to visually represent them in the admin interface. The `Meta` classes also include custom templates for rendering your `ImageBlock` and `HeadingBlock` blocks.
Also, you defined a `Meta` class within your `CaptionedImageBlock` and `HeadingBlock` blocks. The `Meta` classes provide metadata for the blocks, including icons to visually represent them in the admin interface. The `Meta` classes also include custom templates for rendering your `CaptionedImageBlock` and `HeadingBlock` blocks.
```{note}
Wagtail provides built-in templates to render each block. However, you can override the built-in template with a custom template.
```
Finally, you must add the custom templates that you defined in the `Meta` classes of your `ImageBlock` and `HeadingBlock` blocks.
Finally, you must add the custom templates that you defined in the `Meta` classes of your `CaptionedImageBlock` and `HeadingBlock` blocks.
To add the custom template of your `ImageBlock`, create a `base/templates/base/blocks/image_block.html` file and add the following to it:
To add the custom template of your `CaptionedImageBlock`, create a `base/templates/base/blocks/captioned_image_block.html` file and add the following to it:
```html+django
{% load wagtailimages_tags %}
@ -245,8 +245,8 @@ from wagtail.blocks import (
StructBlock,
)
# import ImageChooserBlock:
from wagtail.images.blocks import ImageChooserBlock
# import ImageBlock:
from wagtail.images.blocks import ImageBlock
from base.blocks import BaseStreamBlock
@ -254,7 +254,7 @@ from base.blocks import BaseStreamBlock
class CardBlock(StructBlock):
heading = CharBlock()
text = RichTextBlock(features=["bold", "italic", "link"])
image = ImageChooserBlock(required=False)
image = ImageBlock(required=False)
class Meta:
icon = "form"

Wyświetl plik

@ -1822,7 +1822,7 @@ class TestPageDetailWithStreamField(TestCase):
self.assertEqual(content["body"][0]["value"], "foo")
self.assertTrue(content["body"][0]["id"])
def test_image_block(self):
def test_image_chooser_block(self):
stream_page = self.make_stream_page('[{"type": "image", "value": 1}]')
response_url = reverse("wagtailapi_v2:pages:detail", args=(stream_page.id,))
@ -1833,7 +1833,7 @@ class TestPageDetailWithStreamField(TestCase):
self.assertEqual(content["body"][0]["type"], "image")
self.assertEqual(content["body"][0]["value"], 1)
def test_image_block_with_custom_get_api_representation(self):
def test_image_chooser_block_with_custom_get_api_representation(self):
stream_page = self.make_stream_page('[{"type": "image", "value": 1}]')
response_url = "{}?extended=1".format(
@ -1848,6 +1848,19 @@ class TestPageDetailWithStreamField(TestCase):
content["body"][0]["value"], {"id": 1, "title": "A missing image"}
)
def test_image_block(self):
stream_page = self.make_stream_page(
'[{"type": "image_with_alt", "value": {"image": 1, "alt_text": "Some alt text", "decorative": false}}]'
)
response_url = reverse("wagtailapi_v2:pages:detail", args=(stream_page.id,))
response = self.client.get(response_url)
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(content["body"][0]["type"], "image_with_alt")
self.assertEqual(content["body"][0]["value"]["image"], 1)
self.assertEqual(content["body"][0]["value"]["alt_text"], "Some alt text")
@override_settings(
WAGTAILFRONTENDCACHE={

Wyświetl plik

@ -1,3 +1,4 @@
import copy
import datetime
from decimal import Decimal
@ -817,11 +818,22 @@ class ChooserBlock(FieldBlock):
"""Return the model instances for the given list of primary keys.
The instances must be returned in the same order as the values and keep None values.
If the same ID appears multiple times, a distinct object instance is created for each one.
"""
objects = self.model_class.objects.in_bulk(values)
return [
objects.get(id) for id in values
] # Keeps the ordering the same as in values.
seen_ids = set()
result = []
for id in values:
obj = objects.get(id)
if obj is not None and id in seen_ids:
# this object is already in the result list, so we need to make a copy
obj = copy.copy(obj)
result.append(obj)
seen_ids.add(id)
return result
def get_prep_value(self, value):
# the native value (a model instance or None) should serialise to a PK or None

Wyświetl plik

@ -1,8 +1,14 @@
from django import forms
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail.admin.compare import BlockComparison
from wagtail.blocks import ChooserBlock
from wagtail.admin.compare import BlockComparison, StructBlockComparison
from wagtail.blocks import BooleanBlock, CharBlock, ChooserBlock, StructBlock
from wagtail.blocks.struct_block import StructBlockAdapter, StructBlockValidationError
from wagtail.images.models import AbstractImage
from wagtail.telepath import register
from .shortcuts import get_rendition_or_not_found
@ -51,3 +57,225 @@ class ImageChooserBlockComparison(BlockComparison):
"image_b": self.val_b,
},
)
class ImageBlock(StructBlock):
"""
An usage of ImageChooserBlock with support for alt text.
For backward compatibility, this block overrides necessary methods to change
the StructValue to be an Image model instance, making it compatible in
places where ImageChooserBlock was used.
"""
image = ImageChooserBlock(required=True)
decorative = BooleanBlock(
default=False, required=False, label=_("Image is decorative")
)
alt_text = CharBlock(required=False, label=_("Alt text"))
def __init__(self, required=True, **kwargs):
super().__init__(
[
("image", ImageChooserBlock(required=required)),
(
"decorative",
BooleanBlock(
default=False, required=False, label=_("Image is decorative")
),
),
("alt_text", CharBlock(required=False, label=_("Alt text"))),
],
**kwargs,
)
def deconstruct(self):
"""
For typical StructBlock subclasses, it makes sense for the deconstructed block object to be a basic StructBlock
with the child blocks passed to the constructor (because this is largely functionally identical to the
subclass, and avoids embedding a reference to a potentially-volatile custom class in migrations).
This is not the case for ImageBlock, as it overrides enough of StructBlock's behaviour that a basic StructBlock
is not a suitable substitute - and also has an incompatible constructor signature (as we don't want to support
passing child blocks to it).
Therefore, we opt out of the standard StructBlock deconstruction behaviour here, and always
deconstruct an ImageBlock as an ImageBlock.
"""
return ("wagtail.images.blocks.ImageBlock", [], self._constructor_kwargs)
def deconstruct_with_lookup(self, lookup):
return self.deconstruct()
@classmethod
def construct_from_lookup(cls, lookup, *args, **kwargs):
return cls(**kwargs)
def get_searchable_content(self, value):
if not self.search_index or not value:
return []
return self.child_blocks["alt_text"].get_searchable_content(
value.contextual_alt_text
)
def _struct_value_to_image(self, struct_value):
image = struct_value.get("image")
decorative = struct_value.get("decorative")
if image:
# If the image is decorative, set alt_text to an empty string
image.contextual_alt_text = (
"" if decorative else struct_value.get("alt_text")
)
image.decorative = decorative
return image
def _image_to_struct_value(self, image):
return {
"image": image,
"alt_text": image and image.contextual_alt_text,
"decorative": image and image.decorative,
}
def to_python(self, value):
# For backward compatibility with ImageChooserBlock
if isinstance(value, int):
image = self.child_blocks["image"].to_python(value)
struct_value = {"image": image, "decorative": False, "alt_text": None}
else:
struct_value = super().to_python(value)
return self._struct_value_to_image(struct_value)
def bulk_to_python(self, values):
values = list(values)
if not values:
return []
if isinstance(values[0], int):
# `values` is a list of image IDs (as we might encounter if an ImageChooserBlock has been
# changed to an ImageBlock with no data migration)
image_values = self.child_blocks["image"].bulk_to_python(values)
struct_values = [
{
"image": image,
"decorative": False,
"alt_text": None,
}
for image in image_values
]
else:
# assume `values` is a list of dicts containing `image`, `decorative` and `alt_text` keys
# to be handled by the StructBlock superclass
struct_values = super().bulk_to_python(values)
return [
self._struct_value_to_image(struct_value) for struct_value in struct_values
]
def value_from_datadict(self, data, files, prefix):
struct_value = super().value_from_datadict(data, files, prefix)
return self._struct_value_to_image(struct_value)
def clean(self, value):
try:
self.child_blocks["image"].clean(value)
except ValidationError as e:
raise StructBlockValidationError(
block_errors={"image": e},
)
if value and not value.contextual_alt_text and not value.decorative:
raise StructBlockValidationError(
block_errors={
"alt_text": ValidationError(
_(
"Please add some alt text for your image or mark it as decorative"
)
)
}
)
return value
def normalize(self, value):
if value is None or isinstance(value, AbstractImage):
return value
else:
struct_value = super().normalize(value)
return self._struct_value_to_image(struct_value)
def get_form_context(self, value, prefix="", errors=None):
dict_value = {
"image": value,
"alt_text": value and value.contextual_alt_text,
"decorative": value and value.decorative,
}
context = super().get_form_context(dict_value, prefix=prefix, errors=errors)
context["suggested_alt_text"] = value
return context
def get_form_state(self, value):
return {
"image": self.child_blocks["image"].get_form_state(value),
"alt_text": value and value.contextual_alt_text,
"decorative": value and value.decorative,
}
def get_prep_value(self, value):
return {
"image": self.child_blocks["image"].get_prep_value(value),
"alt_text": value and value.contextual_alt_text,
"decorative": value and value.decorative,
}
def extract_references(self, value):
return self.child_blocks["image"].extract_references(value)
def get_comparison_class(self):
return ImageBlockComparison
def get_api_representation(self, value, context=None):
return super().get_api_representation(
self._image_to_struct_value(value), context=context
)
def render_basic(self, value, context=None):
return self.child_blocks["image"].render_basic(value, context=context)
class Meta:
icon = "image"
template = "wagtailimages/widgets/image.html"
class ImageBlockAdapter(StructBlockAdapter):
js_constructor = "wagtail.images.blocks.ImageBlock"
@cached_property
def media(self):
structblock_media = super().media
return forms.Media(
js=structblock_media._js + ["wagtailimages/js/image-block.js"],
css=structblock_media._css,
)
register(ImageBlockAdapter(), ImageBlock)
class ImageBlockComparison(StructBlockComparison):
def __init__(self, block, exists_a, exists_b, val_a, val_b):
super().__init__(
block,
exists_a,
exists_b,
block._image_to_struct_value(val_a),
block._image_to_struct_value(val_b),
)
def htmlvalue(self, val):
if isinstance(val, AbstractImage):
return super().htmlvalue(self.block._image_to_struct_value(val))
else:
return super().htmlvalue(val)

Wyświetl plik

@ -299,6 +299,11 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
objects = ImageQuerySet.as_manager()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.decorative = False
self.contextual_alt_text = None
def _set_file_hash(self):
with self.open_file() as f:
self.file_hash = hash_filelike(f)
@ -372,6 +377,36 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
def __str__(self):
return self.title
def __eq__(self, other):
"""
Customise the definition of equality so that two Image instances referring to the same
image but different contextual alt text or decorative status are considered different.
All other aspects are copied from Django's base `Model` implementation.
"""
if not isinstance(other, models.Model):
return NotImplemented
if self._meta.concrete_model != other._meta.concrete_model:
return False
my_pk = self.pk
if my_pk is None:
return self is other
return (
my_pk == other.pk
and other.contextual_alt_text == self.contextual_alt_text
and other.decorative == self.decorative
)
def __hash__(self):
"""
Match the semantics of the custom equality definition.
"""
if self.pk is None:
raise TypeError("Model instances without primary key value are unhashable")
return hash((self.pk, self.contextual_alt_text, self.decorative))
def get_rect(self):
return Rect(0, 0, self.width, self.height)
@ -626,6 +661,9 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
]
for rendition in Rendition.cache_backend.get_many(cache_keys).values():
filter = filters_by_spec[rendition.filter_spec]
# The retrieved rendition needs to be associated with the current image instance, so that any
# locally-set properties such as contextual_alt_text are respected
rendition.image = self
found[filter] = rendition
# For items not found in the cache, look in the database
@ -1249,7 +1287,16 @@ class AbstractRendition(ImageFileMixin, models.Model):
@property
def alt(self):
return self.image.default_alt_text
# 'decorative' and 'contextual_alt_text' exist only for ImageBlock
if hasattr(self.image, "decorative") and self.image.decorative:
return ""
elif (
hasattr(self.image, "contextual_alt_text")
and self.image.contextual_alt_text
):
return self.image.contextual_alt_text
else:
return self.image.default_alt_text
@property
def attrs(self):

Wyświetl plik

@ -0,0 +1,5 @@
{% load wagtailimages_tags %}
<figure>
{% image value fill-600x338 loading="lazy" %}
</figure>

Wyświetl plik

@ -2,8 +2,15 @@ import unittest.mock
from django.apps import apps
from django.test import TestCase
from django.utils.safestring import SafeString
from wagtail.images.blocks import ImageChooserBlock
from wagtail.admin import compare
from wagtail.blocks.stream_block import StreamValue
from wagtail.blocks.struct_block import StructBlockValidationError
from wagtail.images.blocks import ImageBlock, ImageChooserBlock
from wagtail.telepath import JSContext
from wagtail.test.testapp.models import StreamPage
from wagtail.test.utils.wagtail_tests import WagtailTestUtils
from .utils import (
Image,
@ -73,3 +80,362 @@ class TestImageChooserBlock(TestCase):
# None should not yield any references
self.assertListEqual(list(block.extract_references(None)), [])
class TestImageChooserBlockComparison(TestCase):
comparison_class = compare.StreamFieldComparison
def setUp(self):
self.image_1 = Image.objects.create(
title="Test image 1",
file=get_test_image_file(),
)
self.image_2 = Image.objects.create(
title="Test image 2",
file=get_test_image_file(),
)
self.field = StreamPage._meta.get_field("body")
def test_hasnt_changed(self):
field = StreamPage._meta.get_field("body")
comparison = self.comparison_class(
field,
StreamPage(
body=StreamValue(
field.stream_block,
[
("image", self.image_1, "1"),
],
)
),
StreamPage(
body=StreamValue(
field.stream_block,
[
("image", self.image_1, "1"),
],
)
),
)
self.assertTrue(comparison.is_field)
self.assertFalse(comparison.is_child_relation)
self.assertEqual(comparison.field_label(), "Body")
htmldiff = comparison.htmldiff()
self.assertIsInstance(htmldiff, SafeString)
self.assertIn('class="comparison__child-object"', htmldiff)
self.assertIn('class="preview-image"', htmldiff)
self.assertNotIn("deletion", htmldiff)
self.assertNotIn("addition", htmldiff)
self.assertFalse(comparison.has_changed())
def test_has_changed(self):
field = StreamPage._meta.get_field("body")
comparison = self.comparison_class(
field,
StreamPage(
body=StreamValue(
field.stream_block,
[
("image", self.image_1, "1"),
],
)
),
StreamPage(
body=StreamValue(
field.stream_block,
[
("image", self.image_2, "1"),
],
)
),
)
self.assertTrue(comparison.is_field)
self.assertFalse(comparison.is_child_relation)
self.assertEqual(comparison.field_label(), "Body")
htmldiff = comparison.htmldiff()
self.assertIsInstance(htmldiff, SafeString)
self.assertIn('class="comparison__child-object"', htmldiff)
self.assertIn('class="preview-image deletion"', htmldiff)
self.assertIn('class="preview-image addition"', htmldiff)
self.assertTrue(comparison.has_changed())
class TestImageBlock(TestImageChooserBlock):
def test_render(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": "Sample alt text",
"decorative": False,
}
html = block.render(block.to_python(value))
soup = WagtailTestUtils.get_soup(html)
img_tag = soup.find("img")
# check specific attributes
self.assertEqual(img_tag["alt"], value.get("alt_text"))
self.assertIn("/media/images/test", img_tag["src"])
def test_render_basic(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": "Sample alt text",
"decorative": False,
}
html = block.render_basic(block.to_python(value))
soup = WagtailTestUtils.get_soup(html)
img_tag = soup.find("img")
# check specific attributes
self.assertEqual(img_tag["alt"], value.get("alt_text"))
self.assertIn("/media/images/test", img_tag["src"])
def test_render_as_decorative(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": "Sample alt text",
"decorative": True,
}
html = block.render(block.to_python(value))
soup = WagtailTestUtils.get_soup(html)
img_tag = soup.find("img")
# check specific attributes
self.assertEqual(img_tag["alt"], "")
self.assertIn("/media/images/test", img_tag["src"])
def test_no_alt_text(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": None, # No alt text passed
"decorative": False,
}
# Invalid state when no alt text is given, and image not marked as decorative
# Should raise a StructBlock validation error
with self.assertRaises(StructBlockValidationError) as context:
block.clean(block.to_python(value))
# Check the error message
self.assertIn(
"Please add some alt text for your image or mark it as decorative",
str(context.exception.block_errors["alt_text"]),
)
def test_to_python_with_int(self):
block = ImageBlock()
value = block.to_python(self.image.id)
self.assertEqual(value.id, self.image.id)
self.assertEqual(value.contextual_alt_text, None)
self.assertFalse(value.decorative)
def test_to_python_with_dict(self):
block = ImageBlock()
value = {"image": self.image.id, "alt_text": "Sample text", "decorative": False}
result = block.to_python(value)
self.assertEqual(result.id, self.image.id)
self.assertEqual(result.contextual_alt_text, "Sample text")
self.assertFalse(result.decorative)
def test_get_searchable_content(self):
block = ImageBlock()
value = {
"image": self.image.id, # An id is expected
"alt_text": "Sample alt text",
"decorative": False,
}
result = block.get_searchable_content(block.to_python(value))
# check specific attributes
self.assertEqual(result, ["Sample alt text"])
def test_required_true(self):
block = ImageBlock()
# the inner ImageChooserBlock should appear as required
image_block_def = JSContext().pack(block)
image_chooser_block_def = image_block_def["_args"][1][0]
self.assertTrue(image_chooser_block_def["_args"][2]["required"])
value = block.to_python(
{
"image": None,
"alt_text": "",
"decorative": False,
}
)
with self.assertRaises(StructBlockValidationError) as context:
block.clean(value)
self.assertIn(
"This field is required",
str(context.exception.block_errors["image"]),
)
def test_required_false(self):
block = ImageBlock(required=False)
# the inner ImageChooserBlock should appear as non-required
image_block_def = JSContext().pack(block)
image_chooser_block_def = image_block_def["_args"][1][0]
self.assertFalse(image_chooser_block_def["_args"][2]["required"])
value = block.to_python(
{
"image": None,
"alt_text": "",
"decorative": False,
}
)
self.assertIsNone(block.clean(value))
class TestImageBlockComparison(TestCase):
comparison_class = compare.StreamFieldComparison
def setUp(self):
self.image_1 = Image.objects.create(
title="Test image 1",
file=get_test_image_file(),
)
self.image_2 = Image.objects.create(
title="Test image 2",
file=get_test_image_file(),
)
self.field = StreamPage._meta.get_field("body")
def test_hasnt_changed(self):
field = StreamPage._meta.get_field("body")
page_1 = StreamPage()
page_1.body = [
{
"type": "image_with_alt",
"value": {
"image": self.image_1.id,
"decorative": False,
"alt_text": "Some alt text",
},
"id": "1",
},
]
page_2 = StreamPage()
page_2.body = [
{
"type": "image_with_alt",
"value": {
"image": self.image_1.id,
"decorative": False,
"alt_text": "Some alt text",
},
"id": "1",
},
]
comparison = self.comparison_class(field, page_1, page_2)
self.assertTrue(comparison.is_field)
self.assertFalse(comparison.is_child_relation)
self.assertEqual(comparison.field_label(), "Body")
htmldiff = comparison.htmldiff()
self.assertIsInstance(htmldiff, SafeString)
self.assertIn('class="comparison__child-object"', htmldiff)
self.assertIn('class="preview-image"', htmldiff)
self.assertNotIn("deletion", htmldiff)
self.assertNotIn("addition", htmldiff)
self.assertFalse(comparison.has_changed())
def test_has_changed(self):
field = StreamPage._meta.get_field("body")
page_1 = StreamPage()
page_1.body = [
{
"type": "image_with_alt",
"value": {
"image": self.image_1.id,
"decorative": False,
"alt_text": "Some alt text",
},
"id": "1",
},
]
page_2 = StreamPage()
page_2.body = [
{
"type": "image_with_alt",
"value": {
"image": self.image_2.id,
"decorative": False,
"alt_text": "Some alt text",
},
"id": "1",
},
]
comparison = self.comparison_class(field, page_1, page_2)
self.assertTrue(comparison.is_field)
self.assertFalse(comparison.is_child_relation)
self.assertEqual(comparison.field_label(), "Body")
htmldiff = comparison.htmldiff()
self.assertIsInstance(htmldiff, SafeString)
self.assertIn('class="comparison__child-object"', htmldiff)
self.assertIn('class="preview-image deletion"', htmldiff)
self.assertIn('class="preview-image addition"', htmldiff)
self.assertTrue(comparison.has_changed())
def test_alt_text_has_changed(self):
field = StreamPage._meta.get_field("body")
page_1 = StreamPage()
page_1.body = [
{
"type": "image_with_alt",
"value": {
"image": self.image_1.id,
"decorative": False,
"alt_text": "a cat playing with some string",
},
"id": "1",
},
]
page_2 = StreamPage()
page_2.body = [
{
"type": "image_with_alt",
"value": {
"image": self.image_1.id,
"decorative": False,
"alt_text": "a kitten playing with some string",
},
"id": "1",
},
]
comparison = self.comparison_class(field, page_1, page_2)
self.assertTrue(comparison.is_field)
self.assertFalse(comparison.is_child_relation)
self.assertEqual(comparison.field_label(), "Body")
htmldiff = comparison.htmldiff()
self.assertIsInstance(htmldiff, SafeString)
self.assertIn('class="comparison__child-object"', htmldiff)
self.assertIn(
'<dd>a <span class="deletion">cat</span><span class="addition">kitten</span> playing with some string</dd>',
htmldiff,
)
self.assertTrue(comparison.has_changed())

Wyświetl plik

@ -804,6 +804,9 @@ class TestRenditions(TestCase):
prefetched_rendition = fresh_image.get_rendition("width-500")
self.assertFalse(hasattr(prefetched_rendition, "_mark"))
# Check that the image instance is the same as the retrieved rendition
self.assertIs(new_rendition.image, self.image)
# changing the image file should invalidate the cache
self.image.file = get_test_image_file(colour="green")
self.image.save()

Wyświetl plik

@ -0,0 +1,52 @@
# Generated by Django 5.1.1 on 2024-10-11 20:40
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("tests", "0044_custompreviewsizesmodel_custompreviewsizespage"),
]
operations = [
migrations.AlterField(
model_name="streampage",
name="body",
field=wagtail.fields.StreamField(
[
("text", 0),
("rich_text", 1),
("image", 2),
("product", 3),
("raw_html", 4),
("books", 5),
("title_list", 6),
("image_with_alt", 7),
],
block_lookup={
0: ("wagtail.blocks.CharBlock", (), {}),
1: ("wagtail.blocks.RichTextBlock", (), {}),
2: (
"wagtail.test.testapp.models.ExtendedImageChooserBlock",
(),
{},
),
3: (
"wagtail.blocks.StructBlock",
[[("name", 0), ("price", 0)]],
{},
),
4: ("wagtail.blocks.RawHTMLBlock", (), {}),
5: (
"wagtail.blocks.StreamBlock",
[[("title", 0), ("author", 0)]],
{},
),
6: ("wagtail.blocks.ListBlock", (0,), {}),
7: ("wagtail.images.blocks.ImageBlock", [], {}),
},
),
),
]

Wyświetl plik

@ -65,7 +65,7 @@ from wagtail.documents.blocks import DocumentChooserBlock
from wagtail.documents.models import AbstractDocument, Document
from wagtail.fields import RichTextField, StreamField
from wagtail.images import get_image_model
from wagtail.images.blocks import ImageChooserBlock
from wagtail.images.blocks import ImageBlock, ImageChooserBlock
from wagtail.images.models import AbstractImage, AbstractRendition, Image
from wagtail.models import (
DraftStateMixin,
@ -1646,6 +1646,7 @@ class StreamPage(Page):
"title_list",
ListBlock(CharBlock()),
),
("image_with_alt", ImageBlock()),
],
)

Wyświetl plik

@ -4889,6 +4889,16 @@ class TestPageChooserBlock(TestCase):
self.assertSequenceEqual(pages, expected_pages)
def test_bulk_to_python_distinct_instances(self):
page_ids = [2, 2]
block = blocks.PageChooserBlock()
with self.assertNumQueries(1):
pages = block.bulk_to_python(page_ids)
# Ensure that the two retrieved pages are distinct instances
self.assertIsNot(pages[0], pages[1])
def test_extract_references(self):
block = blocks.PageChooserBlock()
christmas_page = Page.objects.get(slug="christmas")