├── .gitignore
├── LICENSE
├── README.md
├── TODO
├── pyproject.toml
├── screenshots
├── content_form.png
├── content_render.png
├── weblinks_form.png
└── weblinks_render.png
└── streamfield
├── __init__.py
├── apps.py
├── blocks
├── __init__.py
├── base.py
├── field_block.py
├── list_block.py
├── static_block.py
├── stream_block.py
├── struct_block.py
└── utils.py
├── form_fields.py
├── forms
└── boundfields.py
├── model_fields.py
├── static
└── streamfield
│ ├── css
│ └── streamfield.css
│ └── js
│ ├── blocks
│ ├── list.js
│ ├── sequence.js
│ ├── stream.js
│ └── struct.js
│ ├── vendor
│ └── jquery.autosize.js
│ └── widgets
│ └── DateTimeInputs.js
├── templates
└── streamfield
│ ├── block_forms
│ ├── field.html
│ ├── list.html
│ ├── list_member.html
│ ├── sequence.html
│ ├── sequence_member.html
│ ├── stream.html
│ ├── stream_member.html
│ ├── stream_menu.html
│ └── struct.html
│ └── widgets
│ └── split_datetime.html
├── templatetags
├── __init__.py
└── streamfield_tags.py
├── tests
├── __init__.py
├── fixture_test_model.py
├── test_blocks.py
└── test_validators.py
├── utils.py
├── validators.py
└── widgets.py
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | dist/
3 | *.bck
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020-present RW Crowther.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | * Neither the name of django-streamfield2 nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # StreamField
2 | A Streamfield for Django. Saves data as JSON to the referenced field. The admin display is designed to operate unobtrusively and be styled as the stock Django Admin.
3 |
4 | The distribution is called 'django-streamfield-w', but internally the module is called 'streamfield'.
5 |
6 | ## Pros
7 | - It's a StreamField
8 | - Written data should be Wagtail-compatible (mostly, no guarantee)
9 |
10 | ## Cons
11 | - No file or image handling (unless you implement from another app)
12 | - Small but scenery-chewing admin
13 |
14 | And an admission: anything that works here has needed code reduction, code expansion, and/or hacks. There are still many possibilities for work within the programmer API. So this codebase will not be solid anytime soon, and lacks tests on code, app, and distribution. Best I can say is that I have outlined the functionality.
15 |
16 | ## Examples
17 | Want to give a user the ability to add blocks of text, interspaced with blockquotes, URL links and dates? Declare this field,
18 |
19 | body = StreamField(
20 | block_types = [
21 | ('text', blocks.TextBlock),
22 | ('blockquote', blocks.BlockQuoteBlock),
23 | ('anchor', blocks.AnchorBlock),
24 | ('date', blocks.DateBlock),
25 | ],
26 | verbose_name="Content"
27 | )
28 |
29 | The form looks like this,
30 |
31 | 
32 |
33 | which renders as,
34 |
35 | 
36 |
37 | Want a list of web-links in the footer of your articles on coding? Declare this field,
38 |
39 | weblinks = DefinitionListField(
40 | definition_block = blocks.RawAnchorBlock,
41 | verbose_name="Web links"
42 | )
43 |
44 | The form looks like this,
45 |
46 | 
47 |
48 | which renders as,
49 |
50 | 
51 |
52 |
53 | ## Alternatives
54 | [Steamfield](https://github.com/raagin/django-streamfield/blob/master/README.md) for Django.
55 |
56 | This app didn't work for me. However, the UI, which uses Django's popup mechanism, is interesting, as is the code. There are substantial and notable differences between this app and the code provided here.
57 |
58 |
59 | ## If you have done this before
60 | - [Install](#install)
61 | - [Add fields](#declaring-fields) to models that will use them
62 | - (as you would usually) Migrate the models
63 |
64 | That's all.
65 |
66 |
67 | ## Install
68 | No dependencies. PyPi,
69 |
70 | pip install django-streamfield-w
71 |
72 | Or download the app code to Django.
73 |
74 | Declare in Django settings,
75 |
76 | INSTALLED_APPS = [
77 | ...
78 | 'image.apps.StreamfieldConfig',
79 | ...
80 | ]
81 |
82 | ## Overall
83 | A streamfield is [declared as a model field](#declaring-fields). That field is, unlike most model fields, a growable value of elements which may be of different types. The data is stored as JSON.
84 |
85 | 'Blocks' is the name for the types of items that can be inserted in a streamfield. Blocks to be made available must be declared in the field.
86 |
87 | Underneath, blocks know how to render themselves as a form item. So the supplied Javascrript will allow a user to extend the value held by the field to any number of sub-values.
88 |
89 | When rendered, all the subvalues will be [templated to the screen](#display)
90 |
91 |
92 | ## Declaring fields
93 | Like this, in 'models.py',
94 |
95 | class Page(models.Model):
96 | ...
97 | stream = StreamField(
98 | block_types = [
99 | ('heading', blocks.CharBlock()),
100 | ('text', blocks.TextBlock()),
101 | ],
102 | verbose_name="Page content"
103 | )
104 |
105 | Block declarations can be classes, or instances with [optional parameters](#block-attributes).
106 |
107 | Like the model classes in Django Admin, if you set 'block_types' empty it will not default to 'all blocks'. Instead, the field will issue a warning that no blocks will be shown in the field. This is a small security measure.
108 |
109 | If you want to declare many similar fields, 'block_types' can be declared in class,
110 |
111 | class MyStreamField(
112 | '''
113 | A StreamField that allows subtitles, images and text, nothing else
114 | '''
115 | block_types = [
116 | ('subtitle', blocks.CharBlock()),
117 | ('image', blocks.ImageBlock()),
118 | ('text', blocks.TextBlock()),
119 | ],
120 | verbose_name="Page content"
121 | )
122 |
123 | #### ListField
124 | You may not want a full StreamField. If you want a list, a StreamField is not what you want to say to a user (a ListBlock inside a Strreamfield means the list is replicable, not it's elements). Use a ListField,
125 |
126 | class Page(models.Model):
127 | ...
128 | stream = ListField(
129 | block_type = blocks.CharBlock(max_length=255),
130 | verbose_name="Page list"
131 | )
132 |
133 |
134 | #### Model-field attributes
135 | Are the same as any model field, 'blank', 'help_text', 'verbose_name', 'validators' etc. Consider that the value on this field is a lump of JSON that is recursively evaluated, and the forms are an embedded heap. Setting 'primary_key' on a StreamField is strange, and 'null' should be avoided.
136 |
137 | #### Side note - get_searchable_content()
138 | Streamfield has a method get_searchable_content(). This is inherited from Wagtail, which has builtin search engine API. While there is nothing in Django that uses the method, it is maybe useful, so I left it in.
139 |
140 |
141 | ## Blocks
142 | 'Blocks' is the name for the items that can be inserted in a streamfield.
143 |
144 | Blocks are tricky concept containing elements of Django model-fields, form-fields, and widgets. But if you are implementing, you only need to know one attribute,
145 |
146 | css_classes
147 | list of strings to be used as classnames in HTML rendering. The value is passed to the context.
148 |
149 | This attribute is a limited replacement for the widget attribute 'attrs'. CSS classes are only printed when the template enables this. Most of the default templates for blocks enable printing, except where there is no HTML at all.
150 |
151 |
152 | ### Field blocks
153 | There blocks represent most of Djangos's form fields. To get you going, here is a declaration,
154 |
155 | class Page(models.Model):
156 |
157 | class Creepy(models.TextChoices):
158 | SPIDER = 'SP','Spider'
159 | ANT = 'AT','Ant'
160 | PYTHON = 'PY','Python'
161 | BAT = 'BT','Bat'
162 | CRICKET = 'CR','Cricket'
163 | MOTH = 'MO','Moth'
164 |
165 | stream = StreamField(
166 | block_types = [
167 | ('chars', blocks.CharBlock(
168 | required=False,
169 | help_text="A block inputting a short length of chars",
170 | max_length=5,
171 | ),
172 | ),
173 | ('subtitle', blocks.HeaderBlock()),
174 | ('subsubtitle', blocks.HeaderBlock(level=4)),
175 | ('quote', blocks.QuoteBlock(
176 | required=False,
177 | ),
178 | ),
179 | ('url', blocks.URLBlock),
180 | ('relurl', blocks.RelURLBlock),
181 | ('email', blocks.EmailBlock(css_classes=['email'])),
182 | ('regex', blocks.RegexBlock(regex='\w+')),
183 | ('text', blocks.TextBlock()),
184 | ('blockquote', blocks.BlockQuoteBlock()),
185 | ('html', blocks.RawHTMLBlock()),
186 | ('bool', blocks.BooleanBlock()),
187 | ('choice', blocks.ChoiceBlock(choices=Creepy.choices)),
188 | ('choices', blocks.MultipleChoiceBlock(choices=Creepy.choices)),
189 | ('integer', blocks.IntegerBlock()),
190 | ('decimal', blocks.DecimalBlock()),
191 | ('float', blocks.FloatBlock()),
192 | ('date', blocks.DateBlock()),
193 | ('time', blocks.TimeBlock()),
194 | ('datetime', blocks.DateTimeBlock(css_classes=['datetime'])),
195 | ('rawanchor', blocks.RawAnchorBlock()),
196 | ('anchor', blocks.AnchorBlock()),
197 | ],
198 | verbose_name="Page blocks"
199 | )
200 | ...
201 |
202 | As you can see from the declarations, blocks can be declared as classes (with default parameters), or instances (with optional parameter details).
203 |
204 | You may note the lack of a FileBlock/FileUploadBlock. This would give embedded images, so is a serious loss. However, it difficult to make an upload block, as Django spreads the functionality across model processing, and there is no model instance or field to work with (StreamField data is a lump of JSON). If your images/galleries etc. are tracked in the database, make a custom ModelChoceBlock. That's the Wagtail solution.
205 |
206 | Notes on some of the blocks,
207 |
208 | ChoiceBlock and MultipleChoiceBlock
209 | To make them work, give them choices. The blocks will handle the types and their storage. These blocks cut back on Wagtail/Django provision, they need static declarations, will not accept callables, etc.
210 |
211 | RelURLBlock
212 | Accepts relative URLs and fragments, unlike Django's UELField (the field is also a bit stricter about what gets in)
213 |
214 | ModelChoiceBlockBase
215 | A base to build on. See below.
216 |
217 | RawAnchorBlock
218 | An HTML anchor where the text is a repetition of the href attribute.
219 |
220 | AnchorBlock
221 | A dual input block built with StructBlock that generates... an HTML anchor.
222 |
223 | You may note that the value applied to the templates is the value from the database. This may not be always what you want, especially with enumerables. You can break up the stream and address the properties individually, or construct template tags etc.
224 |
225 | #### Block attributes
226 | The field Blocks (not the collection blocks) wrap Django form fields Some standard attributes from the form fields are exposed,
227 |
228 | required
229 | Works, but in a slightly different way to a form field. If True (the default) the block inpput can not be empty, if the block is summoned by the user..
230 | widget
231 | Set a different widget
232 | #initial
233 | help_text
234 | Set a help text on the block
235 | validators
236 | Add validators to the block
237 | localize
238 | Localise block displays
239 |
240 | and this widget-like attribute,
241 |
242 | placeholder
243 | set the placeholder on an input
244 |
245 | Unlike Wagtail, but like Django, all blocks are required unless declared otherwise.
246 |
247 | Other form-field parameters such as 'label_suffix', 'initial', 'error_messages', 'disabled', are either fixed or not useful for blocks.
248 |
249 | Some blocks have extra attributes. Usually these are similar to form field attributes, but you need to go to source to check.
250 |
251 |
252 | #### ModelChoiceBlocks
253 | The ModelChoiceBlocks are ModelChoiceBlock and ModelMultipleChoiceBlock, both building from ModelChoiceBlockBase (hope you are okay with that). They offer the possibility of building selections from any Django model, which opens many possibilities.
254 |
255 | The fields accept the common parameters, and these,
256 |
257 | target_model
258 | The model to do queries on
259 | target_filter
260 | A query to limit offered options. The query is organised as a dict e.g. {'headline__contains':'Lennon'}
261 | to_field_name
262 | The field to use to supply label names.
263 | empty_label
264 | An alternative to the default empty label (which is '--------')
265 |
266 | The querybuilding is unlike the usual Django or Wagtail provision.
267 |
268 |
269 |
270 | #### Widgets
271 | The widgets used as default are a strange collection. To give you some idea,
272 |
273 | Charboxes (URL, Email, Regex)
274 | Are Django Admin (which are a light restyle for size)
275 | Textareas (inc. RawHTMLArea and Blockquote)
276 | Home-brew auto-resize (an idea from Wagtail, re-implemented here)
277 | Time/Date inputs
278 | Home-brew TimeDate handler
279 |
280 | A word about the temporal widgets: the Javascript may crash on asserting a locale. Either use stock Django widgets,
281 |
282 | from django.forms.widgets import DateTime
283 |
284 | block_types = [
285 | ...
286 | ('subtitle', blocks.DateBlock(widget=DateTime)),
287 | ]
288 |
289 | or explicitly state formats (see below).
290 |
291 | Like other Django widgets, if you like AutoHeightTextWidget or the temporal widgets you can use them elsewhere.
292 |
293 |
294 | ##### The temporal widgets
295 | Django's temporal input form-handling is a tiring mass of code. Formats do not fall through model-fields, to form-fields, to widgets. There are catch-sensible-input defaults in the decode process. Decode and encode happen in different places in the codebase, handled by different code resources. And declared formats can be subverted by locale handling.
296 |
297 | If you want to do interesting things with temporal inputs, you will need to set the form field so the format will be accepted. Then you also need to set the format on the widget. Nothing to do with this app or Wagtail, it's a Django thang,
298 |
299 | class SpaceyDateBlock(blocks.DateBlock):
300 | widget = MiniDateWidget(format='%d / %m / %Y')
301 |
302 | Then set on the field,
303 |
304 | block_types = [
305 | ...
306 | ('subtitle', SpaceyDateBlock(input_formats=['%d / %m / %Y'])),
307 | ]
308 |
309 |
310 |
311 | ### Collection blocks
312 | These blocks represent collections of other blocks,
313 |
314 | ListBlock
315 | An unfixed collection of similar blocks
316 |
317 | StreamBlock
318 | An unfixed collection of disimilar blocks (block choice offered to user)
319 |
320 | StructBlock
321 | A fixed collection of disimilar blocks
322 |
323 | #### ListBlock
324 | An unlinited list of a single block type,
325 |
326 | class Page(models.Model):
327 | stream = StreamField(
328 | block_types = [
329 | ('list', blocks.ListBlock( blocks.CharBlock )),
330 | ],
331 | verbose_name="Page blocks"
332 | )
333 | ...
334 |
335 | Now that is interesting. To represent a list using database records needs a specialised table with foreign key links. Though in many cases you may want the modelfield wrap of this block, a [ListField](#ListField)
336 |
337 |
338 | ##### DefinitionListField
339 | In line with basic HTML provision, there is also a specialist DefinitionListField.
340 |
341 | It accepts common attributes plus,
342 |
343 | term_block_type
344 | definition_block_type
345 |
346 | #### StructBlock
347 | A fixed collection of different block types. It can be declared in-place,
348 |
349 | class Page(models.Model):
350 | stream = StreamField(
351 | block_types = [
352 | ('link', blocks.StructBlock([
353 | ('url', blocks.URLBlock()),
354 | ('title', blocks.CharBlock()),
355 | ]),
356 | ),
357 | ],
358 | verbose_name="Page blocks"
359 | )
360 | ...
361 |
362 | This will do as you hope, produce a one-click field of several blocks at once. However, inline StructBlocks are confusing to write and read, and the agility of StructBlocks is maybe better demonstrated by subclassing and applying declarations,
363 |
364 | class Licence(models.TextChoices):
365 | NO_RIGHTS = 'CCO', 'Creative Commons ("No Rights Reserved")'
366 | CREDIT = 'CC_BY', 'Creative Commons (credit)'
367 | CREDIT_NC = 'CC_BY-NC-ND', 'Creative Commons (credit, non-commercial, no adaption)'
368 | NON_EXCLUSIVE = 'NE','Non-exclusive Rights available'
369 | EXCLUSIVE = 'EX','Exclusive rights available'
370 |
371 |
372 | class QuoteBlock(blocks.StructBlock):
373 | quote = blocks.BlockQuoteBlock
374 | author = blocks.CharBlock()
375 | date = blocks.DateBlock()
376 | licence = blocks.ChoiceBlock(choices=Licence.choices)
377 |
378 |
379 | class Page(models.Model):
380 | stream = StreamField(
381 | block_types = [
382 | ('text', blocks.TextBlock()),
383 | ('quote', QuoteBlock()),
384 | ],
385 | verbose_name="Page block"
386 | )
387 |
388 | This is a big StreamField win. A user can write text, then insert this QuoteBlock anywhere they like, Which database solutions can not do. And RichText i.e. text markup, can not structure the input.
389 |
390 |
391 | #### StreamBlock
392 | StreamBlock is an unlimited list of different block types. It is the base block of a Streamfield. It can also be embedded in a StreamField.
393 |
394 | Attributes are the common set plus,
395 |
396 | max_num
397 | min_num
398 |
399 | The reason you would embed a StreamBlock is to establish a different context for the user to work in, for example, creating an index. Think before you do this. It will be difficult coding, and hard for a user to understand. Also, it will encourage blobs of data in the database, which is the argument against this kind of Streamfield. Can the context be regarded as a fixed entity? In which case, maybe it should be on a separate database field or table. That said, it can be done.
400 |
401 |
402 |
403 | ### Custom blocks
404 | Not as difficult as promised. If you already have a form field you wish to use, you can wrap it, see 'form_field.py' for details. There is usually a form field that will handle data in the way you want. Most of the time, you will want a different widget or to change rendering. Well, 'widget' has been broken out as an attribute, and can be passed as an instance or class. For a one-off change, in 'models.py',
405 |
406 | body = StreamField(
407 | block_types = [
408 | ('float', blocks.FloatBlock(widget=MySuperFloatWidget(point_limit=3))),
409 | ]
410 |
411 | Or if you plan to use the new widget field a few times,
412 |
413 | class GroovyFloatBlock(FloatBlock):
414 | widget = MySuperFloatWidget
415 |
416 | To change rendering, this is the code from QuoteBlock,
417 |
418 | class QuoteBlock(CharBlock):
419 |
420 | def render_basic(self, value, context=None):
421 | if value:
422 | return format_html('{0}', value)
423 | else:
424 | return '
425 |
426 | To initialise Javascript, there is a widget mixin called 'WithScript'. See the next section.
427 |
428 |
429 | #### Issues with custom blocks
430 | Streamfield adds form elements dynamically to the form. It will pick up and add Media resources attached to a widget. If you have a plain widget, this is all you need. But if you want a fancy widget, you need to think about the Javascript.
431 |
432 | Javascript that supports widgets used in streamfields is tricky. Javascript designed to run on page load will not trigger. And the script may not have a non-page-load initialisation. Finally, there may be issues with JS dependency resolution. Django provides some help, but you need to know about it.
433 |
434 | The problems start here. Many, many Javascripts auto-run on page initialisation. You can hope that this is disabled or ineffective. But if not, or the script produces side effects in the form, you will need to modify the script, much as you may dislike the idea (I would).
435 |
436 | Then you must hope the author built the script with a hook for initialisation. Again, if such a breakout of functionality is missing. you will need to modify.
437 |
438 | Then you need to initialise. First, ignore the method in blocks called jsinitialiser(). This may seem promising, but it is for setup of block code on page load. The Wagtail solution is to add a script to the block template (the template is written into the page). This will run whenever a block is loaded into the main form. In a typically cute piece of code, Wagtail have a mixin to do this, called WidgetWithScript. WidgetWithScript is ported to this app as 'streamfield.widgets.WithScript'. All the field block widgets use it.
439 |
440 | If initialisation is not targeted and precise, there can be unwanted effects. For example, outside of Streamfields, a few people have wished to reuse Django Admin widgets. Well, if the widget initialisation runs on page load, then is initialised dynamically later, forms can display with, for example, multiple clocks and calenders attached to temporal inputs. And there is no way to namespace this behaviour away.
441 |
442 | Finally, you need to consider Javascript imports. Django does a somewhat unpubicised dependency resolution on JS resources. Mostly this is great, removing duplicates and ensuring, for example, that JQuery scripts run after JQuery is loaded. But, in such a case, you must declare a JQuery dependency in the widget, or the resource may be sorted before the JQuery library. And don't try a simple rename on JS resources, in the hope this will namespace the code. The dependency resolution will not be able to see the code rename, so will write the code in twice, and then you are in trouble.
443 |
444 | All in all, to add Javascripted widgets to a streamfield block,
445 | - Preferably find Javascript that does nothing on pageload, or disable it. Especially avoid Javascript with general searches. You need a function ''do this to element with 'id' XXX'
446 | - Add the widget to a new FieldBlock, using the broken-out definitions above
447 | - Use streamfield.widgets.WithWidget to initialise
448 | - On the Media class of the widget, declare dependencies for the Javascript, including external dependencies like JQuery
449 | - Besides dependencies, beware how the Javascript loads into namespaces. If it is loaded on ''window', will it clash/ produce side-effects?
450 |
451 | This app has default widgets that seemed to suit the overall theme. but the above points were also a consideration.
452 |
453 |
454 | ## Display
455 | Mostly, you only need to print the value,
456 |
457 | {{ page.content }}
458 |
459 | This will template each block used with the default templating.
460 |
461 |
462 | ### The template tag
463 | There is o template tag which allows you to control/supplement the supplied context.
464 |
465 | {% load streamfield_tags %}
466 |
467 | ...
468 |
469 | ...
470 | {% streamfield page.content with class="my-block-name" %}
471 | ...
472 |
473 |
474 | #### More on rendering
475 | A value can be iterated, then the tag called (in recursive fashion) on elements,
476 |
477 | {% for block in page.content %}
478 |
{% streamfield block %}
479 | {% endfor %}
480 |
481 | And the elements can be controlled by their 'block_type' and 'value' directly,
482 |
483 | {% for block in page.content %}
484 | {% if block.block_type == 'heading' %}
485 |
{{ block.value }}
486 | {% else %}
487 |
488 | {% streamfield block %}
489 |
490 | {% endif %}
491 | {% endfor %}
492 |
493 | I would not be keen on programming which individually targets elements, because what happens if you later introduce collections into the field? But the possibility is there.
494 |
495 |
496 | ## Templating and HTML
497 | ### Overview
498 | It's difficult to produce generic HTML for StreamFields, because the potential uses are many. If you approach the app with specific needs, you should be expecting to override or produce your own blocks to adjust rendering.
499 |
500 | The default rendering is set to be slim and somewhat aimed at supporting HTML typography. For example, text fields render in paragraph tags, numbers have no markup at all.
501 |
502 | ## Migration
503 |
504 | ## Notes
505 | ### What is a streamfield?
506 | When people talk about text, they usually mean more than that. They usually mean a descending block that includes text, images, maybe titles, and various other structures such as lists and indexes. The 'content'.
507 |
508 | How to represent this in a structured way has always been an issue, especially for databases. Some very, very structured text can be broken into database fields. Think of a product review, which will have a title, photograph, review and conclusion. Indeed, reviews are often used as an example.
509 |
510 | But much text is not rigidly structured. An article for a newspaper may have images inserted, depending on what the staff managed to photograph. A book may have occasional illustrations. An author of technical books may wish to insert tables or checklists. The rigid structures of a database can not model these cases.
511 |
512 | The answer would seem to be obvious, and has been used for years. Various text markup languages, from LaTex to HTML are designed to define structures which can be organised in a freeform way. Why not store these as a blob of text in a 'content' or 'body' database field?
513 |
514 | Unfortunately, this fails both the end-user and the computer. The end user knows the structures they want. They know they want a blockquote, even if they don't know the name for it. But they have no time or life to learn markup. So they try to reproduce what they see using indents, or worse, tabs. Coders try to get round this using complex WYSIWYG editors but, marvels of coding though they are, the structure is still not reaching the user. It's known fact that users hate those editors. They never "do what I want".
515 |
516 | As for computers, the data is stored in an unstructured blob in a hard-to-parse text format. And if the user has faked text structure, there is no record of their intention. And introducing the possibility of markup to end-users opens up a large security hole.
517 |
518 | A streamfield stores the structures of the text not as text markup, but in a computer format. There are at least two ways of storing the structures. The first is to keep all structures in dedicated tables, for example a 'blockquote' table, and keep track of the order a user asked for them to be organised. The other way is to write down a blob of data to a field, but in a format computers can get to grips with, like JSON.
519 |
520 | Either way, a computer format benefits both user and computer. The user gets a stream of forms that "do what they want". If they don't find a form, at least that is clear. And the computer gets the kind of data they like, structured lists of encoded data.
521 |
522 | The main difficulty is that the structures are a massive challenge to web-coders. They need to be parsed to produce a screen representation. And making forms for all the structures needed is a trial of endurance. Not to mention that dynamically creating web-forms is a Javascript/HTML battleground. But people are trying.
523 |
524 |
525 | ## The difference between Django-streamfield and Wagtail CMS implementations
526 | Django-streamfield stores block data in dedicated database tables. This is good for reusing existing components. It can be good for maintenance because it single-sources maintenance operations such as migration, data dumps, and transfer/salvage.
527 |
528 | Wagtail CMS stores block data as JSON, in text fields. This represents the structure intended by the coder. JSON is a robust, maintainable and human-readable format. But this approach creates much work on UI widgets for modification and display of data.
529 |
530 | So one represents a clean, easily leveraged solution. The other represents what may become an industry-standard solution, at a cost of code size and complexity.
531 |
532 |
533 |
534 | ### Why not use Wagtail?
535 | Maybe you should. But Wagtail introduces code you may not want or find limiting, such as preset Page types, modified middleware and admin, new configuration methods, stacks of resulting preset URLs, and plenty more.
536 |
537 |
538 | ### Why didn't you include the RichText code?
539 | Wagtail has a well-worked Richtext implementation. But, in short, this app has some integrity to defend. It's a Streamfield implementation, not a "useful stuff nicked from Wagtail" app.
540 |
541 |
542 | ### History/Credits
543 | This was originally intended to be a port of Wagtail's StreamField. However, that code is closely tied to Wagtail's custom Admin, so was abandoned. I looked at [Steamfield](https://github.com/raagin/django-streamfield/blob/master/README.md), but that code is tied to it's data representation. I reverted to porting the Wagtail code. Because [this](https://pypi.org/project/wagtail-react-streamfield/) exists, this code sooner or later becomes a maintained branch.
544 |
545 | The base Javascript and field handling remains as in Wagtail. There are tricks to make this work in Django, and most Wagtail-specific functionality is removed. The programmer API is reworked and extended, and not like Wagtail.
546 |
547 | Throughout, I was trying not to learn anything about StreamField implementations. I only partially succeeded.
548 |
--------------------------------------------------------------------------------
/TODO:
--------------------------------------------------------------------------------
1 |
2 | Response to request with pk f3e19cd5-b19e-4a1b-a4e7-30694e30816d has content type text/javascript but was unable to parse it
3 |
4 | ## Widgets
5 | x(but logical?) Cursor movement jumps when fly-validatitng temporal
6 | x(not possible) Sideways expanding charfields?
7 |
8 | ## CSS
9 |
10 | ## Field
11 | x Cant get rid of toplevel error class because written in in Admin (colours all the blocks, not the relevat ones)
12 |
13 | ## blocks
14 | Not tried groups. This seems to be Wagtail UI affair, and can probably be deleted.
15 | TitleBlock?
16 | CodeBlock?
17 | x FooterBlock?
18 | x HeaderBlock?
19 | 'required' not getting into template
20 | Test ModelChoices
21 | Put 'default' someplace else? It's only used on Choosers?
22 | 'default' is the last relic of Wgatils 'pile the attributes on the Media object' approach. Without it, the code to load attributes on Media can be removed.
23 | Can we 'init'?
24 | 'label' is unused, except for errors in StaticBlock. Can we do label mods, or does this disrupt the Javascript?
25 |
26 | File uploader again?
27 | List should have max/min
28 | Collection Values are inconsistently implemented, holding bound blocks sometimes.
29 | Collection blocks would recursively render better if Block values cartried associated fields, not just the collection block
30 |
31 | ## Fields
32 | Needs a lot of tidying
33 | (retain) get_searchable_content
34 | x(no) Remove pre-save
35 | What are dynamic templates for? If that is over-configuration, remove them
36 |
37 |
38 | ## Style
39 |
40 | ## Test
41 | Oh dear
42 |
43 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit_core >=2,<4"]
3 | build-backend = "flit_core.buildapi"
4 |
5 | [tool.flit.metadata]
6 | module = "streamfield"
7 | # version = 0.0.1
8 | dist-name = "django-streamfield-w"
9 | author = "robert crowther"
10 | author-email = "rw.crowther@gmail.com"
11 | home-page = "https://github.com/rcrowther/django-streamfield-w"
12 | description-file = "README.md"
13 | classifiers=[
14 | "Operating System :: OS Independent",
15 | "License :: OSI Approved :: BSD License",
16 | "Programming Language :: Python :: 3",
17 | "Framework :: Django"
18 | ]
19 |
--------------------------------------------------------------------------------
/screenshots/content_form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rcrowther/django-streamfield-w/9dcdaf2e9a566aebb3957904974f5c7df4be2308/screenshots/content_form.png
--------------------------------------------------------------------------------
/screenshots/content_render.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rcrowther/django-streamfield-w/9dcdaf2e9a566aebb3957904974f5c7df4be2308/screenshots/content_render.png
--------------------------------------------------------------------------------
/screenshots/weblinks_form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rcrowther/django-streamfield-w/9dcdaf2e9a566aebb3957904974f5c7df4be2308/screenshots/weblinks_form.png
--------------------------------------------------------------------------------
/screenshots/weblinks_render.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rcrowther/django-streamfield-w/9dcdaf2e9a566aebb3957904974f5c7df4be2308/screenshots/weblinks_render.png
--------------------------------------------------------------------------------
/streamfield/__init__.py:
--------------------------------------------------------------------------------
1 | """A extendable model field stored as JSON and built from blocks"""
2 | __version__ = '0.2.1'
3 |
4 | from streamfield.model_fields import StreamField, ListField, DefinitionListField
5 |
--------------------------------------------------------------------------------
/streamfield/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 | class StreamfieldConfig(AppConfig):
4 | name = 'streamfield'
--------------------------------------------------------------------------------
/streamfield/blocks/__init__.py:
--------------------------------------------------------------------------------
1 | # Import block types defined in submodules into the stream.blocks namespace
2 | from .base import * # NOQA
3 | from .field_block import * # NOQA
4 | from .struct_block import * # NOQA
5 | from .list_block import * # NOQA
6 | from .stream_block import * # NOQA
7 | from .static_block import * # NOQA
8 |
--------------------------------------------------------------------------------
/streamfield/blocks/field_block.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import copy
3 | from django import forms
4 | from django.contrib import admin
5 | from django.db.models.fields import BLANK_CHOICE_DASH
6 | from django.forms.fields import CallableChoiceIterator
7 | from django.template.loader import render_to_string
8 | from django.utils.dateparse import parse_date, parse_datetime, parse_time
9 | from django.utils.encoding import force_str
10 | from django.utils.functional import cached_property
11 | from django.utils.html import format_html, format_html_join
12 | from django.utils.safestring import mark_safe
13 | from django.forms.widgets import Media, FileInput
14 | from streamfield import widgets
15 | from streamfield import utils
16 | from streamfield.blocks.base import Block
17 | from streamfield.form_fields import RelURLField
18 | from streamfield.widgets import (
19 | AutoHeightTextWidget,
20 | MiniTimeWidget,
21 | MiniDateWidget,
22 | MiniDateTimeWidget,
23 | )
24 |
25 |
26 |
27 | class FieldBlock(Block):
28 | '''
29 | A block that wraps a Django form field.
30 | Can be used by itself, but mainly used as a base for creating blocks
31 | from most of the standard Django model fields.
32 |
33 | self.field
34 | Undeclared internal. A Django form field
35 |
36 | Some standard attributes from the form fields are exposed. Other
37 | paremeters such as 'label_suffix', 'initial', 'error_messages',
38 | 'disabled', are fixed or not useful for blocks.
39 |
40 | required
41 | Works, but in a slightly different way to the usual attribute. If True (the default) the block inpput can not be empty, if the block is requested.
42 | widget
43 | As usual, sets a differnt widget
44 | placeholder
45 | set the placeholder on an input
46 | #initial
47 | help_text
48 | Set a help tect on the block
49 | validators
50 | Add validators to the block
51 | localize
52 | Will localise block displays
53 |
54 | Note that all blocks are required unless stated otherwise.
55 | '''
56 | #NB For implementers:
57 | # self.field is unallocated. You need to,
58 | # make __init__, call super()_
59 | # not only for block base atrributes, but to ensure the widget
60 | # attribute is resolved and if not None, is a new instance
61 | # then use __init__ to create a field
62 | # the field should have 'widget': self.widget parameter, or the
63 | # widget preference code will not operate
64 | widget = None
65 |
66 | #def __init__(self, widget=None, **kwargs):
67 | # 'reuired' is of less use, saying a block must be filled if deployed.
68 | # 'widget' is widget and can be set on the class, too.
69 | # 'label' and 'initial' parameters are not exposed, as Block handles
70 | # that functionality natively (via Media 'label' and 'default')
71 | #x default to initial? No, initial is for placeholders. Default is
72 | # on the model, if formfield is blank
73 | def __init__(self,
74 | required=True,
75 | widget=None,
76 | placeholder='',
77 | #initial=None,
78 | help_text=None,
79 | validators=(),
80 | localize=False,
81 | **kwargs
82 | ):
83 | #NB kwargs that reach block initialisation are placed on Meta.
84 | super().__init__(**kwargs)
85 | #if (not self.field):
86 | # raise AttributeError("'field' is not declared. class:{}".format(self.__class__.__name__) )
87 | widget = widget or self.widget
88 | if (widget):
89 | if isinstance(widget, type):
90 | widget = widget()
91 | else:
92 | widget = copy.deepcopy(widget)
93 | self.placeholder = placeholder
94 | self.field_options = {
95 | 'required': required,
96 | 'widget': widget,
97 | #'initial': initial,
98 | 'help_text': help_text,
99 | 'validators': validators,
100 | 'localize': False,
101 | }
102 |
103 | def id_for_label(self, prefix):
104 | return self.field.widget.id_for_label(prefix)
105 |
106 | def render_form(self, value, prefix='', errors=None):
107 | field = self.field
108 | widget = field.widget
109 | widget_attrs = {'id': prefix, 'placeholder': self.placeholder}
110 | field_value = field.prepare_value(self.value_for_form(value))
111 | widget_html = widget.render(prefix, field_value, attrs=widget_attrs)
112 | #print('render_form')
113 | #print(str(utils.default_to_underscore(widget.__class__.__name__, 'oqqq')))
114 | # Render the form fragment, with errors and help text
115 | return render_to_string('streamfield/block_forms/field.html', {
116 | 'name': self.name,
117 | 'widget': widget_html,
118 | 'field': field,
119 | 'fieldtype': utils.default_to_underscore(field.__class__.__name__),
120 | 'widgettype': utils.default_to_underscore(widget.__class__.__name__),
121 | 'errors': errors
122 | })
123 |
124 | def render_basic(self, value, context=None):
125 | if value:
126 | return format_html('{0}', value)
127 | else:
128 | return
129 |
130 | def value_from_form(self, value):
131 | """
132 | Convert a form field's value to one that can be rendered by
133 | this block.
134 |
135 | The value that we get back from the form field might not be the
136 | type that this block works with natively; for example, the block
137 | may want to wrap a simple value such as a string in an object
138 | that provides a fancy HTML rendering (e.g. EmbedBlock).
139 |
140 | It transforms data from the datadict, and the return from
141 | clean().
142 |
143 | We therefore provide this method to perform any necessary
144 | conversion from the form field value to the block's native
145 | value. As standard, this returns the form field value unchanged.
146 | """
147 | return value
148 |
149 | def value_for_form(self, value):
150 | """
151 | Convert this block's native value to one that can be rendered by
152 | the form field
153 | Reverse of value_from_form; Used to set up clean() and in render()
154 | """
155 | return value
156 |
157 | def value_from_datadict(self, data, files, prefix):
158 | return self.value_from_form(self.field.widget.value_from_datadict(data, files, prefix))
159 |
160 | def value_omitted_from_data(self, data, files, prefix):
161 | return self.field.widget.value_omitted_from_data(data, files, prefix)
162 |
163 | def clean(self, value):
164 | # We need an annoying value_for_form -> value_from_form round trip here to account for
165 | # the possibility that the form field is set up to validate a different value type to
166 | # the one this block works with natively
167 | return self.value_from_form(self.field.clean(self.value_for_form(value)))
168 |
169 | @property
170 | def media(self):
171 | return self.field.widget.media
172 |
173 | @property
174 | def required(self):
175 | # a FieldBlock is required if and only if its underlying form field is required
176 | return self.field.required
177 |
178 | class Meta:
179 | # Default value can be None, as a leaf block usually is
180 | # a *something* value
181 | default = None
182 |
183 |
184 |
185 | class CharBlock(FieldBlock):
186 |
187 | def __init__(self,
188 | max_length=None,
189 | min_length=None,
190 | **kwargs
191 | ):
192 | super().__init__(**kwargs)
193 | self.field_options.update({
194 | 'max_length':max_length,
195 | 'min_length':min_length,
196 | })
197 | self.field = forms.CharField(
198 | **self.field_options
199 | )
200 |
201 | def render_basic(self, value, context=None):
202 | if value:
203 | return format_html('{0}',
204 | value,
205 | self.render_css_classes(context)
206 | )
207 | else:
208 | return
209 |
210 | def get_searchable_content(self, value):
211 | return [force_str(value)]
212 |
213 |
214 |
215 | class HeaderBlock(CharBlock):
216 | '''
217 | level
218 | level to set the HTML heading (
',
424 | value,
425 | self.render_css_classes(context)
426 | )
427 | else:
428 | return ''
429 |
430 |
431 |
432 | class RawHTMLBlock(TextBlock):
433 | # make a default HTML box start a little larger
434 | widget = AutoHeightTextWidget(attrs={'rows': 7})
435 |
436 | def get_default(self):
437 | return mark_safe(self.meta.default or '')
438 |
439 | def to_python(self, value):
440 | return mark_safe(value)
441 |
442 | def get_prep_value(self, value):
443 | # explicitly convert to a plain string, just in case we're using some serialisation method
444 | # that doesn't cope with SafeString values correctly
445 | return str(value) + ''
446 |
447 | def value_for_form(self, value):
448 | # need to explicitly mark as unsafe, or it'll output unescaped HTML in the textarea
449 | return str(value) + ''
450 |
451 | def value_from_form(self, value):
452 | return mark_safe(value)
453 |
454 | def render_basic(self, value, context=None):
455 | if value:
456 | return format_html('
{0}
',
457 | value,
458 | self.render_css_classes(context)
459 | )
460 | else:
461 | return
462 |
463 |
464 |
465 | class BooleanBlock(FieldBlock):
466 |
467 | def __init__(self, **kwargs):
468 | # NOTE: As with forms.BooleanField, the default of required=True means that the checkbox
469 | # must be ticked to pass validation (i.e. it's equivalent to an "I agree to the terms and
470 | # conditions" box). To get the conventional yes/no behaviour, you must explicitly pass
471 | # required=False.
472 | super().__init__(**kwargs)
473 | self.field = forms.BooleanField(
474 | **self.field_options
475 | )
476 |
477 |
478 |
479 | class IntegerBlock(FieldBlock):
480 | widget = admin.widgets.AdminIntegerFieldWidget
481 |
482 | def __init__(self,
483 | min_value=None,
484 | max_value=None,
485 | **kwargs
486 | ):
487 | super().__init__(**kwargs)
488 | self.field_options.update({
489 | 'min_value':min_value,
490 | 'max_value':max_value,
491 | })
492 | self.field = forms.IntegerField(
493 | **self.field_options
494 | )
495 |
496 |
497 |
498 | class DecimalBlock(FieldBlock):
499 | widget = admin.widgets.AdminBigIntegerFieldWidget
500 |
501 | def __init__(self,
502 | max_value=None,
503 | min_value=None,
504 | max_digits=None,
505 | decimal_places=None,
506 | **kwargs
507 | ):
508 | super().__init__(**kwargs)
509 | self.field_options.update({
510 | 'min_value':min_value,
511 | 'max_value':max_value,
512 | 'max_digits':max_digits,
513 | 'decimal_places':decimal_places,
514 | })
515 | self.field = forms.DecimalField(
516 | **self.field_options
517 | )
518 |
519 |
520 | class FloatBlock(FieldBlock):
521 |
522 | def __init__(self,
523 | max_value=None,
524 | min_value=None,
525 | **kwargs
526 | ):
527 | super().__init__(**kwargs)
528 | self.field_options.update({
529 | 'min_value':min_value,
530 | 'max_value':max_value,
531 | })
532 | self.field = forms.FloatField(
533 | **self.field_options
534 | )
535 |
536 | # import posixpath
537 |
538 | # from django.core.files import File
539 | # from django.db.models.fields.files import FileField
540 |
541 | # class FieldFileDumb(File):
542 | # def __init__(self, name, upload_to='', max_length=255, storage=None):
543 | # #def __init__(self, instance, field, name):
544 | # # Django file takes two params, the file and an optional name.
545 | # # but it will fuction if the file is None.
546 | # super().__init__(None, name)
547 | # self.storage = storage
548 | # self.upload_to = upload_to
549 | # self.max_length = max_length
550 | # #self.instance = instance
551 | # #self.field = field
552 | # self._committed = True
553 |
554 | # def __eq__(self, other):
555 | # # Older code may be expecting FileField values to be simple strings.
556 | # # By overriding the == operator, it can remain backwards compatibility.
557 | # if hasattr(other, 'name'):
558 | # return self.name == other.name
559 | # return self.name == other
560 |
561 | # def __hash__(self):
562 | # return hash(self.name)
563 |
564 | # # The standard File contains most of the necessary properties, but
565 | # # FieldFiles can be instantiated without a name, so that needs to
566 | # # be checked for here.
567 |
568 | # def _require_file(self):
569 | # if not self:
570 | # raise ValueError("The '%s' attribute has no file associated with it." % self.name)
571 |
572 | # def _get_file(self):
573 | # self._require_file()
574 | # if getattr(self, '_file', None) is None:
575 | # self._file = self.storage.open(self.name, 'rb')
576 | # return self._file
577 |
578 | # def _set_file(self, file):
579 | # self._file = file
580 |
581 | # def _del_file(self):
582 | # del self._file
583 |
584 | # file = property(_get_file, _set_file, _del_file)
585 |
586 | # @property
587 | # def path(self):
588 | # self._require_file()
589 | # return self.storage.path(self.name)
590 |
591 | # @property
592 | # def url(self):
593 | # self._require_file()
594 | # return self.storage.url(self.name)
595 |
596 | # @property
597 | # def size(self):
598 | # self._require_file()
599 | # if not self._committed:
600 | # return self.file.size
601 | # return self.storage.size(self.name)
602 |
603 | # def open(self, mode='rb'):
604 | # self._require_file()
605 | # if getattr(self, '_file', None) is None:
606 | # self.file = self.storage.open(self.name, mode)
607 | # else:
608 | # self.file.open(mode)
609 | # return self
610 | # # open() doesn't alter the file's contents, but it does reset the pointer
611 | # open.alters_data = True
612 |
613 | # # In addition to the standard File API, FieldFiles have extra methods
614 | # # to further manipulate the underlying file, as well as update the
615 | # # associated model instance.
616 |
617 | # def save(self, name, content, save=True):
618 | # #name = self.field.generate_filename(self.instance, name)
619 | # name = posixpath.join(self.upload_to, name)
620 | # #name = self.storage.generate_filename(filename)
621 | # self.name = self.storage.save(name, content, max_length=self.max_length)
622 | # #setattr(self.instance, self.field.name, self.name)
623 | # self._committed = True
624 |
625 | # # Save the object because it has changed, unless save is False
626 | # #if save:
627 | # #self.instance.save()
628 | # save.alters_data = True
629 |
630 | # def delete(self, save=True):
631 | # if not self:
632 | # return
633 | # # Only close the file if it's already open, which we know by the
634 | # # presence of self._file
635 | # if hasattr(self, '_file'):
636 | # self.close()
637 | # del self.file
638 |
639 | # self.storage.delete(self.name)
640 |
641 | # self.name = None
642 | # #setattr(self.instance, self.field.name, self.name)
643 | # self._committed = False
644 |
645 | # #if save:
646 | # # self.instance.save()
647 | # delete.alters_data = True
648 |
649 | # @property
650 | # def closed(self):
651 | # file = getattr(self, '_file', None)
652 | # return file is None or file.closed
653 |
654 | # def close(self):
655 | # file = getattr(self, '_file', None)
656 | # if file is not None:
657 | # file.close()
658 |
659 | # def __getstate__(self):
660 | # # FieldFile needs access to its associated model field and an instance
661 | # # it's attached to in order to work properly, but the only necessary
662 | # # data to be pickled is the file's name itself. Everything else will
663 | # # be restored later, by FileDescriptor below.
664 | # return {'name': self.name, 'closed': False, '_committed': True, '_file': None}
665 |
666 |
667 | # from django.core.files.storage import default_storage
668 |
669 | # class FileBlock(FieldBlock):
670 | # needs_multipart_form = True
671 |
672 | # def __init__(self,
673 | # required=True,
674 | # help_text=None,
675 | # validators=(),
676 | # max_length=None,
677 | # upload_to='',
678 | # storage=None,
679 | # allow_empty_file=False,
680 | # **kwargs
681 | # ):
682 | # # This is how it works - uploaded files are something like
683 | # # InMemoryUploadedFile. These get wrapped in a upload
684 | # # handler, like MemoryFileUploadHandler in the request, then
685 | # # managed. But the uphot is, post.files are
686 | # # InMemoryUploadedFile.
687 | # #
688 | # # That's not all. clean() and save() take values from the
689 | # # instance field attribute. This is a descriptor packed with
690 | # # trickery. Broadly, if the value is only a string, it gets
691 | # # wrapped in attr_class which is FieldFile or ImageFieldFile.
692 | # # Non-string files get wrapped in attr_class, with
693 | # # the file attribute set and _committed = False
694 | # #
695 | # # In admin, files are bound, for sure. But what does that mean?
696 | # # because there is no field to bind to (it's a streamfield).
697 | # # And this is how formdata gets to a field, by binding it
698 | # # The big problem here is the lack of model machinery,
699 | # # speciality pre_save() is not called, because there is no
700 | # # model field
701 | # Somehow, that bound value needs changing.
702 | # super().__init__(**kwargs)
703 |
704 | # self.model_opts = {
705 | # 'upload_to':upload_to,
706 | # 'storage':storage or default_storage,
707 | # 'max_length': max_length
708 | # }
709 |
710 | # self.field = forms.FileField(
711 | # required=required,
712 | # help_text=help_text,
713 | # validators=validators,
714 | # max_length=max_length,
715 | # #name=None,
716 | # #upload_to='',
717 | # #storage=None,
718 | # allow_empty_file=allow_empty_file,
719 | # widget=FileInput,
720 | # **kwargs
721 | # )
722 |
723 | # #def value_from_form(self, value):
724 | # # pass
725 |
726 | # #def value_for_form(self, value):
727 | # # pass
728 |
729 | # #? cache
730 | # def file_descriptor(self, value):
731 | # '''
732 | # A model-field like descriptor.
733 | # Works like the model field descriptor, but needs and works on
734 | # no instance.
735 | # value
736 | # string, File or FieldFile
737 | # '''
738 | # if isinstance(value, str) or value is None:
739 | # file = FieldFileDumb(value, **self.model_opts)
740 | # elif isinstance(value, File) and not isinstance(value, FieldFile):
741 | # file = FieldFileDumb(str(value), **self.model_opts)
742 | # file.file = value
743 | # file._committed = False
744 |
745 | # #? can happen on pickle, it is said
746 | # elif isinstance(value, FieldFileDumb) and not hasattr(value, 'file'):
747 | # file = FieldFileDumb(str(value), **self.model_opts)
748 | # file.file = value
749 | # return file
750 |
751 | # def to_python(self, value):
752 | # # The model version does nothing
753 | # # The formfield gets an UploadedFile object
754 | # # all we get is a string.
755 | # # No, this overrides form. But without a field, it's getting a
756 | # # raw string
757 | # # it probably needs the descriptor gear
758 | # # if value in self.empty_values:
759 | # # return None
760 | # # if value is None or isinstance(value, datetime.date):
761 | # # return value
762 | # # else:
763 | # # return parse_date(value)
764 | # print('FileBlock to_python')
765 | # print(str(value))
766 | # print(str(value.__class__))
767 | # # A FileDescriptor would wrap in a FieldFile
768 | # # value of None is acceptable.
769 | # value = FieldFileDumb(value, **self.model_opts)
770 |
771 | # print(str(value))
772 | # print(str(value.__class__))
773 | # # to_python() in a form would be supplied with a FieldFile value.
774 | # #return self.field.to_python(value)
775 | # return value
776 |
777 | # # def value_from_form(self, value):
778 |
779 | # # def clean(self, value):
780 | # # # ChooserBlock works natively with model instances as its 'value' type (because that's what you
781 | # # # want to work with when doing front-end templating), but ModelChoiceField.clean expects an ID
782 | # # # as the input value (and returns a model instance as the result). We don't want to bypass
783 | # # # ModelChoiceField.clean entirely (it might be doing relevant validation, such as checking page
784 | # # # type) so we convert our instance back to an ID here. It means we have a wasted round-trip to
785 | # # # the database when ModelChoiceField.clean promptly does its own lookup, but there's no easy way
786 | # # # around that...
787 | # # if isinstance(value, self.target_model):
788 | # # value = value.pk
789 | # # return super().clean(value)
790 |
791 | # def get_prep_value(self, value):
792 | # #value = super().get_prep_value(value)
793 | # # Need to convert File objects provided via a form to string for database insertion
794 | # #? def save_form_data(self, instance, data):
795 | # if value is None:
796 | # return None
797 | # return str(value)
798 |
799 | # def pre_save_hook(self, field_value, value):
800 | # # The value is a upload file, but needs to be....
801 | # print('FileBlock pre_save_hook')
802 | # print(str(value.__class__))
803 | # file = FieldFileDumb(str(value), **self.model_opts)
804 | # file.file = value
805 | # file._committed = False
806 | # print(str(file))
807 | # if file and not file._committed:
808 | # # Commit the file to storage prior to saving the model
809 | # file.save(file.name, file.file, save=False)
810 | # value = file.name
811 | # print('saved to')
812 | # print(str(file.name))
813 |
814 | # print('_')
815 | # #return file
816 |
817 | # #def value_from_datadict(self, data, files, prefix):
818 | # # return super().value_from_datadict(data, files, prefix)
819 |
820 |
821 |
822 | class TimeBlock(FieldBlock):
823 | widget = MiniTimeWidget
824 |
825 | def __init__(self, input_formats=None, **kwargs):
826 | super().__init__(**kwargs)
827 | self.field_options.update({
828 | 'input_formats': input_formats,
829 | })
830 |
831 | @property
832 | def media(self):
833 | return Media(
834 | js = [
835 | 'streamfield/js/widgets/DateTimeInputs.js',
836 | ])
837 |
838 | @cached_property
839 | def field(self):
840 | return forms.TimeField(**self.field_options)
841 |
842 | def to_python(self, value):
843 | if value is None or isinstance(value, datetime.time):
844 | return value
845 | else:
846 | return parse_time(value)
847 |
848 | def render_basic(self, value, context=None):
849 | if value:
850 | return format_html('',
851 | value,
852 | self.render_css_classes(context)
853 | )
854 | else:
855 | return
856 |
857 |
858 |
859 | class DateBlock(FieldBlock):
860 | widget = MiniDateWidget
861 |
862 | def __init__(self, input_formats=None, **kwargs):
863 | super().__init__(**kwargs)
864 | self.field_options.update({
865 | 'input_formats': input_formats,
866 | })
867 |
868 | @property
869 | def media(self):
870 | return Media(
871 | js = [
872 | 'streamfield/js/widgets/DateTimeInputs.js',
873 | ])
874 |
875 | @cached_property
876 | def field(self):
877 | return forms.DateField(**self.field_options)
878 |
879 | def to_python(self, value):
880 | # Serialising to JSON uses DjangoJSONEncoder, which converts date/time objects to strings.
881 | # The reverse does not happen on decoding, because there's no way to know which strings
882 | # should be decoded; we have to convert strings back to dates here instead.
883 | #
884 | #? Which is cute but why not evoke all the localisation and formatting via the field
885 | if value is None or isinstance(value, datetime.date):
886 | return value
887 | else:
888 | return parse_date(value)
889 |
890 | def render_basic(self, value, context=None):
891 | if value:
892 | return format_html('',
893 | value,
894 | self.render_css_classes(context)
895 | )
896 | else:
897 | return
898 |
899 |
900 |
901 | class DateTimeBlock(FieldBlock):
902 | widget = MiniDateTimeWidget
903 |
904 | def __init__(self,
905 | input_date_formats=None,
906 | input_time_formats=None,
907 | **kwargs
908 | ):
909 |
910 | super().__init__(**kwargs)
911 | self.field_options.update({
912 | 'input_date_formats': input_date_formats,
913 | 'input_time_formats': input_time_formats,
914 | })
915 |
916 | @cached_property
917 | def field(self):
918 | return forms.SplitDateTimeField(**self.field_options)
919 |
920 | def to_python(self, value):
921 | if value is None or isinstance(value, datetime.datetime):
922 | return value
923 | else:
924 | return parse_datetime(value)
925 |
926 | def render_basic(self, value, context=None):
927 | if value:
928 | return format_html('',
929 | value,
930 | self.render_css_classes(context)
931 | )
932 | else:
933 | return
934 |
935 |
936 |
937 | class ChoiceBlockBase(FieldBlock):
938 | '''
939 | A block handling choices.
940 | Choices can be declared as literals or via. enumerations. See Django
941 | documentation
942 | https://docs.djangoproject.com/en/3.0/ref/models/fields/#choices
943 | The blocks will handle the types and their storage.
944 | However, these blocks cut back on Wagtail/Django provision, they
945 | need static declarations, and will not will not accept callables,
946 | etc.
947 | '''
948 | choices = ()
949 |
950 | def __init__(
951 | self,
952 | choices=None,
953 | default=None,
954 | **kwargs
955 | ):
956 | super().__init__(default=default, **kwargs)
957 |
958 | if choices is None:
959 | # no choices specified, so pick up the choice defined at the class level
960 | choices = self.choices
961 | choices = list(choices)
962 |
963 | # if not required, and no blank option, force a blank option.
964 | if (not(self.field_options['required']) and not(self.has_blank_choice(choices))):
965 | choices = BLANK_CHOICE_DASH + choices
966 |
967 | self.field_options.update({
968 | 'choices': choices,
969 | })
970 | self.field = self.field_model(
971 | **self.field_options
972 | )
973 |
974 | def has_blank_choice(self, choices):
975 | has_blank_choice = False
976 | for v1, v2 in choices:
977 | if isinstance(v2, (list, tuple)):
978 | # this is a named group, and v2 is the value list
979 | has_blank_choice = any([value in ('', None) for value, label in v2])
980 | if has_blank_choice:
981 | break
982 | else:
983 | # this is an individual choice; v1 is the value
984 | if v1 in ('', None):
985 | has_blank_choice = True
986 | break
987 | return has_blank_choice
988 |
989 |
990 |
991 | class ChoiceBlock(ChoiceBlockBase):
992 | field_model = forms.ChoiceField
993 |
994 | # def deconstruct(self):
995 | # """
996 | # Always deconstruct ChoiceBlock instances as if they were plain ChoiceBlocks with their
997 | # choice list passed in the constructor, even if they are actually subclasses. This allows
998 | # users to define subclasses of ChoiceBlock in their models.py, with specific choice lists
999 | # passed in, without references to those classes ending up frozen into migrations.
1000 | # """
1001 | # return ('streamfield.blocks.ChoiceBlock', [], self._constructor_kwargs)
1002 |
1003 | def get_searchable_content(self, value):
1004 | # Return the display value as the searchable value
1005 | text_value = force_str(value)
1006 | for k, v in self.field.choices:
1007 | if isinstance(v, (list, tuple)):
1008 | # This is an optgroup, so look inside the group for options
1009 | for k2, v2 in v:
1010 | if value == k2 or text_value == force_str(k2):
1011 | return [force_str(k), force_str(v2)]
1012 | else:
1013 | if value == k or text_value == force_str(k):
1014 | return [force_str(v)]
1015 | return []
1016 |
1017 | def render_basic(self, value, context=None):
1018 | return format_html('{0}',
1019 | value,
1020 | self.render_css_classes(context)
1021 | )
1022 |
1023 |
1024 |
1025 | class MultipleChoiceBlock(ChoiceBlockBase):
1026 | field_model = forms.MultipleChoiceField
1027 |
1028 | # def deconstruct(self):
1029 | # """
1030 | # Always deconstruct MultipleChoiceBlock instances as if they were plain
1031 | # MultipleChoiceBlocks with their choice list passed in the constructor,
1032 | # even if they are actually subclasses. This allows users to define
1033 | # subclasses of MultipleChoiceBlock in their models.py, with specific choice
1034 | # lists passed in, without references to those classes ending up frozen
1035 | # into migrations.
1036 | # """
1037 | # return ('streamfield.blocks.MultipleChoiceBlock', [], self._constructor_kwargs)
1038 |
1039 | def get_searchable_content(self, value):
1040 | # Return the display value as the searchable value
1041 | content = []
1042 | text_value = force_str(value)
1043 | for k, v in self.field.choices:
1044 | if isinstance(v, (list, tuple)):
1045 | # This is an optgroup, so look inside the group for options
1046 | for k2, v2 in v:
1047 | if value == k2 or text_value == force_str(k2):
1048 | content.append(force_str(k))
1049 | content.append(force_str(v2))
1050 | else:
1051 | if value == k or text_value == force_str(k):
1052 | content.append(force_str(v))
1053 | return content
1054 |
1055 | def render_basic(self, value, context=None):
1056 | #? format_html_join() flakes...
1057 | optionsL = [format_html('
{0}
', v) for v in value]
1058 | options = mark_safe(''.join(optionsL))
1059 | return format_html('
{0}
',
1060 | options,
1061 | self.render_css_classes(context)
1062 | )
1063 |
1064 |
1065 |
1066 |
1067 | class ModelChoiceBlockBase(FieldBlock):
1068 | '''
1069 | Base for fields that implement a choice of models.
1070 | It works with model instances as it's values. The block has the
1071 | expense of a DB lookup. However, it opens the possibility of
1072 | selecting on Django models, which may be worth the expense.
1073 |
1074 | This is not a choice of enumerables, see ChoiceBlock and
1075 | MultipleChoiceBlock for that.
1076 |
1077 | target_model
1078 | The model to do queries on
1079 | target_filter
1080 | A query to limit offered options. The query is organised as a
1081 | dict e.g. {'headline__contains':'Lennon'}
1082 | to_field_name
1083 | The model field to use to supply label names.
1084 | empty_label
1085 | An alternative to the default empty label, which is '--------'
1086 | '''
1087 | def __init__(self,
1088 | target_model,
1089 | target_filter={},
1090 | to_field_name=None,
1091 | empty_label="---------",
1092 | **kwargs
1093 | ):
1094 | super().__init__(**kwargs)
1095 | self.target_model = target_model
1096 | if issubclass(target_model, Model):
1097 | raise AttributeError("'target_model' must be a Model. class:{}".format(self.__class__.__name__) )
1098 | self.field_options.update({
1099 | 'queryset': target_model.objects.filter(target_filter),
1100 | 'to_field_name': to_field_name,
1101 | 'empty_label': empty_label,
1102 | })
1103 |
1104 |
1105 |
1106 | class ModelChoiceBlock(ModelChoiceBlockBase):
1107 | '''
1108 | Base for fields that implement a choice of models.
1109 | It works with model instances as it's values. The block has the
1110 | expense of a DB lookup. However, it opens the possibility of
1111 | selecting on any Django model, which may be worth the expense.
1112 |
1113 | This is not a choice of enumerables, see ChoiceBlock and
1114 | MultipleChoiceBlock for that.
1115 | '''
1116 |
1117 | """Abstract superclass for fields that implement a chooser interface (page, image, snippet etc)"""
1118 | @cached_property
1119 | def field(self):
1120 | return forms.ModelChoiceField(
1121 | **self.field_options
1122 | )
1123 |
1124 | def to_python(self, value):
1125 | # the incoming serialised value should be None or an ID
1126 | if value is None:
1127 | return value
1128 | else:
1129 | try:
1130 | return self.target_model.objects.get(pk=value)
1131 | except self.target_model.DoesNotExist:
1132 | return None
1133 |
1134 | # def bulk_to_python(self, values):
1135 | # """Return the model instances for the given list of primary keys.
1136 |
1137 | # The instances must be returned in the same order as the values and keep None values.
1138 | # """
1139 | # objects = self.target_model.objects.in_bulk(values)
1140 | # return [objects.get(id) for id in values] # Keeps the ordering the same as in values.
1141 |
1142 | def get_prep_value(self, value):
1143 | # the native value (a model instance or None) should serialise to a PK or None
1144 | if value is None:
1145 | return None
1146 | else:
1147 | return value.pk
1148 |
1149 | def value_from_form(self, value):
1150 | # ModelChoiceField sometimes returns an ID, and sometimes an instance; we want the instance
1151 | if value is None or isinstance(value, self.target_model):
1152 | return value
1153 | else:
1154 | try:
1155 | return self.target_model.objects.get(pk=value)
1156 | except self.target_model.DoesNotExist:
1157 | return None
1158 |
1159 | def clean(self, value):
1160 | # ChooserBlock works natively with model instances as its 'value' type (because that's what you
1161 | # want to work with when doing front-end templating), but ModelChoiceField.clean expects an ID
1162 | # as the input value (and returns a model instance as the result). We don't want to bypass
1163 | # ModelChoiceField.clean entirely (it might be doing relevant validation, such as checking page
1164 | # type) so we convert our instance back to an ID here. It means we have a wasted round-trip to
1165 | # the database when ModelChoiceField.clean promptly does its own lookup, but there's no easy way
1166 | # around that...
1167 | if isinstance(value, self.target_model):
1168 | value = value.pk
1169 | return super().clean(value)
1170 |
1171 | def value_from_form(self, value):
1172 | # ModelChoiceField sometimes returns IDs, and sometimes instances; we want the instance
1173 | if value is None or isinstance(value, self.target_model):
1174 | return value
1175 | else:
1176 | try:
1177 | return self.target_model.objects.get(pk=value)
1178 | except self.target_model.DoesNotExist:
1179 | return None
1180 |
1181 |
1182 |
1183 | class ModelMultipleChoiceBlock(ModelChoiceBlock):
1184 |
1185 | @cached_property
1186 | def field(self):
1187 | # # return forms.ModelChoiceField(
1188 | # # queryset=self.target_model.objects.all(), widget=self.widget, required=self._required,
1189 | # # validators=self._validators,
1190 | # # help_text=self._help_text)
1191 | return forms.ModelMultipleChoiceField(
1192 | **self.field_options
1193 | )
1194 |
1195 | def to_python(self, value):
1196 | # # the incoming serialised value should be None or IDs
1197 | if value is None:
1198 | return value
1199 | else:
1200 | try:
1201 | return self.target_model.objects.filter(pk__in=value)
1202 | except self.target_model.DoesNotExist:
1203 | return None
1204 |
1205 | def get_prep_value(self, value):
1206 | # the native value (a model instance or None) should serialise to PKs or None
1207 | if value is None:
1208 | return None
1209 | else:
1210 | return [e.pk for e in value]
1211 |
1212 | def clean(self, value):
1213 | # ChooserBlocks work natively with model instances as its 'value' type (because that's what you
1214 | # want to work with when doing front-end templating), but ModelChoiceField.clean expects an ID
1215 | # as the input value (and returns a model instance as the result). We don't want to bypass
1216 | # ModelChoiceField.clean entirely (it might be doing relevant validation, such as checking page
1217 | # type) so we convert our instance back to an ID here. It means we have a wasted round-trip to
1218 | # the database when ModelChoiceField.clean promptly does its own lookup, but there's no easy way
1219 | # around that...
1220 | # if not isinstance(value, (list, tuple)):
1221 | if isinstance(value[0], self.target_model):
1222 | value = value.pk
1223 | return super().clean(value)
1224 |
1225 | def value_from_form(self, value):
1226 | # ModelChoiceField sometimes returns an ID, and sometimes an instance; we want the instance
1227 | if value is None or isinstance(value[0], self.target_model):
1228 | return value
1229 | else:
1230 | try:
1231 | return self.field_options['target_model'].objects.filter(pk__in=value)
1232 | except self.field_options['target_model'].DoesNotExist:
1233 | return None
1234 |
1235 |
1236 |
1237 | # Ensure that the blocks defined here get deconstructed as streamfield.blocks.FooBlock
1238 | # rather than streamfield.blocks.field_block.FooBlock
1239 | block_classes = [
1240 | FieldBlock,
1241 | CharBlock, HeaderBlock, QuoteBlock, EmailBlock, RegexBlock, URLBlock, RelURLBlock,
1242 | RawAnchorBlock, AnchorBlock,
1243 | TextBlock, BlockQuoteBlock, RawHTMLBlock,
1244 | BooleanBlock,
1245 | IntegerBlock, DecimalBlock, FloatBlock,
1246 | DateBlock, TimeBlock, DateTimeBlock,
1247 | ChoiceBlock, MultipleChoiceBlock,
1248 | ]
1249 |
1250 | DECONSTRUCT_ALIASES = {
1251 | cls: 'streamfield.blocks.%s' % cls.__name__
1252 | for cls in block_classes
1253 | }
1254 | __all__ = [cls.__name__ for cls in block_classes]
1255 |
--------------------------------------------------------------------------------
/streamfield/blocks/list_block.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Sequence
2 |
3 | from django import forms
4 | from django.core.exceptions import ValidationError
5 | from django.forms.utils import ErrorList
6 | from django.template.loader import render_to_string
7 | from django.utils.html import format_html, format_html_join
8 | from django.utils.safestring import mark_safe
9 |
10 | #from wagtail.admin.staticfiles import versioned_static
11 | #from wagtail.core.utils import escape_script
12 | from streamfield.utils import escape_script, versioned_static
13 |
14 | from .base import Block
15 | from .utils import js_dict
16 |
17 | __all__ = ['ListBlock'] #, 'OrderedListBlock']
18 |
19 |
20 | class ListValue(Sequence):
21 | '''
22 | Qrap the values used in a listblock.
23 | It acts like a list. It mainly exists because it's __str__()
24 | methods reference the block methods, meaning even template
25 | references can be automatically written as HTML lists.
26 | '''
27 | #? Don't know why wagtail breaks out __html__()
28 | def __init__(self, list_block, data):
29 | self.list_block = list_block
30 | self.data = data # a list of data
31 |
32 | def __getitem__(self, i):
33 | return self.data[i]
34 |
35 | def __len__(self):
36 | return len(self.data)
37 |
38 | def __repr__(self):
39 | return repr(list(self))
40 |
41 | def render_as_block(self, context=None):
42 | return self.list_block.render(self, context=context)
43 |
44 | def __str__(self):
45 | return self.list_block.render(self)
46 |
47 |
48 |
49 | class ListBlock(Block):
50 | '''
51 | An extendable collection of similar blocks.
52 | Compare to StructBlock, a collection of not similar blocks.
53 | '''
54 | # When a listblock renders as a block, it is bound, which calls
55 | # a block render, which understands to make an HTML list of it.
56 | # However, we allow to wrap ListBlock as a model-field. If ListBlock
57 | # is wrapped as a model-field, when rendered as a value, none of the
58 | # above happens. the value is
59 | def __init__(self,
60 | child_block,
61 | element_template="",
62 | wrap_template = "
{0}
",
63 | **kwargs
64 | ):
65 | super().__init__(**kwargs)
66 |
67 | # assert child block is instance
68 | if (not(isinstance(child_block, Block))):
69 | child_block = child_block()
70 |
71 | self.child_block = child_block
72 |
73 |
74 | self.wrap_template = wrap_template
75 | self.element_template = element_template
76 | if not hasattr(self.meta, 'default'):
77 | # Default to a list consisting of one empty (i.e. default-valued) child item
78 | self.meta.default = [self.child_block.get_default()]
79 |
80 | self.dependencies = [self.child_block]
81 | self.child_js_initializer = self.child_block.js_initializer()
82 |
83 | @property
84 | def media(self):
85 | # Here's something Pythonic, an attribute muddle.
86 | # Django documentation blithely states it tries to maintain
87 | # order in media statements. In tact, it runs a dependancy
88 | # analysis which can scatter media declarations into unexpected
89 | # orders. Indeed a static/ prefix can influence the order!
90 | #
91 | # Not only is this a horror of unpredicatablility, it influences
92 | # us. Wagtails JS uses it's own admin, and has no need/interest
93 | # in namespacing. But all of Django's admin namespaces jQuery
94 | # using jquery.init (and the jQuery command |noConflict). The
95 | # resolutions are to remove the namespacing, easy but
96 | # makes a pool of specialist code, or namespace the Wagtail
97 | # code.
98 | # We also want to remove Wagtails cachebusting URLS. They are
99 | # valuable code, but are not Django-like, and auto-generate
100 | # static/ URLS, leading to worse complexity.
101 | #
102 | # But remember what is above, rendering order of JS is
103 | # erratic. We need to declare at least
104 | # 'admin/js/jquery.init.js' in every media declaration BEFORE
105 | # any media ineritance or merge, to establish that dependency.
106 | #
107 | # Upshot: all Wagtail code has been namespaced. Remove the
108 | # apparently repetitive statements of Django JS code, and
109 | # the Wagtail code may be placed in non-namespaced positions,
110 | # resulting in multiple and cascading errors.
111 | return forms.Media(js=[
112 | 'admin/js/jquery.init.js',
113 | 'admin/js/core.js',
114 | 'streamfield/js/blocks/sequence.js',
115 | 'streamfield/js/blocks/list.js'
116 | ])
117 |
118 | def render_list_member(self, value, prefix, index, errors=None):
119 | """
120 | Render the HTML for a single list item in the form. This consists of an
wrapper, hidden fields
121 | to manage ID/deleted state, delete/reorder buttons, and the child block's own form HTML.
122 | """
123 | child = self.child_block.bind(value, prefix="%s-value" % prefix, errors=errors)
124 | return render_to_string('streamfield/block_forms/list_member.html', {
125 | 'child_block': self.child_block,
126 | 'prefix': prefix,
127 | 'child': child,
128 | 'index': index,
129 | })
130 |
131 | def html_declarations(self):
132 | # generate the HTML to be used when adding a new item to the list;
133 | # this is the output of render_list_member as rendered with the prefix '__PREFIX__'
134 | # (to be replaced dynamically when adding the new item) and the child block's default value
135 | # as its value.
136 | list_member_html = self.render_list_member(self.child_block.get_default(), '__PREFIX__', '')
137 |
138 | return format_html(
139 | '',
140 | self.definition_prefix, mark_safe(escape_script(list_member_html))
141 | )
142 |
143 | def js_initializer(self):
144 | opts = {'definitionPrefix': "'%s'" % self.definition_prefix}
145 |
146 | if self.child_js_initializer:
147 | opts['childInitializer'] = self.child_js_initializer
148 |
149 | return "ListBlock(%s)" % js_dict(opts)
150 |
151 | def render_form(self, value, prefix='', errors=None):
152 | if errors:
153 | if len(errors) > 1:
154 | # We rely on ListBlock.clean throwing a single ValidationError with a specially crafted
155 | # 'params' attribute that we can pull apart and distribute to the child blocks
156 | raise TypeError('ListBlock.render_form unexpectedly received multiple errors')
157 | error_list = errors.as_data()[0].params
158 | else:
159 | error_list = None
160 |
161 | # value can be None when a ListBlock is initialising
162 | if value is None:
163 | value = []
164 |
165 | list_members_html = [
166 | self.render_list_member(child_val, "%s-%d" % (prefix, i), i,
167 | errors=error_list[i] if error_list else None)
168 | for (i, child_val) in enumerate(value)
169 | ]
170 |
171 | return render_to_string('streamfield/block_forms/list.html', {
172 | 'help_text': getattr(self.meta, 'help_text', None),
173 | 'prefix': prefix,
174 | 'list_members_html': list_members_html,
175 | })
176 |
177 | def value_from_datadict(self, data, files, prefix):
178 | count = int(data['%s-count' % prefix])
179 | values_with_indexes = []
180 | for i in range(0, count):
181 | if data['%s-%d-deleted' % (prefix, i)]:
182 | continue
183 | values_with_indexes.append(
184 | (
185 | int(data['%s-%d-order' % (prefix, i)]),
186 | self.child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i))
187 | )
188 | )
189 |
190 | values_with_indexes.sort()
191 | return [v for (i, v) in values_with_indexes]
192 |
193 | def value_omitted_from_data(self, data, files, prefix):
194 | return ('%s-count' % prefix) not in data
195 |
196 | def clean(self, value):
197 | result = []
198 | errors = []
199 | for child_val in value:
200 | try:
201 | result.append(self.child_block.clean(child_val))
202 | except ValidationError as e:
203 | errors.append(ErrorList([e]))
204 | else:
205 | errors.append(None)
206 |
207 | if any(errors):
208 | # The message here is arbitrary - outputting error messages is delegated to the child blocks,
209 | # which only involves the 'params' list
210 | raise ValidationError('Validation error in ListBlock', params=errors)
211 |
212 | return result
213 |
214 | def to_python(self, value):
215 | return ListValue(self, [
216 | self.child_block.to_python(item)
217 | for item in value
218 | ]
219 | )
220 |
221 | def get_prep_value(self, value):
222 | # recursively call get_prep_value on children and return as a list
223 | return [
224 | self.child_block.get_prep_value(item)
225 | for item in value
226 | ]
227 |
228 | # def pre_save_hook(self, field_value, value):
229 | # for child_val in value:
230 | # self.child_block.pre_save_hook(field_value, child_val)
231 |
232 | def render_basic(self, value, context=None):
233 | print("ListBlock render")
234 | children = format_html_join(
235 | '\n', self.element_template,
236 | [
237 | (self.child_block.render(child_value, context=context),)
238 | for child_value in value
239 | ]
240 | )
241 | return format_html(self.wrap_template,
242 | children,
243 | self.render_css_classes(context)
244 | )
245 |
246 | def get_searchable_content(self, value):
247 | content = []
248 |
249 | for child_value in value:
250 | content.extend(self.child_block.get_searchable_content(child_value))
251 |
252 | return content
253 |
254 | def check(self, **kwargs):
255 | errors = super().check(**kwargs)
256 | errors.extend(self.child_block.check(**kwargs))
257 | return errors
258 |
259 |
260 |
261 | DECONSTRUCT_ALIASES = {
262 | ListBlock: 'streamfield.blocks.ListBlock',
263 | }
264 |
--------------------------------------------------------------------------------
/streamfield/blocks/static_block.py:
--------------------------------------------------------------------------------
1 | from django.utils.translation import ugettext_lazy as _
2 |
3 | from .base import Block
4 |
5 | __all__ = ['StaticBlock']
6 |
7 |
8 | class StaticBlock(Block):
9 | """
10 | A block that just 'exists' and has no fields.
11 | """
12 | def render_form(self, value, prefix='', errors=None):
13 | if self.meta.admin_text is None:
14 | if self.label:
15 | return _('%(label)s: this block has no options.') % {'label': self.label}
16 | else:
17 | return _('This block has no options.')
18 | return self.meta.admin_text
19 |
20 | def value_from_datadict(self, data, files, prefix):
21 | return None
22 |
23 | class Meta:
24 | admin_text = None
25 | default = None
26 |
--------------------------------------------------------------------------------
/streamfield/blocks/stream_block.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from collections import OrderedDict, defaultdict
3 | from collections.abc import Sequence
4 |
5 | from django import forms
6 | from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
7 | from django.forms.utils import ErrorList
8 | from django.template.loader import render_to_string
9 | from django.utils.html import format_html_join, format_html
10 | from django.utils.safestring import mark_safe
11 | from django.utils.translation import ugettext as _
12 |
13 | from streamfield.utils import escape_script
14 |
15 | from .base import Block, BoundBlock, DeclarativeSubBlocksMetaclass
16 | from .utils import indent, js_dict
17 |
18 | __all__ = ['BaseStreamBlock', 'StreamBlock', 'StreamValue', 'StreamBlockValidationError']
19 |
20 |
21 |
22 | class StreamBlockValidationError(ValidationError):
23 | def __init__(self, block_errors=None, non_block_errors=None):
24 | params = {}
25 | if block_errors:
26 | params.update(block_errors)
27 | if non_block_errors:
28 | params[NON_FIELD_ERRORS] = non_block_errors
29 | super().__init__(
30 | 'Validation error in StreamBlock', params=params)
31 |
32 |
33 | class BaseStreamBlock(Block):
34 |
35 | def __init__(self, local_blocks=None, **kwargs):
36 | self._constructor_kwargs = kwargs
37 |
38 | super().__init__(**kwargs)
39 |
40 | # create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
41 | self.child_blocks = self.base_blocks.copy()
42 | if local_blocks:
43 | for name, block in local_blocks:
44 | # assert local blocks are instances
45 | if (not(isinstance(block, Block))):
46 | block = block()
47 | block.set_name(name)
48 | self.child_blocks[name] = block
49 |
50 | self.dependencies = self.child_blocks.values()
51 |
52 | def get_default(self):
53 | """
54 | Default values set on a StreamBlock should be a list of (type_name, value) tuples -
55 | we can't use StreamValue directly, because that would require a reference back to
56 | the StreamBlock that hasn't been built yet.
57 |
58 | For consistency, then, we need to convert it to a StreamValue here for StreamBlock
59 | to work with.
60 | """
61 | return StreamValue(self, self.meta.default)
62 |
63 | def sorted_child_blocks(self):
64 | """Child blocks, sorted in to their groups."""
65 | return sorted(self.child_blocks.values(),
66 | key=lambda child_block: child_block.meta.group)
67 |
68 | def render_list_member(self, block_type_name, value, prefix, index, errors=None, id=None):
69 | """
70 | Render the HTML for a single list item. This consists of a container, hidden fields
71 | to manage ID/deleted state/type, delete/reorder buttons, and the child block's own HTML.
72 | """
73 | child_block = self.child_blocks[block_type_name]
74 | child = child_block.bind(value, prefix="%s-value" % prefix, errors=errors)
75 | return render_to_string('streamfield/block_forms/stream_member.html', {
76 | 'child_blocks': self.sorted_child_blocks(),
77 | 'block_type_name': block_type_name,
78 | 'child_block': child_block,
79 | 'prefix': prefix,
80 | 'child': child,
81 | 'index': index,
82 | 'block_id': id,
83 | })
84 |
85 | def html_declarations(self):
86 | r = format_html_join(
87 | '\n', '',
88 | [
89 | (
90 | self.definition_prefix,
91 | name,
92 | mark_safe(escape_script(self.render_list_member(name, child_block.get_default(), '__PREFIX__', '')))
93 | )
94 | for name, child_block in self.child_blocks.items()
95 | ]
96 | )
97 | return r
98 |
99 | @property
100 | def media(self):
101 | # Here's something Pythonic, an attribute muddle.
102 | # Django documentation blithely states it tries to maintain
103 | # order in media statements. In tact, it runs a dependancy
104 | # analysis which can scatter media declarations into unexpected
105 | # orders. Indeed a static/ prefix can influence the order!
106 | #
107 | # Not only is this a horror of unpredicatablility, it influences
108 | # us. Wagtails JS uses it's own admin, and has no need/interest
109 | # in namespacing. But all of Django's admin namespaces jQuery
110 | # using jquery.init (and the jQuery command |noConflict). The
111 | # resolutions are to remove the namespacing, easy but
112 | # makes a pool of specialist code, or namespace the Wagtail
113 | # code.
114 | # We also want to remove Wagtails cachebusting URLS. They are
115 | # valuable code, but are not Django-like, and auto-generate
116 | # static/ URLS, leading to worse complexity.
117 | #
118 | # But remember what is above, rendering order of JS is
119 | # erratic. We need to declare at least
120 | # 'admin/js/jquery.init.js' in every media declaration BEFORE
121 | # any media ineritance or merge, to establish that dependency.
122 | #
123 | # Upshot: all Wagtail code has been namespaced. Remove the
124 | # apparently repetitive statements of Django JS code, and
125 | # the Wagtail code may be placed in non-namespaced positions,
126 | # resulting in multiple and cascading errors.
127 | return forms.Media(js=[
128 | 'admin/js/jquery.init.js',
129 | 'admin/js/core.js',
130 | 'streamfield/js/blocks/sequence.js',
131 | 'streamfield/js/blocks/stream.js',
132 | ])
133 |
134 | def js_initializer(self):
135 | # compile a list of info dictionaries, one for each available block type
136 | child_blocks = []
137 | for name, child_block in self.child_blocks.items():
138 | # each info dictionary specifies at least a block name
139 | child_block_info = {'name': "'%s'" % name}
140 |
141 | # if the child defines a JS initializer function, include that in the info dict
142 | # along with the param that needs to be passed to it for initializing an empty/default block
143 | # of that type
144 | child_js_initializer = child_block.js_initializer()
145 | if child_js_initializer:
146 | child_block_info['initializer'] = child_js_initializer
147 |
148 | child_blocks.append(indent(js_dict(child_block_info)))
149 |
150 | opts = {
151 | 'definitionPrefix': "'%s'" % self.definition_prefix,
152 | 'childBlocks': '[\n%s\n]' % ',\n'.join(child_blocks),
153 | }
154 | return "StreamBlock(%s)" % js_dict(opts)
155 |
156 | def render_form(self, value, prefix='', errors=None):
157 | error_dict = {}
158 | if errors:
159 | if len(errors) > 1:
160 | # We rely on StreamBlock.clean throwing a single
161 | # StreamBlockValidationError with a specially crafted 'params'
162 | # attribute that we can pull apart and distribute to the child
163 | # blocks
164 | raise TypeError('StreamBlock.render_form unexpectedly received multiple errors')
165 | error_dict = errors.as_data()[0].params
166 |
167 | # value can be None when the StreamField is in a formset
168 | if value is None:
169 | value = self.get_default()
170 | # drop any child values that are an unrecognised block type
171 | valid_children = [child for child in value if child.block_type in self.child_blocks]
172 | list_members_html = [
173 | self.render_list_member(child.block_type, child.value, "%s-%d" % (prefix, i), i,
174 | errors=error_dict.get(i), id=child.id)
175 | for (i, child) in enumerate(valid_children)
176 | ]
177 | return render_to_string('streamfield/block_forms/stream.html', {
178 | 'prefix': prefix,
179 | 'help_text': getattr(self.meta, 'help_text', None),
180 | 'list_members_html': list_members_html,
181 | 'child_blocks': self.sorted_child_blocks(),
182 | 'header_menu_prefix': '%s-before' % prefix,
183 | 'block_errors': error_dict.get(NON_FIELD_ERRORS),
184 | })
185 |
186 | def value_from_datadict(self, data, files, prefix):
187 | count = int(data['%s-count' % prefix])
188 | values_with_indexes = []
189 | for i in range(0, count):
190 | if data['%s-%d-deleted' % (prefix, i)]:
191 | continue
192 | block_type_name = data['%s-%d-type' % (prefix, i)]
193 | try:
194 | child_block = self.child_blocks[block_type_name]
195 | except KeyError:
196 | continue
197 |
198 | values_with_indexes.append(
199 | (
200 | int(data['%s-%d-order' % (prefix, i)]),
201 | block_type_name,
202 | child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i)),
203 | data.get('%s-%d-id' % (prefix, i)),
204 | )
205 | )
206 |
207 | values_with_indexes.sort()
208 | return StreamValue(self, [
209 | (child_block_type_name, value, block_id)
210 | for (index, child_block_type_name, value, block_id) in values_with_indexes
211 | ])
212 |
213 | def value_omitted_from_data(self, data, files, prefix):
214 | return ('%s-count' % prefix) not in data
215 |
216 | @property
217 | def required(self):
218 | return self.meta.required
219 |
220 | def clean(self, value):
221 | cleaned_data = []
222 | errors = {}
223 | non_block_errors = ErrorList()
224 | for i, child in enumerate(value): # child is a StreamChild instance
225 | try:
226 | cleaned_data.append(
227 | (child.block.name, child.block.clean(child.value), child.id)
228 | )
229 | except ValidationError as e:
230 | errors[i] = ErrorList([e])
231 |
232 | if self.meta.min_num is not None and self.meta.min_num > len(value):
233 | non_block_errors.append(ValidationError(
234 | _('The minimum number of items is %d') % self.meta.min_num
235 | ))
236 | elif self.required and len(value) == 0:
237 | non_block_errors.append(ValidationError(_('This field is required.')))
238 |
239 | if self.meta.max_num is not None and self.meta.max_num < len(value):
240 | non_block_errors.append(ValidationError(
241 | _('The maximum number of items is %d') % self.meta.max_num
242 | ))
243 |
244 | if self.meta.block_counts:
245 | block_counts = defaultdict(int)
246 | for item in value:
247 | block_counts[item.block_type] += 1
248 |
249 | for block_name, min_max in self.meta.block_counts.items():
250 | block = self.child_blocks[block_name]
251 | max_num = min_max.get('max_num', None)
252 | min_num = min_max.get('min_num', None)
253 | block_count = block_counts[block_name]
254 | if min_num is not None and min_num > block_count:
255 | non_block_errors.append(ValidationError(
256 | '{}: {}'.format(block.label, _('The minimum number of items is %d') % min_num)
257 | ))
258 | if max_num is not None and max_num < block_count:
259 | non_block_errors.append(ValidationError(
260 | '{}: {}'.format(block.label, _('The maximum number of items is %d') % max_num)
261 | ))
262 |
263 | if errors or non_block_errors:
264 | # The message here is arbitrary - outputting error messages
265 | # is delegated to the child blocks, which only involves the
266 | # 'params' list
267 | raise StreamBlockValidationError(block_errors=errors, non_block_errors=non_block_errors)
268 |
269 | return StreamValue(self, cleaned_data)
270 |
271 | def to_python(self, value):
272 | # the incoming JSONish representation is a list of dicts, each with a 'type' and 'value' field
273 | # (and possibly an 'id' too).
274 | # This is passed to StreamValue to be expanded lazily - but first we reject any unrecognised
275 | # block types from the list
276 | return StreamValue(self, [
277 | child_data for child_data in value
278 | if child_data['type'] in self.child_blocks
279 | ], is_lazy=True)
280 |
281 | def get_prep_value(self, value):
282 | if not value:
283 | # Falsy values (including None, empty string, empty list, and
284 | # empty StreamValue) become an empty stream
285 | return []
286 | else:
287 | # value is a StreamValue - delegate to its get_prep_value() method
288 | # (which has special-case handling for lazy StreamValues to avoid useless
289 | # round-trips to the full data representation and back)
290 | return value.get_prep_value()
291 |
292 | # def pre_save_hook(self, field_value, value):
293 | # # child is a StreamChild instance
294 | # for child in value:
295 | # child.block.pre_save_hook(field_value, child.value)
296 |
297 | def render_basic(self, value, context=None):
298 | stream = format_html_join(
299 | '\n', '{0}',
300 | [
301 | (child.render(context=context),)
302 | for child in value
303 | ]
304 | )
305 | return format_html('
{0}
',
306 | stream,
307 | self.render_css_classes(context)
308 | )
309 |
310 | def get_searchable_content(self, value):
311 | content = []
312 |
313 | for child in value:
314 | content.extend(child.block.get_searchable_content(child.value))
315 |
316 | return content
317 |
318 | def deconstruct(self):
319 | """
320 | Always deconstruct StreamBlock instances as if they were plain StreamBlocks with all of the
321 | field definitions passed to the constructor - even if in reality this is a subclass of StreamBlock
322 | with the fields defined declaratively, or some combination of the two.
323 |
324 | This ensures that the field definitions get frozen into migrations, rather than leaving a reference
325 | to a custom subclass in the user's models.py that may or may not stick around.
326 | """
327 | path = 'streamfield.blocks.StreamBlock'
328 | args = [list(self.child_blocks.items())]
329 | kwargs = self._constructor_kwargs
330 | return (path, args, kwargs)
331 |
332 | def check(self, **kwargs):
333 | errors = super().check(**kwargs)
334 | for name, child_block in self.child_blocks.items():
335 | errors.extend(child_block.check(**kwargs))
336 | errors.extend(child_block._check_name(**kwargs))
337 |
338 | return errors
339 |
340 | class Meta:
341 | default = []
342 | required = True
343 | min_num = None
344 | max_num = None
345 | block_counts = {}
346 |
347 |
348 |
349 | class StreamBlock(BaseStreamBlock, metaclass=DeclarativeSubBlocksMetaclass):
350 | '''
351 | A unlimited collection of not similar blocks.
352 | Streamblock is the default for a Streamfield.
353 | '''
354 | pass
355 |
356 |
357 | class StreamValue(Sequence):
358 | """
359 | Custom type used to represent the value of a StreamBlock; behaves as a sequence of BoundBlocks
360 | (which keep track of block types in a way that the values alone wouldn't).
361 | """
362 |
363 | class StreamChild(BoundBlock):
364 | """
365 | Extends BoundBlock with methods that make logical sense in the context of
366 | children of StreamField, but not necessarily elsewhere that BoundBlock is used
367 | """
368 |
369 | def __init__(self, *args, **kwargs):
370 | self.id = kwargs.pop('id')
371 | super(StreamValue.StreamChild, self).__init__(*args, **kwargs)
372 |
373 | @property
374 | def block_type(self):
375 | """
376 | Syntactic sugar so that we can say child.block_type instead of child.block.name.
377 | (This doesn't belong on BoundBlock itself because the idea of block.name denoting
378 | the child's "type" ('heading', 'paragraph' etc) is unique to StreamBlock, and in the
379 | wider context people are liable to confuse it with the block class (CharBlock etc).
380 | """
381 | return self.block.name
382 |
383 | def __init__(self, stream_block, stream_data, is_lazy=False, raw_text=None):
384 | """
385 | Construct a StreamValue linked to the given StreamBlock,
386 | with child values given in stream_data.
387 |
388 | Passing is_lazy=True means that stream_data is raw JSONish data as stored
389 | in the database, and needs to be converted to native values
390 | (using block.to_python()) when accessed. In this mode, stream_data is a
391 | list of dicts, each containing 'type' and 'value' keys.
392 |
393 | Passing is_lazy=False means that stream_data consists of immediately usable
394 | native values. In this mode, stream_data is a list of (type_name, value)
395 | or (type_name, value, id) tuples.
396 |
397 | raw_text exists solely as a way of representing StreamField content that is
398 | not valid JSON; this may legitimately occur if an existing text field is
399 | migrated to a StreamField. In this situation we return a blank StreamValue
400 | with the raw text accessible under the `raw_text` attribute, so that migration
401 | code can be rewritten to convert it as desired.
402 | """
403 | self.is_lazy = is_lazy
404 | self.stream_block = stream_block # the StreamBlock object that handles this value
405 | self.stream_data = stream_data # a list of (type_name, value) tuples
406 | self._bound_blocks = {} # populated lazily from stream_data as we access items through __getitem__
407 | self.raw_text = raw_text
408 |
409 | def __getitem__(self, i):
410 | if i not in self._bound_blocks:
411 | if self.is_lazy:
412 | raw_value = self.stream_data[i]
413 | type_name = raw_value['type']
414 | child_block = self.stream_block.child_blocks[type_name]
415 | if hasattr(child_block, 'bulk_to_python'):
416 | self._prefetch_blocks(type_name, child_block)
417 | return self._bound_blocks[i]
418 | else:
419 | value = child_block.to_python(raw_value['value'])
420 | block_id = raw_value.get('id')
421 | else:
422 | try:
423 | type_name, value, block_id = self.stream_data[i]
424 | except ValueError:
425 | type_name, value = self.stream_data[i]
426 | block_id = None
427 |
428 | child_block = self.stream_block.child_blocks[type_name]
429 |
430 | self._bound_blocks[i] = StreamValue.StreamChild(child_block, value, id=block_id)
431 |
432 | return self._bound_blocks[i]
433 |
434 | def _prefetch_blocks(self, type_name, child_block):
435 | """Prefetch all child blocks for the given `type_name` using the
436 | given `child_blocks`.
437 |
438 | This prevents n queries for n blocks of a specific type.
439 | """
440 | # create a mapping of all the child blocks matching the given block type,
441 | # mapping (index within the stream) => (raw block value)
442 | raw_values = OrderedDict(
443 | (i, item['value']) for i, item in enumerate(self.stream_data)
444 | if item['type'] == type_name
445 | )
446 | # pass the raw block values to bulk_to_python as a list
447 | converted_values = child_block.bulk_to_python(raw_values.values())
448 |
449 | # reunite the converted values with their stream indexes
450 | for i, value in zip(raw_values.keys(), converted_values):
451 | # also pass the block ID to StreamChild, if one exists for this stream index
452 | block_id = self.stream_data[i].get('id')
453 | self._bound_blocks[i] = StreamValue.StreamChild(child_block, value, id=block_id)
454 |
455 | def get_prep_value(self):
456 | prep_value = []
457 |
458 | for i, stream_data_item in enumerate(self.stream_data):
459 | if self.is_lazy and i not in self._bound_blocks:
460 | # This child has not been accessed as a bound block, so its raw JSONish
461 | # value (stream_data_item here) is still valid
462 | prep_value_item = stream_data_item
463 |
464 | # As this method is preparing this value to be saved to the database,
465 | # this is an appropriate place to ensure that each block has a unique id.
466 | prep_value_item['id'] = prep_value_item.get('id', str(uuid.uuid4()))
467 |
468 | else:
469 | # convert the bound block back into JSONish data
470 | child = self[i]
471 | # As this method is preparing this value to be saved to the database,
472 | # this is an appropriate place to ensure that each block has a unique id.
473 | child.id = child.id or str(uuid.uuid4())
474 | prep_value_item = {
475 | 'type': child.block.name,
476 | 'value': child.block.get_prep_value(child.value),
477 | 'id': child.id,
478 | }
479 |
480 | prep_value.append(prep_value_item)
481 |
482 | return prep_value
483 |
484 | def __eq__(self, other):
485 | if not isinstance(other, StreamValue):
486 | return False
487 |
488 | return self.stream_data == other.stream_data
489 |
490 | def __len__(self):
491 | return len(self.stream_data)
492 |
493 | def __repr__(self):
494 | return repr(list(self))
495 |
496 | def render_as_block(self, context=None):
497 | return self.stream_block.render(self, context=context)
498 |
499 | def __html__(self):
500 | return self.stream_block.render(self)
501 |
502 | def __str__(self):
503 | return self.__html__()
504 |
--------------------------------------------------------------------------------
/streamfield/blocks/struct_block.py:
--------------------------------------------------------------------------------
1 | import collections
2 |
3 | from django import forms
4 | from django.core.exceptions import ValidationError
5 | from django.forms.utils import ErrorList
6 | from django.template.loader import render_to_string
7 | from django.utils.functional import cached_property
8 | from django.utils.html import format_html, format_html_join
9 | from django.utils.safestring import mark_safe
10 |
11 | #from wagtail.admin.staticfiles import versioned_static
12 | from streamfield.utils import versioned_static
13 |
14 | from .base import Block, DeclarativeSubBlocksMetaclass
15 | from .utils import js_dict
16 |
17 | __all__ = ['BaseStructBlock', 'StructBlock', 'StructValue']
18 |
19 |
20 | class StructValue(collections.OrderedDict):
21 | """ A class that generates a StructBlock value from provided sub-blocks
22 | name->value
23 | """
24 | def __init__(self, block, *args):
25 | super().__init__(*args)
26 | self.block = block
27 |
28 | def __html__(self):
29 | return self.block.render(self)
30 |
31 | def render_as_block(self, context=None):
32 | return self.block.render(self, context=context)
33 |
34 | @cached_property
35 | def bound_blocks(self):
36 | return collections.OrderedDict([
37 | (name, block.bind(self.get(name)))
38 | for name, block in self.block.child_blocks.items()
39 | ])
40 |
41 |
42 | class BaseStructBlock(Block):
43 | '''
44 | local_blocks
45 |
46 | '''
47 | def __init__(self, local_blocks=None, **kwargs):
48 | self._constructor_kwargs = kwargs
49 |
50 | super().__init__(**kwargs)
51 |
52 | # create a local (shallow) copy of base_blocks so that it can be
53 | # supplemented by local_blocks
54 | self.child_blocks = self.base_blocks.copy()
55 |
56 | if local_blocks:
57 | for _name, block in local_blocks:
58 | # assert local blocks are instances
59 | if (not(isinstance(block, Block))):
60 | block = block()
61 | block.set_name(_name)
62 | self.child_blocks[_name] = block
63 |
64 | self.child_js_initializers = {}
65 | for name, block in self.child_blocks.items():
66 | js_initializer = block.js_initializer()
67 | if js_initializer is not None:
68 | self.child_js_initializers[name] = js_initializer
69 |
70 | self.dependencies = self.child_blocks.values()
71 |
72 | def get_default(self):
73 | """
74 | Any default value passed in the constructor or self.meta is going to be a dict
75 | rather than a StructValue; for consistency, we need to convert it to a StructValue
76 | for StructBlock to work with
77 | """
78 | return self._to_struct_value(self.meta.default.items())
79 |
80 | def js_initializer(self):
81 | # skip JS setup entirely if no children have js_initializers
82 | if not self.child_js_initializers:
83 | return None
84 |
85 | return "StructBlock(%s)" % js_dict(self.child_js_initializers)
86 |
87 | @property
88 | def media(self):
89 | return forms.Media(js=[versioned_static('streamfield/js/blocks/struct.js')])
90 |
91 | def get_form_context(self, value, prefix='', errors=None):
92 | if errors:
93 | if len(errors) > 1:
94 | # We rely on StructBlock.clean throwing a single ValidationError with a specially crafted
95 | # 'params' attribute that we can pull apart and distribute to the child blocks
96 | raise TypeError('StructBlock.render_form unexpectedly received multiple errors')
97 | error_dict = errors.as_data()[0].params
98 | else:
99 | error_dict = {}
100 |
101 | bound_child_blocks = collections.OrderedDict([
102 | (
103 | name,
104 | block.bind(value.get(name, block.get_default()),
105 | prefix="%s-%s" % (prefix, name), errors=error_dict.get(name))
106 | )
107 | for name, block in self.child_blocks.items()
108 | ])
109 |
110 | return {
111 | 'children': bound_child_blocks,
112 | 'help_text': getattr(self.meta, 'help_text', None),
113 | 'block_definition': self,
114 | 'prefix': prefix,
115 | }
116 |
117 | def render_form(self, value, prefix='', errors=None):
118 | context = self.get_form_context(value, prefix=prefix, errors=errors)
119 |
120 | return mark_safe(render_to_string(self.meta.form_template, context))
121 |
122 | def value_from_datadict(self, data, files, prefix):
123 | return self._to_struct_value([
124 | (name, block.value_from_datadict(data, files, '%s-%s' % (prefix, name)))
125 | for name, block in self.child_blocks.items()
126 | ])
127 |
128 | def value_omitted_from_data(self, data, files, prefix):
129 | return all(
130 | block.value_omitted_from_data(data, files, '%s-%s' % (prefix, name))
131 | for name, block in self.child_blocks.items()
132 | )
133 |
134 | def clean(self, value):
135 | result = [] # build up a list of (name, value) tuples to be passed to the StructValue constructor
136 | errors = {}
137 | for name, val in value.items():
138 | try:
139 | result.append((name, self.child_blocks[name].clean(val)))
140 | except ValidationError as e:
141 | errors[name] = ErrorList([e])
142 |
143 | if errors:
144 | # The message here is arbitrary - StructBlock.render_form will suppress it
145 | # and delegate the errors contained in the 'params' dict to the child blocks instead
146 | raise ValidationError('Validation error in StructBlock', params=errors)
147 |
148 | return self._to_struct_value(result)
149 |
150 | def to_python(self, value):
151 | """ Recursively call to_python on children and return as a StructValue """
152 | return self._to_struct_value([
153 | (
154 | name,
155 | (child_block.to_python(value[name]) if name in value else child_block.get_default())
156 | # NB the result of get_default is NOT passed through to_python, as it's expected
157 | # to be in the block's native type already
158 | )
159 | for name, child_block in self.child_blocks.items()
160 | ])
161 |
162 | def _to_struct_value(self, block_items):
163 | """ Return a Structvalue representation of the sub-blocks in this block """
164 | return self.meta.value_class(self, block_items)
165 |
166 | def get_prep_value(self, value):
167 | """ Recursively call get_prep_value on children and return as a plain dict """
168 | return dict([
169 | (name, self.child_blocks[name].get_prep_value(val))
170 | for name, val in value.items()
171 | ])
172 |
173 | # def pre_save_hook(self, field_value, value):
174 | # # child is a StreamChild instance
175 | # for name, value in value.items():
176 | # self.child_blocks[name].pre_save_hook(field_value, value)
177 |
178 | def get_searchable_content(self, value):
179 | content = []
180 |
181 | for name, block in self.child_blocks.items():
182 | content.extend(block.get_searchable_content(value.get(name, block.get_default())))
183 |
184 | return content
185 |
186 | def deconstruct(self):
187 | """
188 | Always deconstruct StructBlock instances as if they were plain StructBlocks with all of the
189 | field definitions passed to the constructor - even if in reality this is a subclass of StructBlock
190 | with the fields defined declaratively, or some combination of the two.
191 |
192 | This ensures that the field definitions get frozen into migrations, rather than leaving a reference
193 | to a custom subclass in the user's models.py that may or may not stick around.
194 | """
195 | path = 'streamfield.blocks.StructBlock'
196 | args = [list(self.child_blocks.items())]
197 | kwargs = self._constructor_kwargs
198 | return (path, args, kwargs)
199 |
200 | def check(self, **kwargs):
201 | errors = super().check(**kwargs)
202 | for name, child_block in self.child_blocks.items():
203 | errors.extend(child_block.check(**kwargs))
204 | errors.extend(child_block._check_name(**kwargs))
205 |
206 | return errors
207 |
208 | def render_basic(self, value, context=None):
209 | struct = format_html_join(
210 | '\n', '{0}',
211 | # [
212 | # (child.render(context=context),)
213 | # for child in value.values()
214 | # ]
215 | [
216 | (block.render(value[name], context),)
217 | for name, block in self.child_blocks.items()
218 | ]
219 | )
220 | return format_html('
{0}
',
221 | struct,
222 | self.render_css_classes(context)
223 | )
224 |
225 |
226 |
227 | class Meta:
228 | default = {}
229 | form_template = 'streamfield/block_forms/struct.html'
230 | value_class = StructValue
231 |
232 |
233 |
234 | class StructBlock(BaseStructBlock, metaclass=DeclarativeSubBlocksMetaclass):
235 | '''
236 | A fixed collection of not similar blocks.
237 | Compare to ListBlock, a collection of similar blocks,
238 | and StreamField, a not-fixed collection of not similar blocks.
239 | '''
240 | pass
241 |
242 |
243 |
244 |
245 |
--------------------------------------------------------------------------------
/streamfield/blocks/utils.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 |
4 | # helpers for Javascript expression formatting
5 | def indent(string, depth=1):
6 | """indent all non-empty lines of string by 'depth' 4-character tabs"""
7 | return re.sub(r'(^|\n)([^\n]+)', r'\g<1>' + (' ' * depth) + r'\g<2>', string)
8 |
9 |
10 | def js_dict(d):
11 | """
12 | Return a Javascript expression string for the dict 'd'.
13 | Keys are assumed to be strings consisting only of JS-safe characters, and will be quoted but not escaped;
14 | values are assumed to be valid Javascript expressions and will be neither escaped nor quoted (but will be
15 | wrapped in parentheses, in case some awkward git decides to use the comma operator...)
16 | """
17 | dict_items = [
18 | indent("'%s': (%s)" % (k, v))
19 | for (k, v) in d.items()
20 | ]
21 | return "{\n%s\n}" % ',\n'.join(dict_items)
22 |
--------------------------------------------------------------------------------
/streamfield/form_fields.py:
--------------------------------------------------------------------------------
1 | import re
2 | from urllib.parse import urlsplit, urlunsplit
3 | from django.core.exceptions import ValidationError
4 | from django.forms.widgets import TextInput
5 | from django.utils.translation import gettext_lazy as _, ngettext_lazy
6 | from django.forms.fields import CharField
7 | from streamfield.validators import RelURLValidator
8 |
9 | from streamfield.validators import validators
10 |
11 |
12 |
13 | class RelURLField(CharField):
14 | #NB URL Inputs are for absolute URLs. They'll potentially display
15 | # incorrect/unhelpful dropdowns for relative URLs.
16 | widget = TextInput
17 | default_error_messages = {
18 | 'invalid': _('Enter a valid URL.'),
19 | }
20 | default_validators = [RelURLValidator()]
21 |
22 | def __init__(self, **kwargs):
23 | super().__init__(strip=True, **kwargs)
24 |
25 | def to_python(self, value):
26 |
27 | def split_url(url):
28 | """
29 | Return a list of url parts via urlparse.urlsplit(), or raise
30 | ValidationError for some malformed URLs.
31 | """
32 | try:
33 | return list(urlsplit(url))
34 | except ValueError:
35 | # urlparse.urlsplit can raise a ValueError with some
36 | # misformatted URLs.
37 | raise ValidationError(self.error_messages['invalid'], code='invalid')
38 |
39 | value = super().to_python(value)
40 | if value:
41 | url_fields = split_url(value)
42 | if not url_fields[0]:
43 | # If no URL scheme given, assume http://
44 | #NB annoying. A one-line change
45 | #url_fields[0] = 'http'
46 | url_fields[0] = ''
47 | if not url_fields[1]:
48 | # Assume that if no domain is provided, that the path segment
49 | # contains the domain.
50 | url_fields[1] = url_fields[2]
51 | url_fields[2] = ''
52 |
53 | # Rebuild the url_fields list, since the domain segment may now
54 | # contain the path too.
55 | url_fields = split_url(urlunsplit(url_fields))
56 | value = urlunsplit(url_fields)
57 | return value
58 |
--------------------------------------------------------------------------------
/streamfield/forms/boundfields.py:
--------------------------------------------------------------------------------
1 | from django.forms.boundfield import BoundField
2 |
3 |
4 |
5 | class BoundFieldWithErrors(BoundField):
6 | '''
7 | Binds the form and value to a widget, but also the error list.
8 | Like a usual BoundForm, but passing the error list to the widget is
9 | new.
10 | '''
11 | def css_classes(self, extra_classes=None):
12 | r = super().css_classes(extra_classes=None)
13 | print('BoundFieldWithErrors css classes')
14 | print(str(r))
15 | return r
16 |
17 | # def value(self):
18 | # """
19 | # Return the value for this BoundField, using the initial value if
20 | # the form is not bound or the data otherwise.
21 | # """
22 | # data = self.initial
23 | # if self.form.is_bound:
24 | # data = self.field.bound_data(self.data, data)
25 | # return self.field.prepare_value(data)
26 |
27 | def as_widget(self, widget=None, attrs=None, only_initial=False):
28 | """
29 | Render the field by rendering the passed widget, adding any HTML
30 | attributes passed as attrs. If a widget isn't specified, use the
31 | field's default widget.
32 | """
33 | widget = widget or self.field.widget
34 | if self.field.localize:
35 | widget.is_localized = True
36 |
37 | # Tweaky. Calling the 'errors' property may cause a full_clean
38 | # with no protection against this completed. There's no
39 | # code guarentee.
40 | errors = None
41 | if (self.form.is_bound and (self.form._errors is not None)):
42 | errors = self.form._errors.get(self.name)
43 | attrs = attrs or {}
44 | attrs = self.build_widget_attrs(attrs, widget)
45 | if self.auto_id and 'id' not in widget.attrs:
46 | attrs.setdefault('id', self.html_initial_id if only_initial else self.auto_id)
47 | return widget.render(
48 | name=self.html_initial_name if only_initial else self.html_name,
49 | value=self.value(),
50 | attrs=attrs,
51 | errors=errors,
52 | renderer=self.form.renderer,
53 | )
54 |
--------------------------------------------------------------------------------
/streamfield/model_fields.py:
--------------------------------------------------------------------------------
1 | import json
2 | from django.db import models
3 | from django.core import checks, exceptions, validators
4 | from django.core.serializers.json import DjangoJSONEncoder
5 | from streamfield.blocks import (
6 | StreamValue,
7 | BlockField,
8 | Block,
9 | StreamBlock,
10 | ListBlock,
11 | CharBlock
12 | )
13 |
14 |
15 |
16 |
17 | # https://github.com/django/django/blob/64200c14e0072ba0ffef86da46b2ea82fd1e019a/django/db/models/fields/subclassing.py#L31-L44
18 | class Creator:
19 | """
20 | A placeholder class that provides a way to set the attribute on the model.
21 | """
22 | def __init__(self, field):
23 | self.field = field
24 |
25 | def __get__(self, obj, type=None):
26 | if obj is None:
27 | return self
28 | return obj.__dict__[self.field.name]
29 |
30 | def __set__(self, obj, value):
31 | obj.__dict__[self.field.name] = self.field.to_python(value)
32 |
33 |
34 |
35 | class StreamFieldBase(models.Field):
36 |
37 | def get_internal_type(self):
38 | return 'TextField'
39 |
40 | #def get_prep_value(self, value):
41 | # return json.dumps(self.root_block.get_prep_value(value), cls=DjangoJSONEncoder)
42 |
43 | def from_db_value(self, value, expression, connection):
44 | return self.to_python(value)
45 |
46 | def value_to_string(self, obj):
47 | value = self.value_from_object(obj)
48 | return self.get_prep_value(value)
49 |
50 |
51 |
52 | class StreamField(StreamFieldBase):
53 | # think this renders throug h
54 | # streamfield/stream_form/stream.html
55 | # Which includes a menu
56 | # streamfield/templates/streamfield/block_forms/stream_menu.html
57 | # embedded in,
58 | # streamfield/templates/streamfield/block_forms/ssequence.html
59 | block_types = []
60 |
61 | def __init__(self, block_types=[], **kwargs):
62 | super().__init__(**kwargs)
63 | # if isinstance(block_types, Block):
64 | # self.stream_block = block_types
65 | # elif isinstance(block_types, type):
66 | # self.stream_block = block_types(required=not self.blank)
67 | # else:
68 | # self.stream_block = StreamBlock(block_types, required=not self.blank)
69 | # assert the parameter, if given
70 | if (block_types):
71 | self.block_types = block_types
72 | self.block_types = list(self.block_types)
73 | self.root_block = StreamBlock(block_types, required=not self.blank)
74 |
75 | #def get_internal_type(self):
76 | # return 'TextField'
77 |
78 | def deconstruct(self):
79 | # Deconstruct will find all the usaul model field attributes.
80 | # It will also succeed in deconstructing block classes
81 | # returned to one of it's attributes.
82 | # But it will fail to note custom parameters.
83 | name, path, args, kwargs = super().deconstruct()
84 | block_types = list(self.root_block.child_blocks.items())
85 | kwargs['block_types'] = block_types
86 | return name, path, args, kwargs
87 |
88 | def to_python(self, value):
89 | if value is None or value == '':
90 | return StreamValue(self.root_block, [])
91 | elif isinstance(value, StreamValue):
92 | return value
93 | elif isinstance(value, str):
94 | try:
95 | unpacked_value = json.loads(value)
96 | except ValueError:
97 | # value is not valid JSON; most likely, this field was previously a
98 | # rich text field before being migrated to StreamField, and the data
99 | # was left intact in the migration. Return an empty stream instead
100 | # (but keep the raw text available as an attribute, so that it can be
101 | # used to migrate that data to StreamField)
102 | return StreamValue(self.root_block, [], raw_text=value)
103 |
104 | if unpacked_value is None:
105 | # we get here if value is the literal string 'null'. This should probably
106 | # never happen if the rest of the (de)serialization code is working properly,
107 | # but better to handle it just in case...
108 | return StreamValue(self.root_block, [])
109 |
110 | return self.root_block.to_python(unpacked_value)
111 | else:
112 | # See if it looks like the standard non-smart representation of a
113 | # StreamField value: a list of (block_name, value) tuples
114 | try:
115 | [None for (x, y) in value]
116 | except (TypeError, ValueError):
117 | # Give up trying to make sense of the value
118 | raise TypeError("Cannot handle %r (type %r) as a value of StreamField" % (value, type(value)))
119 |
120 | # Test succeeded, so return as a StreamValue-ified version of that value
121 | return StreamValue(self.root_block, value)
122 |
123 | def get_prep_value(self, value):
124 | if isinstance(value, StreamValue) and not(value) and value.raw_text is not None:
125 | # An empty StreamValue with a nonempty raw_text attribute should have that
126 | # raw_text attribute written back to the db. (This is probably only useful
127 | # for reverse migrations that convert StreamField data back into plain text
128 | # fields.)
129 | return value.raw_text
130 | else:
131 | return json.dumps(self.root_block.get_prep_value(value), cls=DjangoJSONEncoder)
132 |
133 | #def from_db_value(self, value, expression, connection):
134 | # return self.to_python(value)
135 |
136 | def formfield(self, **kwargs):
137 | '''
138 | Return a formfield for use with this model field.
139 | The default is BlockField with the root_block attribute from
140 | this class.
141 | '''
142 | #NB dont modify unleess you wish to subvert the entire
143 | # streamblocks render chain!
144 | defaults = {'form_class': BlockField, 'block': self.root_block}
145 | defaults.update(kwargs)
146 | return super().formfield(**defaults)
147 |
148 | #def value_to_string(self, obj):
149 | # value = self.value_from_object(obj)
150 | # return self.get_prep_value(value)
151 |
152 | def get_searchable_content(self, value):
153 | return self.root_block.get_searchable_content(value)
154 |
155 | def _check_block_spec(self, spec):
156 | errors = []
157 |
158 | # test the kv
159 | #NB init will ensure two values i.e. kv
160 | label = spec[0]
161 | block_class = spec[1]
162 | try:
163 | if (not(isinstance(block_class, Block))):
164 | raise TypeError
165 | except TypeError:
166 | #NB catch 'not a class' too
167 | errors.append(
168 | checks.Error(
169 | "'block_types' value must be subclass of blocks.Block. value:'{}'".format(
170 | block_class
171 | ),
172 | id='streamfield.model_fields.E004',
173 | )
174 | )
175 | return errors
176 |
177 | # fails in init before this?
178 | def _check_block_types(self, **kwargs):
179 | if (not self.block_types):
180 | return [
181 | checks.Warning(
182 | "'block_types' attribute is empty, so offers no blocks.",
183 | id='streamfield.model_fields.W001',
184 | )
185 | ]
186 | try:
187 | block_specs = list(self.block_types)
188 | except TypeError:
189 | return [
190 | checks.Error(
191 | "'block_types' structure must cast to a list.",
192 | id='streamfield.model_fields.E001',
193 | )
194 | ]
195 | errors = []
196 | #for bs in self.block_types:
197 | # errors.extend(self._check_block_spec(bs))
198 | keys = [kv[0] for kv in self.block_types]
199 | if len(block_specs) != len(set(keys)):
200 | return [
201 | checks.Error(
202 | "'block_types' value contains duplicate field(s).",
203 | id='admin.E002',
204 | )
205 | ]
206 | return errors
207 |
208 | #! to be extended, maybe with checks in other class
209 | def check(self, **kwargs):
210 | errors = super().check(**kwargs)
211 | errors.extend(self._check_block_types(**kwargs))
212 | errors.extend(self.root_block.check(field=self, **kwargs))
213 | return errors
214 |
215 | def contribute_to_class(self, cls, name, **kwargs):
216 | super().contribute_to_class(cls, name, **kwargs)
217 |
218 | # Add Creator descriptor to allow the field to be set from a list or a
219 | # JSON string.
220 | setattr(cls, self.name, Creator(self))
221 |
222 |
223 |
224 |
225 | class ListFieldBase(StreamFieldBase):
226 |
227 | def __init__(self,
228 | child_block,
229 | element_template,
230 | wrap_template,
231 | **kwargs
232 | ):
233 | super().__init__(**kwargs)
234 | #self.block_type = block_type
235 | #if (block_type):
236 | # self.block_type = block_type
237 |
238 | self.root_block = ListBlock(
239 | child_block,
240 | element_template,
241 | wrap_template,
242 | required=not self.blank
243 | )
244 |
245 | # def deconstruct(self):
246 | # name, path, args, kwargs = super().deconstruct()
247 | # kwargs['block_type'] = self.root_block.child_block
248 | # return name, path, args, kwargs
249 |
250 | def to_python(self, value):
251 | if value is None or value == '':
252 | return []
253 | elif isinstance(value, list):
254 | return value
255 | elif isinstance(value, str):
256 | try:
257 | unpacked_value = json.loads(value)
258 | except ValueError:
259 | # value is not valid JSON; most likely, this field was previously a
260 | # text field before being migrated, and the data
261 | # was left intact in the migration. Return an empty stream instead
262 | # (but keep the raw text available as an attribute, so that it can be
263 | # used to migrate that data to StreamField)
264 | return [value]
265 |
266 | if unpacked_value is None:
267 | # we get here if value is the literal string 'null'. This should probably
268 | # never happen if the rest of the (de)serialization code is working properly,
269 | # but better to handle it just in case...
270 | return []
271 |
272 | return self.root_block.to_python(unpacked_value)
273 | else:
274 | raise TypeError("Cannot handle %r (type %r) as a value of ListField" % (value, type(value)))
275 |
276 | def get_prep_value(self, value):
277 | return json.dumps(self.root_block.get_prep_value(value), cls=DjangoJSONEncoder)
278 |
279 | def formfield(self, **kwargs):
280 | '''
281 | Return a formfield for use with this model field.
282 | The default is BlockField with the stream.block attribute from
283 | this class.
284 | '''
285 | #NB dont modify unleess you wish to subvert the entire
286 | # streamblocks render chain!
287 | defaults = {'form_class': BlockField, 'block': self.root_block}
288 | defaults.update(kwargs)
289 |
290 | return super().formfield(**defaults)
291 |
292 |
293 |
294 | class ListField(ListFieldBase):
295 | block_type = CharBlock
296 |
297 | def __init__(self,
298 | block_type=CharBlock,
299 | html_ordered=False,
300 | **kwargs
301 | ):
302 | self.block_type = block_type or self.block_type
303 | self.html_ordered = html_ordered
304 | wrap_template = "
",
346 | **kwargs
347 | )
348 |
349 | def deconstruct(self):
350 | name, path, args, kwargs = super().deconstruct()
351 | kwargs['term_block'] = self.term_block
352 | kwargs['definition_block'] = self.definition_block
353 | return name, path, args, kwargs
354 |
--------------------------------------------------------------------------------
/streamfield/static/streamfield/css/streamfield.css:
--------------------------------------------------------------------------------
1 | .c-sf-container {
2 | margin-left:160px;
3 | padding: 0 10px;
4 | }
5 |
6 | .c-sf-block__header button,
7 | .c-sf-add-panel button {
8 | padding: 3px 12px;
9 | margin-left: 5px;
10 | }
11 |
12 | .c-sf-add-panel button {
13 | background-color: #8fabeb;
14 | }
15 |
16 | .c-sf-add-panel button:hover {
17 | background: #7f9bda;
18 | }
19 |
20 | .c-sf-block__header {
21 | margin-top: 3px;
22 | }
23 |
24 | .c-sf-block__content {
25 | padding: 3px 0 12px 0;
26 | }
27 |
28 | /* Django style-killer */
29 | .c-sf-block__content label {
30 | float: none;
31 | width:4rem;
32 | }
33 |
34 | .c-sf-block__content .field__label,
35 | .c-sf-block__content .field__content {
36 | display:inline-block;
37 | }
38 |
39 | .aligned ul.errors-block {
40 | margin: 0 0 4px;
41 | padding: 0;
42 | color: #ba2121;
43 | background: #fff;
44 | }
45 |
46 | ul.errors-block li {
47 | font-size: 13px;
48 | display: block;
49 | margin-bottom: 4px;
50 | }
51 |
52 | .field__content .help {
53 | margin-left: 0;
54 | padding-left: 0;
55 | }
56 |
--------------------------------------------------------------------------------
/streamfield/static/streamfield/js/blocks/list.js:
--------------------------------------------------------------------------------
1 | (function($) {
2 | window.ListBlock = function(opts) {
3 | /* contents of 'opts':
4 | definitionPrefix (required)
5 | childInitializer (optional) - JS initializer function for each child
6 | */
7 | var listMemberTemplate = $('#' + opts.definitionPrefix + '-newmember').text();
8 |
9 | return function(elementPrefix) {
10 | var sequence = django.Sequence({
11 | prefix: elementPrefix,
12 | onInitializeMember: function(sequenceMember) {
13 | /* initialize child block's JS behaviour */
14 | if (opts.childInitializer) {
15 | opts.childInitializer(sequenceMember.prefix + '-value');
16 | }
17 |
18 | /* initialise delete button */
19 | $('#' + sequenceMember.prefix + '-delete').on('click', function() {
20 | sequenceMember.delete();
21 | });
22 |
23 | /* initialise move up/down buttons */
24 | $('#' + sequenceMember.prefix + '-moveup').on('click', function() {
25 | sequenceMember.moveUp();
26 | });
27 |
28 | $('#' + sequenceMember.prefix + '-movedown').on('click', function() {
29 | sequenceMember.moveDown();
30 | });
31 | },
32 |
33 | onEnableMoveUp: function(sequenceMember) {
34 | $('#' + sequenceMember.prefix + '-moveup').removeClass('disabled');
35 | },
36 |
37 | onDisableMoveUp: function(sequenceMember) {
38 | $('#' + sequenceMember.prefix + '-moveup').addClass('disabled');
39 | },
40 |
41 | onEnableMoveDown: function(sequenceMember) {
42 | $('#' + sequenceMember.prefix + '-movedown').removeClass('disabled');
43 | },
44 |
45 | onDisableMoveDown: function(sequenceMember) {
46 | $('#' + sequenceMember.prefix + '-movedown').addClass('disabled');
47 | }
48 | });
49 |
50 | /* initialize 'add' button */
51 | $('#' + elementPrefix + '-add').on('click', function() {
52 | sequence.insertMemberAtEnd(listMemberTemplate);
53 | });
54 | };
55 | };
56 | })(django.jQuery);
57 |
--------------------------------------------------------------------------------
/streamfield/static/streamfield/js/blocks/sequence.js:
--------------------------------------------------------------------------------
1 | /*
2 | Operations on a sequence of items, common to both ListBlock and
3 | StreamBlock.
4 |
5 | These assume the presence of a container element named
6 | "{prefix}-container" for each list item, and certain hidden fields such
7 | as "{prefix}-deleted" as defined in sequence_member.html, but make no
8 | assumptions about layout or visible controls within the block.
9 |
10 | For example, they don't assume the presence of a 'delete' button - it's
11 | up to the specific subclass (list.js / stream.js) to attach this to the
12 | SequenceMember.delete method.
13 |
14 | CODE FOR SETTING UP SPECIFIC UI WIDGETS, SUCH AS DELETE BUTTONS OR
15 | MENUS, DOES NOT BELONG HERE.
16 | */
17 | (function($) {
18 | django.SequenceMember = function(sequence, prefix) {
19 | var self = {};
20 | self.prefix = prefix;
21 | self.container = $('#' + self.prefix + '-container');
22 |
23 | var indexField = $('#' + self.prefix + '-order');
24 |
25 | self.delete = function() {
26 | sequence.deleteMember(self);
27 | };
28 |
29 | self.prependMember = function(template) {
30 | sequence.insertMemberBefore(self, template);
31 | };
32 |
33 | self.appendMember = function(template) {
34 | sequence.insertMemberAfter(self, template);
35 | };
36 |
37 | self.moveUp = function() {
38 | sequence.moveMemberUp(self);
39 | };
40 |
41 | self.moveDown = function() {
42 | sequence.moveMemberDown(self);
43 | };
44 |
45 | self._markDeleted = function() {
46 | /* set this list member's hidden 'deleted' flag to true */
47 | $('#' + self.prefix + '-deleted').val('1');
48 | /* hide the list item */
49 | self.container.slideUp().dequeue().fadeOut();
50 | };
51 |
52 | self._markAdded = function() {
53 | self.container.hide();
54 | self.container.slideDown();
55 |
56 | // focus first suitable input found
57 | var timeout = setTimeout(function() {
58 | var $input = $('.input', self.container);
59 | var $firstField = $('input, textarea, [data-hallo-editor], [data-draftail-input]', $input).first();
60 |
61 | if ($firstField.is('[data-draftail-input]')) {
62 | $firstField.get(0).draftailEditor.focus();
63 | } else {
64 | $firstField.trigger('focus');
65 | }
66 | }, 250);
67 | };
68 |
69 | self.getIndex = function() {
70 | return parseInt(indexField.val(), 10);
71 | };
72 |
73 | self.setIndex = function(i) {
74 | indexField.val(i);
75 | };
76 |
77 | return self;
78 | };
79 |
80 | django.Sequence = function(opts) {
81 | var self = {};
82 | var list = $('#' + opts.prefix + '-list');
83 | var countField = $('#' + opts.prefix + '-count');
84 | /* NB countField includes deleted items; for the count of non-deleted items, use members.length */
85 | var members = [];
86 |
87 | self.getCount = function() {
88 | return parseInt(countField.val(), 10);
89 | };
90 |
91 | function getNewMemberPrefix() {
92 | /* Update the counter and use it to create a prefix for the new list member */
93 | var newIndex = self.getCount();
94 | countField.val(newIndex + 1);
95 | return opts.prefix + '-' + newIndex;
96 | }
97 |
98 | function setInitialMoveUpDownState(newMember) {
99 | }
100 |
101 | function postInsertMember(newMember) {
102 | /* run any supplied initializer functions */
103 | if (opts.onInitializeMember) {
104 | opts.onInitializeMember(newMember);
105 | }
106 |
107 | var index = newMember.getIndex();
108 | if (index === 0) {
109 | /* first item should have 'move up' disabled */
110 | if (opts.onDisableMoveUp) opts.onDisableMoveUp(newMember);
111 | } else {
112 | if (opts.onEnableMoveUp) opts.onEnableMoveUp(newMember);
113 | }
114 |
115 | if (index === (members.length - 1)) {
116 | /* last item should have 'move down' disabled */
117 | if (opts.onDisableMoveDown) opts.onDisableMoveDown(newMember);
118 | } else {
119 | if (opts.onEnableMoveDown) opts.onEnableMoveDown(newMember);
120 | }
121 |
122 | newMember._markAdded();
123 | }
124 |
125 | function elementFromTemplate(template, newPrefix) {
126 | /* generate a jquery object ready to be inserted into the list, based on the passed HTML template string.
127 | '__PREFIX__' will be substituted with newPrefix, and script tags escaped as <-/script> will be un-escaped */
128 | return $(template.replace(/__PREFIX__/g, newPrefix).replace(/<-(-*)\/script>/g, '<$1/script>'));
129 | }
130 |
131 | self.insertMemberBefore = function(otherMember, template) {
132 | var newMemberPrefix = getNewMemberPrefix();
133 |
134 | /* Create the new list member element with the real prefix substituted in */
135 | var elem = elementFromTemplate(template, newMemberPrefix);
136 | otherMember.container.before(elem);
137 | var newMember = django.SequenceMember(self, newMemberPrefix);
138 | var index = otherMember.getIndex();
139 |
140 | /* bump up index of otherMember and subsequent members */
141 | for (var i = index; i < members.length; i++) {
142 | members[i].setIndex(i + 1);
143 | }
144 |
145 | members.splice(index, 0, newMember);
146 | newMember.setIndex(index);
147 |
148 | postInsertMember(newMember);
149 |
150 | if (index === 0 && opts.onEnableMoveUp) {
151 | /* other member can now move up */
152 | opts.onEnableMoveUp(otherMember);
153 | }
154 |
155 | return newMember;
156 | };
157 |
158 | self.insertMemberAfter = function(otherMember, template) {
159 | var newMemberPrefix = getNewMemberPrefix();
160 |
161 | /* Create the new list member element with the real prefix substituted in */
162 | var elem = elementFromTemplate(template, newMemberPrefix);
163 | otherMember.container.after(elem);
164 | var newMember = django.SequenceMember(self, newMemberPrefix);
165 | var index = otherMember.getIndex() + 1;
166 |
167 | /* bump up index of subsequent members */
168 | for (var i = index; i < members.length; i++) {
169 | members[i].setIndex(i + 1);
170 | }
171 |
172 | members.splice(index, 0, newMember);
173 | newMember.setIndex(index);
174 |
175 | postInsertMember(newMember);
176 |
177 | if (index === (members.length - 1) && opts.onEnableMoveDown) {
178 | /* other member can now move down */
179 | opts.onEnableMoveDown(otherMember);
180 | }
181 |
182 | return newMember;
183 | };
184 |
185 | self.insertMemberAtStart = function(template) {
186 | /* NB we can't just do
187 | self.insertMemberBefore(members[0], template)
188 | because that won't work for initially empty lists
189 | */
190 | var newMemberPrefix = getNewMemberPrefix();
191 |
192 | /* Create the new list member element with the real prefix substituted in */
193 | var elem = elementFromTemplate(template, newMemberPrefix);
194 | list.prepend(elem);
195 | var newMember = django.SequenceMember(self, newMemberPrefix);
196 |
197 | /* bump up index of all other members */
198 | for (var i = 0; i < members.length; i++) {
199 | members[i].setIndex(i + 1);
200 | }
201 |
202 | members.unshift(newMember);
203 | newMember.setIndex(0);
204 |
205 | postInsertMember(newMember);
206 |
207 | if (members.length > 1 && opts.onEnableMoveUp) {
208 | /* previous first member can now move up */
209 | opts.onEnableMoveUp(members[1]);
210 | }
211 |
212 | return newMember;
213 | };
214 |
215 | self.insertMemberAtEnd = function(template) {
216 | var newMemberPrefix = getNewMemberPrefix();
217 |
218 | /* Create the new list member element with the real prefix substituted in */
219 | var elem = elementFromTemplate(template, newMemberPrefix);
220 | list.append(elem);
221 | var newMember = django.SequenceMember(self, newMemberPrefix);
222 |
223 | newMember.setIndex(members.length);
224 | members.push(newMember);
225 |
226 | postInsertMember(newMember);
227 |
228 | if (members.length > 1 && opts.onEnableMoveDown) {
229 | /* previous last member can now move down */
230 | opts.onEnableMoveDown(members[members.length - 2]);
231 | }
232 |
233 | return newMember;
234 | };
235 |
236 | self.deleteMember = function(member) {
237 | var index = member.getIndex();
238 | /* reduce index numbers of subsequent members */
239 | for (var i = index + 1; i < members.length; i++) {
240 | members[i].setIndex(i - 1);
241 | }
242 | /* remove from the 'members' list */
243 | members.splice(index, 1);
244 | member._markDeleted();
245 |
246 | if (index === 0 && members.length > 0 && opts.onDisableMoveUp) {
247 | /* deleting the first member; the new first member cannot move up now */
248 | opts.onDisableMoveUp(members[0]);
249 | }
250 |
251 | if (index === members.length && members.length > 0 && opts.onDisableMoveDown) {
252 | /* deleting the last member; the new last member cannot move down now */
253 | opts.onDisableMoveDown(members[members.length - 1]);
254 | }
255 | };
256 |
257 | self.moveMemberUp = function(member) {
258 | var oldIndex = member.getIndex();
259 | if (oldIndex > 0) {
260 | var newIndex = oldIndex - 1;
261 | var swappedMember = members[newIndex];
262 |
263 | members[newIndex] = member;
264 | member.setIndex(newIndex);
265 |
266 | members[oldIndex] = swappedMember;
267 | swappedMember.setIndex(oldIndex);
268 |
269 | member.container.insertBefore(swappedMember.container);
270 |
271 | if (newIndex === 0) {
272 | /*
273 | member is now the first member and cannot move up further;
274 | swappedMember is no longer the first member, and CAN move up
275 | */
276 | if (opts.onDisableMoveUp) opts.onDisableMoveUp(member);
277 | if (opts.onEnableMoveUp) opts.onEnableMoveUp(swappedMember);
278 | }
279 |
280 | if (oldIndex === (members.length - 1)) {
281 | /*
282 | member was previously the last member, and can now move down;
283 | swappedMember is now the last member, and cannot move down
284 | */
285 | if (opts.onEnableMoveDown) opts.onEnableMoveDown(member);
286 | if (opts.onDisableMoveDown) opts.onDisableMoveDown(swappedMember);
287 | }
288 | }
289 | };
290 |
291 | self.moveMemberDown = function(member) {
292 | var oldIndex = member.getIndex();
293 | if (oldIndex < (members.length - 1)) {
294 | var newIndex = oldIndex + 1;
295 | var swappedMember = members[newIndex];
296 |
297 | members[newIndex] = member;
298 | member.setIndex(newIndex);
299 |
300 | members[oldIndex] = swappedMember;
301 | swappedMember.setIndex(oldIndex);
302 |
303 | member.container.insertAfter(swappedMember.container);
304 |
305 | if (newIndex === (members.length - 1)) {
306 | /*
307 | member is now the last member and cannot move down further;
308 | swappedMember is no longer the last member, and CAN move down
309 | */
310 | if (opts.onDisableMoveDown) opts.onDisableMoveDown(member);
311 | if (opts.onEnableMoveDown) opts.onEnableMoveDown(swappedMember);
312 | }
313 |
314 | if (oldIndex === 0) {
315 | /*
316 | member was previously the first member, and can now move up;
317 | swappedMember is now the first member, and cannot move up
318 | */
319 | if (opts.onEnableMoveUp) opts.onEnableMoveUp(member);
320 | if (opts.onDisableMoveUp) opts.onDisableMoveUp(swappedMember);
321 | }
322 | }
323 | };
324 |
325 | /* initialize initial list members */
326 | var count = self.getCount();
327 | for (var i = 0; i < count; i++) {
328 | var memberPrefix = opts.prefix + '-' + i;
329 | var sequenceMember = django.SequenceMember(self, memberPrefix);
330 | members[i] = sequenceMember;
331 | if (opts.onInitializeMember) {
332 | opts.onInitializeMember(sequenceMember);
333 | }
334 |
335 | if (i === 0) {
336 | /* first item should have 'move up' disabled */
337 | if (opts.onDisableMoveUp) opts.onDisableMoveUp(sequenceMember);
338 | } else {
339 | if (opts.onEnableMoveUp) opts.onEnableMoveUp(sequenceMember);
340 | }
341 |
342 | if (i === (count - 1)) {
343 | /* last item should have 'move down' disabled */
344 | if (opts.onDisableMoveDown) opts.onDisableMoveDown(sequenceMember);
345 | } else {
346 | if (opts.onEnableMoveDown) opts.onEnableMoveDown(sequenceMember);
347 | }
348 | }
349 |
350 | return self;
351 | };
352 | })(django.jQuery);
353 |
--------------------------------------------------------------------------------
/streamfield/static/streamfield/js/blocks/stream.js:
--------------------------------------------------------------------------------
1 | //jQuery.noConflict();
2 | (function($) {
3 | var StreamBlockMenu = function(opts) {
4 | /*
5 | Helper object to handle the menu of available block types.
6 | Options:
7 | childBlocks: list of block definitions (same as passed to StreamBlock)
8 | id: ID of the container element (the one around 'c-sf-add-panel')
9 | onChooseBlock: callback fired when a block type is chosen -
10 | the corresponding childBlock is passed as a parameter
11 | */
12 | var self = {};
13 | self.container = $('#' + opts.id);
14 | self.openCloseButton = $('#' + opts.id + '-openclose');
15 |
16 | if (self.container.hasClass('stream-menu-closed')) {
17 | self.container.hide();
18 | }
19 |
20 | self.show = function() {
21 | self.container.slideDown();
22 | self.container.removeClass('stream-menu-closed');
23 | self.container.attr('aria-hidden', 'false');
24 | self.openCloseButton.addClass('c-sf-add-button--close');
25 | };
26 |
27 | self.hide = function() {
28 | self.container.slideUp();
29 | self.container.addClass('stream-menu-closed');
30 | self.container.attr('aria-hidden', 'true');
31 | self.openCloseButton.removeClass('c-sf-add-button--close');
32 | };
33 |
34 | self.addFirstBlock = function() {
35 | if (opts.onChooseBlock) opts.onChooseBlock(opts.childBlocks[0]);
36 | };
37 |
38 | self.toggle = function() {
39 | if (self.container.hasClass('stream-menu-closed')) {
40 | if (opts.childBlocks.length == 1) {
41 | /* If there's only one block type, add it automatically */
42 | self.addFirstBlock();
43 | } else {
44 | self.show();
45 | }
46 | } else {
47 | self.hide();
48 | }
49 | };
50 |
51 | /* set up show/hide on click behaviour */
52 | self.openCloseButton.on('click', function(e) {
53 | e.preventDefault();
54 | self.toggle();
55 | });
56 |
57 | /* set up button behaviour */
58 | $.each(opts.childBlocks, function(i, childBlock) {
59 | var button = self.container.find('.action-add-block-' + childBlock.name);
60 | button.on('click', function() {
61 | if (opts.onChooseBlock) opts.onChooseBlock(childBlock);
62 | self.hide();
63 | });
64 | });
65 |
66 | return self;
67 | };
68 |
69 | window.StreamBlock = function(opts) {
70 | /* Fetch the HTML template strings to be used when adding a new block of each type.
71 | Also reorganise the opts.childBlocks list into a lookup by name
72 |
73 | This is run on page load.
74 | */
75 | var listMemberTemplates = {};
76 | var childBlocksByName = {};
77 | for (var i = 0; i < opts.childBlocks.length; i++) {
78 | var childBlock = opts.childBlocks[i];
79 | childBlocksByName[childBlock.name] = childBlock;
80 | /* blockdef-14-newmember-date */
81 | var template = $('#' + opts.definitionPrefix + '-newmember-' + childBlock.name).text();
82 | listMemberTemplates[childBlock.name] = template;
83 | }
84 |
85 | /* The function returned is called by the snippet in
86 | Block.render_with_errors()
87 |
88 | The elementPrefix is the field name in the model.
89 | */
90 | return function(elementPrefix) {
91 | var sequence = django.Sequence({
92 | prefix: elementPrefix,
93 | onInitializeMember: function(sequenceMember) {
94 | /* initialize child block's JS behaviour */
95 | /*
96 | '#stream-0-type' might find 'title', the name in
97 | the model definition
98 | */
99 | var blockTypeName = $('#' + sequenceMember.prefix + '-type').val();
100 | var blockOpts = childBlocksByName[blockTypeName];
101 | if (blockOpts.initializer) {
102 | /* the child block's own elements have the prefix '{list member prefix}-value' */
103 | blockOpts.initializer(sequenceMember.prefix + '-value');
104 | }
105 | /* initialize delete button */
106 | $('#' + sequenceMember.prefix + '-delete').on('click', function() {
107 | sequenceMember.delete();
108 | });
109 |
110 | /* initialise move up/down buttons */
111 | $('#' + sequenceMember.prefix + '-moveup').on('click', function() {
112 | sequenceMember.moveUp();
113 | });
114 |
115 | $('#' + sequenceMember.prefix + '-movedown').on('click', function() {
116 | sequenceMember.moveDown();
117 | });
118 |
119 | /* Set up the 'append a block' menu that appears after the block */
120 | StreamBlockMenu({
121 | childBlocks: opts.childBlocks,
122 | id: sequenceMember.prefix + '-appendmenu',
123 | onChooseBlock: function(childBlock) {
124 | var template = listMemberTemplates[childBlock.name];
125 | sequenceMember.appendMember(template);
126 | }
127 | });
128 | },
129 |
130 | onEnableMoveUp: function(sequenceMember) {
131 | $('#' + sequenceMember.prefix + '-moveup').removeClass('disabled');
132 | },
133 |
134 | onDisableMoveUp: function(sequenceMember) {
135 | $('#' + sequenceMember.prefix + '-moveup').addClass('disabled');
136 | },
137 |
138 | onEnableMoveDown: function(sequenceMember) {
139 | $('#' + sequenceMember.prefix + '-movedown').removeClass('disabled');
140 | },
141 |
142 | onDisableMoveDown: function(sequenceMember) {
143 | $('#' + sequenceMember.prefix + '-movedown').addClass('disabled');
144 | }
145 | });
146 |
147 | /* Set up the 'prepend a block' menu that appears above the first block in the sequence */
148 | StreamBlockMenu({
149 | childBlocks: opts.childBlocks,
150 | id: elementPrefix + '-prependmenu',
151 | onChooseBlock: function(childBlock) {
152 | var template = listMemberTemplates[childBlock.name];
153 | sequence.insertMemberAtStart(template);
154 | }
155 | });
156 | };
157 | };
158 | })(django.jQuery);
159 |
--------------------------------------------------------------------------------
/streamfield/static/streamfield/js/blocks/struct.js:
--------------------------------------------------------------------------------
1 | window.StructBlock = function(childInitializersByName) {
2 | return function(prefix) {
3 | for (var childName in childInitializersByName) {
4 | var childInitializer = childInitializersByName[childName];
5 | var childPrefix = prefix + '-' + childName;
6 | childInitializer(childPrefix);
7 | }
8 | };
9 | };
10 |
--------------------------------------------------------------------------------
/streamfield/static/streamfield/js/vendor/jquery.autosize.js:
--------------------------------------------------------------------------------
1 | /*!
2 | Autosize 3.0.4
3 | license: MIT
4 | http://www.jacklmoore.com/autosize
5 | */
6 | (function (global, factory) {
7 | if (typeof define === 'function' && define.amd) {
8 | define(['exports', 'module'], factory);
9 | } else if (typeof exports !== 'undefined' && typeof module !== 'undefined') {
10 | factory(exports, module);
11 | } else {
12 | var mod = {
13 | exports: {}
14 | };
15 | factory(mod.exports, mod);
16 | global.autosize = mod.exports;
17 | }
18 | })(this, function (exports, module) {
19 | 'use strict';
20 |
21 | function assign(ta) {
22 | var _ref = arguments[1] === undefined ? {} : arguments[1];
23 |
24 | var _ref$setOverflowX = _ref.setOverflowX;
25 | var setOverflowX = _ref$setOverflowX === undefined ? true : _ref$setOverflowX;
26 | var _ref$setOverflowY = _ref.setOverflowY;
27 | var setOverflowY = _ref$setOverflowY === undefined ? true : _ref$setOverflowY;
28 |
29 | if (!ta || !ta.nodeName || ta.nodeName !== 'TEXTAREA' || ta.hasAttribute('data-autosize-on')) {
30 | return;
31 | }var heightOffset = null;
32 | var overflowY = 'hidden';
33 |
34 | function init() {
35 | var style = window.getComputedStyle(ta, null);
36 |
37 | if (style.resize === 'vertical') {
38 | ta.style.resize = 'none';
39 | } else if (style.resize === 'both') {
40 | ta.style.resize = 'horizontal';
41 | }
42 |
43 | if (style.boxSizing === 'content-box') {
44 | heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom));
45 | } else {
46 | heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
47 | }
48 |
49 | update();
50 | }
51 |
52 | function changeOverflow(value) {
53 | {
54 | // Chrome/Safari-specific fix:
55 | // When the textarea y-overflow is hidden, Chrome/Safari do not reflow the text to account for the space
56 | // made available by removing the scrollbar. The following forces the necessary text reflow.
57 | var width = ta.style.width;
58 | ta.style.width = '0px';
59 | // Force reflow:
60 | /* jshint ignore:start */
61 | ta.offsetWidth;
62 | /* jshint ignore:end */
63 | ta.style.width = width;
64 | }
65 |
66 | overflowY = value;
67 |
68 | if (setOverflowY) {
69 | ta.style.overflowY = value;
70 | }
71 |
72 | update();
73 | }
74 |
75 | function update() {
76 | var startHeight = ta.style.height;
77 | var htmlTop = document.documentElement.scrollTop;
78 | var bodyTop = document.body.scrollTop;
79 | var originalHeight = ta.style.height;
80 |
81 | ta.style.height = 'auto';
82 |
83 | var endHeight = ta.scrollHeight + heightOffset;
84 |
85 | if (ta.scrollHeight === 0) {
86 | // If the scrollHeight is 0, then the element probably has display:none or is detached from the DOM.
87 | ta.style.height = originalHeight;
88 | return;
89 | }
90 |
91 | ta.style.height = endHeight + 'px';
92 |
93 | // prevents scroll-position jumping
94 | document.documentElement.scrollTop = htmlTop;
95 | document.body.scrollTop = bodyTop;
96 |
97 | var style = window.getComputedStyle(ta, null);
98 |
99 | if (style.height !== ta.style.height) {
100 | if (overflowY !== 'visible') {
101 | changeOverflow('visible');
102 | return;
103 | }
104 | } else {
105 | if (overflowY !== 'hidden') {
106 | changeOverflow('hidden');
107 | return;
108 | }
109 | }
110 |
111 | if (startHeight !== ta.style.height) {
112 | var evt = document.createEvent('Event');
113 | evt.initEvent('autosize:resized', true, false);
114 | ta.dispatchEvent(evt);
115 | }
116 | }
117 |
118 | var destroy = (function (style) {
119 | window.removeEventListener('resize', update);
120 | ta.removeEventListener('input', update);
121 | ta.removeEventListener('keyup', update);
122 | ta.removeAttribute('data-autosize-on');
123 | ta.removeEventListener('autosize:destroy', destroy);
124 |
125 | Object.keys(style).forEach(function (key) {
126 | ta.style[key] = style[key];
127 | });
128 | }).bind(ta, {
129 | height: ta.style.height,
130 | resize: ta.style.resize,
131 | overflowY: ta.style.overflowY,
132 | overflowX: ta.style.overflowX,
133 | wordWrap: ta.style.wordWrap });
134 |
135 | ta.addEventListener('autosize:destroy', destroy);
136 |
137 | // IE9 does not fire onpropertychange or oninput for deletions,
138 | // so binding to onkeyup to catch most of those events.
139 | // There is no way that I know of to detect something like 'cut' in IE9.
140 | if ('onpropertychange' in ta && 'oninput' in ta) {
141 | ta.addEventListener('keyup', update);
142 | }
143 |
144 | window.addEventListener('resize', update);
145 | ta.addEventListener('input', update);
146 | ta.addEventListener('autosize:update', update);
147 | ta.setAttribute('data-autosize-on', true);
148 |
149 | if (setOverflowY) {
150 | ta.style.overflowY = 'hidden';
151 | }
152 | if (setOverflowX) {
153 | ta.style.overflowX = 'hidden';
154 | ta.style.wordWrap = 'break-word';
155 | }
156 |
157 | init();
158 | }
159 |
160 | function destroy(ta) {
161 | if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) {
162 | return;
163 | }var evt = document.createEvent('Event');
164 | evt.initEvent('autosize:destroy', true, false);
165 | ta.dispatchEvent(evt);
166 | }
167 |
168 | function update(ta) {
169 | if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) {
170 | return;
171 | }var evt = document.createEvent('Event');
172 | evt.initEvent('autosize:update', true, false);
173 | ta.dispatchEvent(evt);
174 | }
175 |
176 | var autosize = null;
177 |
178 | // Do nothing in IE8 or lower
179 | if (typeof window.getComputedStyle !== 'function') {
180 | autosize = function (el) {
181 | return el;
182 | };
183 | autosize.destroy = function (el) {
184 | return el;
185 | };
186 | autosize.update = function (el) {
187 | return el;
188 | };
189 | } else {
190 | autosize = function (el, options) {
191 | if (el) {
192 | Array.prototype.forEach.call(el.length ? el : [el], function (x) {
193 | return assign(x, options);
194 | });
195 | }
196 | return el;
197 | };
198 | autosize.destroy = function (el) {
199 | if (el) {
200 | Array.prototype.forEach.call(el.length ? el : [el], destroy);
201 | }
202 | return el;
203 | };
204 | autosize.update = function (el) {
205 | if (el) {
206 | Array.prototype.forEach.call(el.length ? el : [el], update);
207 | }
208 | return el;
209 | };
210 | }
211 |
212 | module.exports = autosize;
213 | });
--------------------------------------------------------------------------------
/streamfield/static/streamfield/js/widgets/DateTimeInputs.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | 'use strict';
3 |
4 | const NUMERIC = 0
5 | const ALPHABETA = 2
6 | var PartData = {
7 | // time
8 | 'H': {tpe: NUMERIC, len: 2, fprint: 'HH', data: 23},
9 | 'M': {tpe: NUMERIC, len: 2, fprint: 'MM', data: 59},
10 | 'S': {tpe: NUMERIC, len: 2, fprint: 'SS', data: 59},
11 | // date
12 | 'd': {tpe: NUMERIC, len: 2, fprint: 'DD', data: 31},
13 | 'j': {tpe: NUMERIC, len: 3, fprint: 'DDD', data: 366},
14 | 'U': {tpe: NUMERIC, len: 2, fprint: 'WW', data: 53},
15 | 'W': {tpe: NUMERIC, len: 2, fprint: 'WW', data: 53},
16 | 'm': {tpe: NUMERIC, len: 2, fprint: 'MM', data: 12},
17 | 'y': {tpe: NUMERIC, len: 2, fprint: 'YY', data: 99},
18 | 'Y': {tpe: NUMERIC, len: 4, fprint: 'YYYY', data: 9999},
19 | // alphabetic times
20 | 'a': {tpe: ALPHABETA, len: 3, fprint: 'ddd', data: {
21 | mon: {auto: 'm'},
22 | tue: {auto: 'tu'},
23 | wed: {auto: 'w'},
24 | thu: {auto: 'th'},
25 | fri: {auto: 'f'},
26 | sat: {auto: 'sa'},
27 | sun: {auto: 'su'},
28 | }},
29 | 'b': {tpe: ALPHABETA, len: 3, fprint: 'mmm', data: {
30 | jan: {auto: 'j'},
31 | feb: {auto: 'f'},
32 | mar: {auto: 'mar'},
33 | apr: {auto: 'ap'},
34 | may: {auto: 'may'},
35 | jun: {auto: 'jun'},
36 | jul: {auto: 'jul'},
37 | aug: {auto: 'au'},
38 | sep: {auto: 's'},
39 | oct: {auto: 'o'},
40 | nov: {auto: 'n'},
41 | dec: {auto: 'd'},
42 | }},
43 | }
44 |
45 | function autoMap(obj) {
46 | const r = {};
47 | Object.keys(obj).forEach(key => {
48 | r[obj[key]['auto']] = key
49 | })
50 | return r
51 | }
52 |
53 | function formatParse(fmt) {
54 | let toks = []
55 | let dlms = []
56 | let i = 0;
57 | let c = ''
58 | let dlm = ''
59 | while (i < fmt.length) {
60 | c = fmt[i]
61 | if (c == '%') {
62 | dlms.push( dlm )
63 | dlm = ''
64 | toks.push(fmt[i+1])
65 | i++
66 | } else {
67 | dlm += c
68 | }
69 | i++
70 | }
71 | return {tokenIds: toks, delimiters: dlms.slice(1) }
72 | }
73 |
74 | class Parser {
75 | constructor(fInfo) {
76 | var i = 0
77 | var chain = []
78 | var partNames = []
79 | for (const pid of fInfo.tokenIds) {
80 | if (pid in PartData) {
81 | const d = PartData[pid]
82 | const l = d.len
83 | switch(d.tpe){
84 | case NUMERIC:
85 | chain.push(new PartNumParse(pid, i, l, d.data))
86 | break
87 | case ALPHABETA:
88 | chain.push(new PartTextParse(pid, i, l, Object.keys(d.data), autoMap(d.data)))
89 | break
90 | default:
91 | throw "Format type not recognised. code: " + d.tpe
92 | }
93 | partNames.push(d.fprint)
94 | i += l
95 |
96 | } else {
97 | throw "Part of format not recognised. part:'" + pid + "'"
98 | }
99 | }
100 | this.placeholder = this.interspace(partNames, fInfo.delimiters)
101 | this.pLen = i
102 | this.chain = chain
103 | this.prevLen = 0
104 | }
105 |
106 | delimit(v, seps) {
107 | let l = v.length
108 | let b = []
109 | let i = 0
110 | let nextSep = ''
111 | for (const e of this.chain) {
112 | nextSep = seps[i]
113 | i++
114 | const to = e.to
115 | b.push(v.slice(e.from, to))
116 | if (l <= to) {
117 | if (l == to && l < this.pLen) { b.push(nextSep) }
118 | break;
119 | }
120 | b.push(nextSep)
121 | }
122 | return b.join('')
123 | }
124 |
125 | interspace(ex, seps) {
126 | const b = []
127 | let i = 0
128 | let l = seps.length
129 | while(i < l){
130 | b.push(ex[i])
131 | b.push(seps[i])
132 | i++
133 | }
134 | b.push(ex[i])
135 | return b.join('')
136 | }
137 |
138 | prepare(v) {
139 | //v = v.replace(this.regex,'')
140 | v = v.replace(/[^\w]/g, '')
141 | v = v.slice(0, this.pLen)
142 | return v
143 | }
144 |
145 | clean(v) {
146 | for (const e of this.chain) {
147 | v = e.clean(v)
148 | }
149 | return v
150 | }
151 |
152 | fclean(v, seps) {
153 | const l = v.length
154 | if (this.prevLen > l) {
155 | this.prevLen = l
156 | return v
157 | }
158 | v = this.prepare(v)
159 | v = this.clean(v)
160 | v = this.delimit(v, seps)
161 | this.prevLen = v.length
162 | return v
163 | }
164 | }
165 |
166 | class PartNumParse {
167 | constructor(p, from, len, max) {
168 | this.part = p
169 | this.from = from
170 | this.len = len
171 | this.to = from + len
172 | this.max = max
173 | }
174 | clean(v) {
175 | const l = v.length
176 | if (l < this.from) {
177 | return v
178 | }
179 | let tok = v.slice(this.from, this.to)
180 | tok = tok.replace(/[^\d]/g, '')
181 | const ft = tok + "0".repeat(this.len - tok.length)
182 | if (+ft > this.max) {
183 | tok = '0' + tok
184 | }
185 | return v.slice(0, this.from) + tok + v.slice(this.to)
186 | }
187 | }
188 |
189 | class PartTextParse {
190 | constructor(p, from, len, opts, auto) {
191 | this.part = p
192 | this.from = from
193 | this.len = len
194 | this.to = from + len
195 | this.opts = opts
196 | this.auto = auto
197 | }
198 |
199 | clean(v) {
200 | const l = v.length
201 | if (l < this.from) {
202 | return v
203 | }
204 | var tok = v.slice(this.from, this.to)
205 | tok = tok.replace(/\d/g, '')
206 |
207 | if (tok.length < this.len) {
208 | const full = this.auto[tok]
209 | if (full) { tok = full }
210 | } else {
211 | if (this.opts.indexOf(tok.toLowerCase()) == -1) { tok = '' }
212 | }
213 | return v.slice(0, this.from) + tok + v.slice(this.to)
214 | }
215 | }
216 |
217 |
218 | var DateTimeInputs = {
219 |
220 | enable: function(e, format) {
221 | if (!(e && e.nodeName && e.nodeName === 'INPUT')) {
222 | return;
223 | };
224 |
225 | const fInfo = formatParse(format)
226 |
227 | const ps = new Parser(fInfo)
228 | e.setAttribute("placeholder", ps.placeholder)
229 |
230 | e.addEventListener("input", function(e) {
231 | e.preventDefault()
232 | e.stopPropagation()
233 | e.currentTarget.value = ps.fclean(e.target.value, fInfo.delimiters)
234 | });
235 | },
236 |
237 | };
238 |
239 | window.DateTimeInputs = DateTimeInputs;
240 | })();
241 |
--------------------------------------------------------------------------------
/streamfield/templates/streamfield/block_forms/field.html:
--------------------------------------------------------------------------------
1 | {% load streamfield_tags %}
2 |
3 |
4 | {% if errors %}
5 |
6 | {% for error in errors %}
7 |
{{ error|escape }}
8 | {% endfor %}
9 |
10 | {% endif %}
11 |
12 | {{ widget }}
13 | {# This span only used on rare occasions by certain types of input #}
14 |
15 |
16 | {% if field.help_text %}
17 |
{{ field.help_text }}
18 | {% endif %}
19 |
20 |
--------------------------------------------------------------------------------
/streamfield/templates/streamfield/block_forms/list.html:
--------------------------------------------------------------------------------
1 | {% extends "streamfield/block_forms/sequence.html" %}
2 | {% load i18n %}
3 |
4 | {% block footer %}
5 |
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/streamfield/templates/streamfield/block_forms/list_member.html:
--------------------------------------------------------------------------------
1 | {% extends "streamfield/block_forms/sequence_member.html" %}
2 | {% load i18n %}
3 |
4 | {% block header_controls %}
5 |
8 |
11 |
14 | {% endblock %}
15 |
--------------------------------------------------------------------------------
/streamfield/templates/streamfield/block_forms/sequence.html:
--------------------------------------------------------------------------------
1 | {% comment %}
2 | A 'sequence' is a generalised structure that implements a collection
3 | of blocks that can be ordered, added and deleted.
4 |
5 | It provides the overall HTML structure, and the logic for updating
6 | hidden fields to reflect changes to the sequence, but NOT the UI
7 | controls for performing those changes: that is the responsibility of
8 | specific subtypes of 'sequence', such as list and stream.
9 |
10 | DO NOT PUT UI CONTROLS HERE, OR ANYTHING ELSE THAT ASSUMES A
11 | SPECIFIC VISUAL RENDERING OF THE LIST.
12 | (That belongs in templates that extend this one, such as list.html
13 | and stream.html.)
14 | {% endcomment %}
15 |
16 | {% if help_text %}
17 |
18 |
19 | {{ help_text }}
20 |
21 |
22 | {% endif %}
23 |
24 |
25 |
26 |
27 | {% block header %}{% endblock %}
28 |
29 | {% if block_errors %}
30 | {% for error in block_errors %}
31 |
{{ error }}
32 | {% endfor %}
33 | {% endif %}
34 |
35 |
36 | {% for list_member_html in list_members_html %}
37 | {{ list_member_html }}
38 | {% endfor %}
39 |
40 | {% block footer %}{% endblock %}
41 |
42 |
--------------------------------------------------------------------------------
/streamfield/templates/streamfield/block_forms/sequence_member.html:
--------------------------------------------------------------------------------
1 | {% comment %}
2 | A 'sequence' is a generalised structure that implements a collection
3 | of blocks that can be ordered, added and deleted.
4 |
5 | It provides the overall HTML structure, and the logic for updating
6 | hidden fields to reflect changes to the sequence, but NOT the UI
7 | controls for performing those changes: that is the responsibility of
8 | specific subtypes of 'sequence', such as list and stream.
9 |
10 |
11 | DO NOT PUT UI CONTROLS HERE, OR ANYTHING ELSE THAT ASSUMES A
12 | SPECIFIC VISUAL RENDERING OF THE LIST.
13 | (That belongs in templates that extend this one, such as
14 | list_member.html and stream_member.html.)
15 | {% endcomment %}
16 |
')
69 |
70 | #def test_to_python(self):
71 | # self.assertEqual(self.block.to_python(), [self.block])
72 |
73 |
--------------------------------------------------------------------------------
/streamfield/tests/test_validators.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from streamfield import validators
3 | from django.core.exceptions import ValidationError
4 |
5 |
6 | # ./manage.py test streamfield.tests.test_validators
7 | class TestValidators(TestCase):
8 |
9 | def setUp(self):
10 | self.v = validators.URIValidator()
11 |
12 | def test_url(self):
13 | self.v('http://localhost:8000/django-admin/tester/page/1/change/')
14 |
15 | def test_uri_1(self):
16 | self.v('/page/1/change/')
17 |
18 | def test_uri_2(self):
19 | self.v('page/1/change/')
20 |
21 | def test_uri_3(self):
22 | self.v('#page')
23 |
24 | def test_bad_uri_1(self):
25 | with self.assertRaises(ValidationError):
26 | self.v('/page\/1/change/')
27 |
28 | def test_bad_uri_2(self):
29 | with self.assertRaises(ValidationError):
30 | self.v('/page~dop/1/change/')
31 |
--------------------------------------------------------------------------------
/streamfield/utils.py:
--------------------------------------------------------------------------------
1 | import re
2 | from django.templatetags.static import static
3 |
4 | def camelcase_to_underscore(s):
5 | # https://djangosnippets.org/snippets/585/
6 | return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', '_\\1', s).lower().strip('_')
7 |
8 | def default_to_underscore(s, default=''):
9 | try:
10 | return camelcase_to_underscore(s)
11 | except AttributeError:
12 | return default
13 |
14 | # def field_to_underscore(field):
15 | # try:
16 | # return camelcase_to_underscore(field.field.__class__.__name__)
17 | # except AttributeError:
18 | # try:
19 | # return camelcase_to_underscore(field.__class__.__name__)
20 | # except AttributeError:
21 | # return ""
22 |
23 |
24 | # def field_widget_to_underscore(field):
25 | # try:
26 | # return camelcase_to_underscore(field.field.widget.__class__.__name__)
27 | # except AttributeError:
28 | # try:
29 | # return camelcase_to_underscore(field.widget.__class__.__name__)
30 | # except AttributeError:
31 | # return ""
32 |
33 | # from admin.static_files
34 | def versioned_static(path):
35 | """
36 | Wrapper for Django's static file finder to append a cache-busting query parameter
37 | that updates on each Wagtail version
38 | """
39 | # An absolute path is returned unchanged (either a full URL, or processed already)
40 | if path.startswith(('http://', 'https://', '/')):
41 | return path
42 |
43 | base_url = static(path)
44 |
45 | # if URL already contains a querystring, don't add our own, to avoid interfering
46 | # with existing mechanisms
47 | #if VERSION_HASH is None or '?' in base_url:
48 | # return base_url
49 | #else:
50 | # return base_url + '?v=' + VERSION_HASH
51 | return base_url
52 |
53 |
54 | SCRIPT_RE = re.compile(r'<(-*)/script>')
55 |
56 | def escape_script(text):
57 | """
58 | Escape `` tags in 'text' so that it can be placed within a `'.format(widget_html, js)
29 | return mark_safe(out)
30 |
31 | def render_js_init(self, id_, name, value):
32 | '''
33 | Override ti return a JS script after tthe widget.
34 | This can be used for initialisation of Javascript.
35 | id
36 | id of the associated widget. For multi-widgets, add a
37 | traailing '_X' to get the id of the widgets in the list..
38 | '''
39 | return ''
40 |
41 |
42 |
43 | class MiniTimeWidget(WithScript, widgets.TimeInput):
44 | '''
45 | Input placeholding a time format, and validating edits against the format.
46 | The widget responds to field and widget format declarations.
47 | However, it is unable to function on any format but American
48 | language, with a given separator, and limited set of fields,
49 |
50 | '%H': num hours (24)
51 | '%M': num minutes
52 | '%S': num seconds
53 | '%d': num day
54 | '%j': num day in year
55 | '%U': num week in year
56 | '%W': num week in year
57 | '%m': num month
58 | '%y': num short year
59 | '%Y': num long year
60 | '%a': short day
61 | '%b': short month
62 |
63 | If the format is not recognised, the Javascript will fail with a
64 | warning. If your app is creating an unusable format, you can make
65 | the widget work by stating a format on the field.
66 |
67 | Note that Django makes localisation of time/date by forcing formats
68 | on the fields and widgets, If this widget is used in an
69 | internationalised app, in most cases the format must be explicitly
70 | stated.
71 | '''
72 | #NB date/time widgets localise in format_value(self, value):
73 | # They delocalize using strptime()/to_python() in the form field
74 | # This is not symetric, or easy to override
75 | def render_js_init(self, id_, name, value):
76 | return 'e = document.getElementById("{eid}"); DateTimeInputs.enable(e, "{wFormat}");'.format(
77 | eid = id_,
78 | wFormat=self.format or '%H:%M:%S',
79 | )
80 |
81 |
82 |
83 | class MiniDateWidget(WithScript, widgets.DateInput):
84 | '''
85 | An input placeholding a date format, and validating edits against
86 | the format.
87 | The widget responds to field and widget format declarations.
88 | However, it is unable to function on any format but American
89 | language, with a given separator, and limited set of fields,
90 |
91 | '%H': num hours (24)
92 | '%M': num minutes
93 | '%S': num seconds
94 | '%d': num day
95 | '%j': num day in year
96 | '%U': num week in year
97 | '%W': num week in year
98 | '%m': num month
99 | '%y': num short year
100 | '%Y': num long year
101 | '%a': short day
102 | '%b': short month
103 |
104 | If the format is not recognised, the Javascript will fail with a
105 | warning. If your app is creating an unusable format, you can make
106 | the widget work by stating a format on the field.
107 |
108 | Note that Django makes localisation of time/date by forcing formats
109 | on the fields and widgets, If this widget is used in an
110 | internationalised app, in most cases the format must be explicitly
111 | stated.
112 | '''
113 | #NB date/time widgets localise in format_value(self, value):
114 | # They delocalize using strptime()/to_python() in the form field
115 | # This is not symetric, or easy to override
116 | def render_js_init(self, id_, name, value):
117 | return 'e = document.getElementById("{eid}"); DateTimeInputs.enable(e, "{wFormat}");'.format(
118 | eid=id_,
119 | wFormat=self.format or '%d/%m/%Y',
120 | #wFormat='%Y / %m / %d',
121 | )
122 |
123 |
124 | #from wagtail.admin.widgets import AdminDateTimeInput
125 | class MiniDateTimeWidget(WithScript, widgets.SplitDateTimeWidget):
126 | template_name = 'streamfield/widgets/split_datetime.html'
127 |
128 | def __init__(self, attrs=None):
129 | widgets = [MiniDateWidget, MiniTimeWidget]
130 | # Note that we're calling MultiWidget, not SplitDateTimeWidget, because
131 | # we want to define widgets.
132 | forms.MultiWidget.__init__(self, widgets, attrs)
133 |
134 | def get_context(self, name, value, attrs):
135 | context = super().get_context(name, value, attrs)
136 | context['date_label'] = _('Date:')
137 | context['time_label'] = _('Time:')
138 | return context
139 |
140 | def render_js_init(self, id_, name, value):
141 | w1 = 'e = document.getElementById("{eid}_{wid}"); DateTimeInputs.enable(e, "{wFormat}");'.format(
142 | eid=id_,
143 | wid=0,
144 | wFormat=self.widgets[0].format or '%d/%m/%Y',
145 | )
146 | w2 = 'e = document.getElementById("{eid}_{wid}"); DateTimeInputs.enable(e, "{wFormat}");'.format(
147 | eid = id_,
148 | wid=1,
149 | wFormat=self.widgets[1].format or '%H:%M:%S',
150 | )
151 | return w1 + w2
152 |
153 |
154 |
155 | class AutoHeightTextWidget(WithScript, widgets.Textarea):
156 | '''
157 | Textarea that eexpands and contracts with text content.
158 | '''
159 | def __init__(self, attrs=None):
160 | # Use more appropriate rows default, given autoheight will
161 | # alter this anyway
162 | default_attrs = {'rows': '1'}
163 | if attrs:
164 | default_attrs.update(attrs)
165 | super().__init__(default_attrs)
166 |
167 | def render_js_init(self, id_, name, value):
168 | return 'autosize(document.getElementById("{eid}"));'.format(eid=id_)
169 |
170 | class Media():
171 | js = (
172 | 'admin/js/jquery.init.js',
173 | 'streamfield/js/vendor/jquery.autosize.js',
174 | )
175 |
--------------------------------------------------------------------------------