wagtail-block-fields 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StructField and ListField for Wagtail.
|
|
3
|
+
|
|
4
|
+
API based on how StreamField wraps StreamBlock:
|
|
5
|
+
https://github.com/wagtail/wagtail/blob/main/wagtail/fields.py
|
|
6
|
+
https://github.com/wagtail/wagtail/blob/main/wagtail/blocks/stream_block.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
12
|
+
from django.db import models
|
|
13
|
+
from django.utils.functional import cached_property
|
|
14
|
+
|
|
15
|
+
from wagtail.blocks import Block, BlockField, ListBlock, StructBlock
|
|
16
|
+
from wagtail.blocks.definition_lookup import (
|
|
17
|
+
BlockDefinitionLookup,
|
|
18
|
+
BlockDefinitionLookupBuilder,
|
|
19
|
+
)
|
|
20
|
+
from wagtail.blocks.list_block import ListValue
|
|
21
|
+
from wagtail.blocks.struct_block import StructValue
|
|
22
|
+
from wagtail.fields import Creator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ListBlock(ListBlock):
|
|
26
|
+
"""Fixes missing form data handling for nested blocks."""
|
|
27
|
+
|
|
28
|
+
def value_from_datadict(self, data, files, prefix):
|
|
29
|
+
if self.value_omitted_from_data(data, files, prefix):
|
|
30
|
+
return self.get_default()
|
|
31
|
+
return super().value_from_datadict(data, files, prefix)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _StructBlock(StructBlock):
|
|
35
|
+
"""Fixes missing form data handling for nested blocks."""
|
|
36
|
+
|
|
37
|
+
def value_from_datadict(self, data, files, prefix):
|
|
38
|
+
return self._to_struct_value(
|
|
39
|
+
[
|
|
40
|
+
(
|
|
41
|
+
name,
|
|
42
|
+
(
|
|
43
|
+
block.value_from_datadict(data, files, f"{prefix}-{name}")
|
|
44
|
+
if not block.value_omitted_from_data(data, files, f"{prefix}-{name}")
|
|
45
|
+
else block.get_default()
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
for name, block in self.child_blocks.items()
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BaseBlockField(models.Field):
|
|
54
|
+
def __init__(self, block_lookup=None, **kwargs):
|
|
55
|
+
self.block_lookup = block_lookup
|
|
56
|
+
super().__init__(**kwargs)
|
|
57
|
+
|
|
58
|
+
@cached_property
|
|
59
|
+
def block(self):
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def empty_value(self):
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def _get_block_from_arg(self, block_arg):
|
|
66
|
+
has_block_lookup = self.block_lookup is not None
|
|
67
|
+
if has_block_lookup:
|
|
68
|
+
lookup = BlockDefinitionLookup(self.block_lookup)
|
|
69
|
+
|
|
70
|
+
if isinstance(block_arg, Block):
|
|
71
|
+
return block_arg
|
|
72
|
+
elif isinstance(block_arg, int) and has_block_lookup:
|
|
73
|
+
return lookup.get_block(block_arg)
|
|
74
|
+
elif isinstance(block_arg, type) and issubclass(block_arg, Block):
|
|
75
|
+
return block_arg()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def json_field(self):
|
|
80
|
+
return models.JSONField(encoder=DjangoJSONEncoder)
|
|
81
|
+
|
|
82
|
+
def get_internal_type(self):
|
|
83
|
+
return "JSONField"
|
|
84
|
+
|
|
85
|
+
def get_lookup(self, lookup_name):
|
|
86
|
+
return self.json_field.get_lookup(lookup_name)
|
|
87
|
+
|
|
88
|
+
def get_transform(self, lookup_name):
|
|
89
|
+
return self.json_field.get_transform(lookup_name)
|
|
90
|
+
|
|
91
|
+
def get_db_prep_value(self, value, connection, prepared=False):
|
|
92
|
+
if not prepared:
|
|
93
|
+
value = self.get_prep_value(value)
|
|
94
|
+
return self.json_field.get_db_prep_value(value, connection=connection, prepared=True)
|
|
95
|
+
|
|
96
|
+
def from_db_value(self, value, expression, connection):
|
|
97
|
+
value = self.json_field.from_db_value(value, expression, connection)
|
|
98
|
+
return self.to_python(value)
|
|
99
|
+
|
|
100
|
+
def formfield(self, **kwargs):
|
|
101
|
+
defaults = {"form_class": BlockField, "block": self.block}
|
|
102
|
+
defaults.update(kwargs)
|
|
103
|
+
return super().formfield(**defaults)
|
|
104
|
+
|
|
105
|
+
def value_to_string(self, obj):
|
|
106
|
+
value = self.value_from_object(obj)
|
|
107
|
+
return json.dumps(self.get_prep_value(value), cls=self.json_field.encoder)
|
|
108
|
+
|
|
109
|
+
def get_searchable_content(self, value):
|
|
110
|
+
return self.block.get_searchable_content(value)
|
|
111
|
+
|
|
112
|
+
def extract_references(self, value):
|
|
113
|
+
yield from self.block.extract_references(value)
|
|
114
|
+
|
|
115
|
+
def check(self, **kwargs):
|
|
116
|
+
errors = super().check(**kwargs)
|
|
117
|
+
errors.extend(self.block.check(field=self, **kwargs))
|
|
118
|
+
return errors
|
|
119
|
+
|
|
120
|
+
def contribute_to_class(self, cls, name, **kwargs):
|
|
121
|
+
super().contribute_to_class(cls, name, **kwargs)
|
|
122
|
+
setattr(cls, self.name, Creator(self))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class StructField(BaseBlockField):
|
|
126
|
+
def __init__(self, block_type, block_lookup=None, **kwargs):
|
|
127
|
+
self.block_type_arg = block_type
|
|
128
|
+
super().__init__(block_lookup=block_lookup, **kwargs)
|
|
129
|
+
|
|
130
|
+
@cached_property
|
|
131
|
+
def block(self):
|
|
132
|
+
block = self._get_block_from_arg(self.block_type_arg)
|
|
133
|
+
|
|
134
|
+
if block is None:
|
|
135
|
+
if isinstance(self.block_type_arg, list):
|
|
136
|
+
has_block_lookup = self.block_lookup is not None
|
|
137
|
+
if has_block_lookup:
|
|
138
|
+
lookup = BlockDefinitionLookup(self.block_lookup)
|
|
139
|
+
|
|
140
|
+
child_blocks = []
|
|
141
|
+
for name, child_block in self.block_type_arg:
|
|
142
|
+
if isinstance(child_block, int) and has_block_lookup:
|
|
143
|
+
child_blocks.append((name, lookup.get_block(child_block)))
|
|
144
|
+
else:
|
|
145
|
+
child_blocks.append((name, child_block))
|
|
146
|
+
block = _StructBlock(child_blocks)
|
|
147
|
+
else:
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"StructField requires a StructBlock instance, class, or list of "
|
|
150
|
+
f"(name, block) tuples, got {type(self.block_type_arg).__name__}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if not isinstance(block, StructBlock):
|
|
154
|
+
raise TypeError(f"StructField requires a StructBlock, got {type(block).__name__}")
|
|
155
|
+
|
|
156
|
+
if not isinstance(block, _StructBlock):
|
|
157
|
+
block = _StructBlock(list(block.child_blocks.items()))
|
|
158
|
+
|
|
159
|
+
return block
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def struct_block(self):
|
|
163
|
+
return self.block
|
|
164
|
+
|
|
165
|
+
def empty_value(self):
|
|
166
|
+
return self.block.get_default()
|
|
167
|
+
|
|
168
|
+
def get_default(self):
|
|
169
|
+
return self.block.get_default()
|
|
170
|
+
|
|
171
|
+
def deconstruct(self):
|
|
172
|
+
name, path, _, kwargs = super().deconstruct()
|
|
173
|
+
lookup = BlockDefinitionLookupBuilder()
|
|
174
|
+
block_types = [
|
|
175
|
+
(child_name, lookup.add_block(child_block))
|
|
176
|
+
for child_name, child_block in self.block.child_blocks.items()
|
|
177
|
+
]
|
|
178
|
+
args = [block_types]
|
|
179
|
+
kwargs["block_lookup"] = lookup.get_lookup_as_dict()
|
|
180
|
+
return name, path, args, kwargs
|
|
181
|
+
|
|
182
|
+
def to_python(self, value):
|
|
183
|
+
if value is None:
|
|
184
|
+
return self.block.get_default()
|
|
185
|
+
if isinstance(value, StructValue):
|
|
186
|
+
return value
|
|
187
|
+
if isinstance(value, str) and value:
|
|
188
|
+
try:
|
|
189
|
+
value = json.loads(value)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return self.block.get_default()
|
|
192
|
+
if isinstance(value, dict):
|
|
193
|
+
return self.block.to_python(value)
|
|
194
|
+
return self.block.get_default()
|
|
195
|
+
|
|
196
|
+
def get_prep_value(self, value):
|
|
197
|
+
if value is None:
|
|
198
|
+
return {}
|
|
199
|
+
if isinstance(value, StructValue):
|
|
200
|
+
return self.block.get_prep_value(value)
|
|
201
|
+
if isinstance(value, dict):
|
|
202
|
+
return value
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ListField(BaseBlockField):
|
|
207
|
+
def __init__(self, child_block, block_lookup=None, **kwargs):
|
|
208
|
+
self.block_opts = {}
|
|
209
|
+
for arg in ["min_num", "max_num"]:
|
|
210
|
+
if arg in kwargs:
|
|
211
|
+
self.block_opts[arg] = kwargs.pop(arg)
|
|
212
|
+
|
|
213
|
+
self.child_block_arg = child_block
|
|
214
|
+
super().__init__(block_lookup=block_lookup, **kwargs)
|
|
215
|
+
|
|
216
|
+
@cached_property
|
|
217
|
+
def block(self):
|
|
218
|
+
child_block = self._get_block_from_arg(self.child_block_arg)
|
|
219
|
+
|
|
220
|
+
if child_block is None:
|
|
221
|
+
raise TypeError(
|
|
222
|
+
f"ListField requires a Block instance or class, "
|
|
223
|
+
f"got {type(self.child_block_arg).__name__}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
block = _ListBlock(child_block)
|
|
227
|
+
block.set_meta_options(self.block_opts)
|
|
228
|
+
return block
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def list_block(self):
|
|
232
|
+
return self.block
|
|
233
|
+
|
|
234
|
+
def empty_value(self):
|
|
235
|
+
return self.block.empty_value()
|
|
236
|
+
|
|
237
|
+
def get_default(self):
|
|
238
|
+
return self.block.empty_value()
|
|
239
|
+
|
|
240
|
+
def deconstruct(self):
|
|
241
|
+
name, path, _, kwargs = super().deconstruct()
|
|
242
|
+
lookup = BlockDefinitionLookupBuilder()
|
|
243
|
+
block_id = lookup.add_block(self.block.child_block)
|
|
244
|
+
args = [block_id]
|
|
245
|
+
kwargs["block_lookup"] = lookup.get_lookup_as_dict()
|
|
246
|
+
return name, path, args, kwargs
|
|
247
|
+
|
|
248
|
+
def to_python(self, value):
|
|
249
|
+
if value is None:
|
|
250
|
+
return self.block.empty_value()
|
|
251
|
+
if isinstance(value, ListValue):
|
|
252
|
+
return value
|
|
253
|
+
if isinstance(value, str) and value:
|
|
254
|
+
try:
|
|
255
|
+
value = json.loads(value)
|
|
256
|
+
except ValueError:
|
|
257
|
+
return self.block.empty_value()
|
|
258
|
+
if isinstance(value, list):
|
|
259
|
+
return self.block.to_python(value)
|
|
260
|
+
return self.block.empty_value()
|
|
261
|
+
|
|
262
|
+
def get_prep_value(self, value):
|
|
263
|
+
if value is None:
|
|
264
|
+
return []
|
|
265
|
+
if isinstance(value, ListValue):
|
|
266
|
+
return self.block.get_prep_value(value)
|
|
267
|
+
if isinstance(value, list):
|
|
268
|
+
return self.block.get_prep_value(self.block.normalize(value))
|
|
269
|
+
return []
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wagtail-block-fields
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Allows using StructBlock and ListBlock as model fields
|
|
5
|
+
Project-URL: Repository, https://github.com/joeyjurjens/wagtail-block-fields
|
|
6
|
+
Author-email: Joey Jurjens <joeyjurjens@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: wagtail>=6.3
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# wagtail-block-fields
|
|
14
|
+
|
|
15
|
+
Use Wagtail's `StructBlock` and `ListBlock` as standalone model fields.
|
|
16
|
+
|
|
17
|
+
Just like `StreamField` wraps `StreamBlock`, this package provides:
|
|
18
|
+
- **`StructField`** - wraps a `StructBlock`
|
|
19
|
+
- **`ListField`** - wraps a `ListBlock`
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install wagtail-block-fields
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### StructField
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from wagtail.blocks import CharBlock, StructBlock
|
|
33
|
+
from wagtail_block_fields import StructField
|
|
34
|
+
|
|
35
|
+
class AddressBlock(StructBlock):
|
|
36
|
+
street = CharBlock()
|
|
37
|
+
city = CharBlock()
|
|
38
|
+
postal_code = CharBlock()
|
|
39
|
+
|
|
40
|
+
class MyPage(Page):
|
|
41
|
+
address = StructField(AddressBlock())
|
|
42
|
+
|
|
43
|
+
contact = StructField([
|
|
44
|
+
('email', CharBlock()),
|
|
45
|
+
('phone', CharBlock()),
|
|
46
|
+
])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### ListField
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from wagtail.blocks import CharBlock
|
|
53
|
+
from wagtail_block_fields import ListField
|
|
54
|
+
|
|
55
|
+
class MyPage(Page):
|
|
56
|
+
tags = ListField(CharBlock())
|
|
57
|
+
categories = ListField(CharBlock(), min_num=1, max_num=5)
|
|
58
|
+
addresses = ListField(AddressBlock())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### In templates
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<p>{{ page.address.street }}, {{ page.address.city }}</p>
|
|
65
|
+
|
|
66
|
+
<ul>
|
|
67
|
+
{% for tag in page.tags %}
|
|
68
|
+
<li>{{ tag }}</li>
|
|
69
|
+
{% endfor %}
|
|
70
|
+
</ul>
|
|
71
|
+
|
|
72
|
+
{% for address in page.addresses %}
|
|
73
|
+
<p>{{ address.street }}, {{ address.city }}</p>
|
|
74
|
+
{% endfor %}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Why?
|
|
78
|
+
|
|
79
|
+
Sometimes you need structured JSON data in a single field without the complexity of StreamField. These fields:
|
|
80
|
+
|
|
81
|
+
- Store data as JSON in a single database column
|
|
82
|
+
- Provide full Wagtail admin editing UI
|
|
83
|
+
- Support validation, search indexing, and reference extraction
|
|
84
|
+
- Work with migrations just like StreamField
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
wagtail_block_fields/__init__.py,sha256=vePyqYGTvTyRZRgl8-LMEiV_2UcQNMaOOuAsV1uDsbQ,117
|
|
2
|
+
wagtail_block_fields/fields.py,sha256=pzsktMIEsAU-EtcN9ilAvxZvO6QZvBg-EkC3aKoLpiM,9018
|
|
3
|
+
wagtail_block_fields-0.1.0.dist-info/METADATA,sha256=_17MWiWAZi8jzjWqhnk0TdYMstbMy-GfYjKtgnuRumw,1995
|
|
4
|
+
wagtail_block_fields-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
wagtail_block_fields-0.1.0.dist-info/licenses/LICENSE,sha256=6wvTtd0K1ifRwo0UYlFt5ZvqPfO7ir1rip70grR8oj0,1069
|
|
6
|
+
wagtail_block_fields-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Joey Jurjens
|
|
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.
|