├── .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 | ![What a user wants to see, not a coder](screenshots/content_form.png) 32 | 33 | which renders as, 34 | 35 | ![Where's the CSS guy?](screenshots/content_render.png) 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 | ![No markup in sight](screenshots/weblinks_form.png) 47 | 48 | which renders as, 49 | 50 | ![No probleem](screenshots/weblinks_render.png) 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 (

,

etc). Default=3. 219 | ''' 220 | level = 3 221 | def __init__(self, 222 | level=3, 223 | **kwargs 224 | ): 225 | #NB kwargs that reach block initialisation are placed on Meta. 226 | super().__init__(**kwargs) 227 | self.level = level or self.level 228 | 229 | def render_basic(self, value, context=None): 230 | if value: 231 | return format_html('{0}', 232 | value, 233 | self.level, 234 | self.render_css_classes(context) 235 | ) 236 | else: 237 | return '' 238 | 239 | 240 | 241 | class QuoteBlock(CharBlock): 242 | 243 | def render_basic(self, value, context=None): 244 | if value: 245 | return format_html('{0}', 246 | value, 247 | self.render_css_classes(context) 248 | ) 249 | else: 250 | return '' 251 | 252 | 253 | 254 | class RegexBlock(CharBlock): 255 | 256 | def __init__(self, regex, **kwargs): 257 | super().__init__(**kwargs) 258 | self.field_options.update({ 259 | 'regex':regex, 260 | }) 261 | self.field = forms.RegexField( 262 | **self.field_options 263 | ) 264 | 265 | 266 | 267 | class EmailBlock(CharBlock): 268 | widget = admin.widgets.AdminEmailInputWidget 269 | 270 | def __init__(self, **kwargs): 271 | super().__init__(**kwargs) 272 | self.field = forms.EmailField( 273 | **self.field_options 274 | ) 275 | 276 | 277 | 278 | class URLBlock(CharBlock): 279 | ''' 280 | Block for URLs. 281 | This uses Django core.validators.URLValidator so is strict. It 282 | accepts a full-formed URL, with scheme, path etc. It will accept 283 | queries and fragments, and 'www' scheme, but not relative URIs. 284 | 285 | ''' 286 | widget = admin.widgets.AdminURLFieldWidget 287 | 288 | def __init__(self, **kwargs): 289 | super().__init__(**kwargs) 290 | self.field = forms.URLField( 291 | **self.field_options 292 | ) 293 | 294 | def render_basic(self, value, context=None): 295 | if value: 296 | return format_html('{0}', 297 | value, 298 | self.render_css_classes(context) 299 | ) 300 | else: 301 | return 302 | 303 | 304 | 305 | class RelURLBlock(CharBlock): 306 | ''' 307 | Block for URLs. 308 | This uses streamfield.RelURLValidator. It accepts a full-formed URL, 309 | with scheme, path etc. but will also accept fragments and relative 310 | URLs. 311 | ''' 312 | def __init__(self, **kwargs): 313 | super().__init__(**kwargs) 314 | self.field = RelURLField( 315 | **self.field_options 316 | ) 317 | 318 | def render_basic(self, value, context=None): 319 | if value: 320 | return format_html('{0}', 321 | value, 322 | self.render_css_classes(context) 323 | ) 324 | else: 325 | return 326 | 327 | 328 | 329 | 330 | class RawAnchorBlock(RelURLBlock): 331 | def render_basic(self, value, context=None): 332 | print(str(self.render_css_classes(context))) 333 | if value: 334 | return format_html('{0}', 335 | value, 336 | self.render_css_classes(context) 337 | ) 338 | else: 339 | return 340 | 341 | 342 | 343 | from .struct_block import StructBlock 344 | class AnchorBlock(StructBlock): 345 | url = RelURLBlock() 346 | text = CharBlock() 347 | 348 | def render_basic(self, value, context=None): 349 | if value: 350 | return format_html('{1}', 351 | value['url'], 352 | value['text'], 353 | self.render_css_classes(context), 354 | ) 355 | else: 356 | return 357 | 358 | 359 | 360 | class TextBlock(FieldBlock): 361 | widget = AutoHeightTextWidget(attrs={'rows': 1}) 362 | 363 | def __init__(self, 364 | max_length=None, 365 | min_length=None, 366 | **kwargs 367 | ): 368 | super().__init__(**kwargs) 369 | self.field_options.update({ 370 | 'max_length':max_length, 371 | 'min_length':min_length, 372 | }) 373 | 374 | @cached_property 375 | def field(self): 376 | return forms.CharField(**self.field_options) 377 | 378 | def get_searchable_content(self, value): 379 | return [force_str(value)] 380 | 381 | def render_basic(self, value, context=None): 382 | if value: 383 | return format_html('{0}

', 384 | value, 385 | self.render_css_classes(context) 386 | ) 387 | else: 388 | return 389 | 390 | 391 | 392 | class DefinitionBlock(StructBlock): 393 | term_block = CharBlock 394 | definition_block = TextBlock 395 | 396 | def __init__(self, term_block, definition_block, **kwargs): 397 | trm_block = term_block or self.term_block 398 | dfn_block = definition_block or self.definition_block 399 | 400 | # assert blocks are instances 401 | super().__init__( 402 | (('term', trm_block), ('definition', dfn_block)), 403 | **kwargs 404 | ) 405 | 406 | def render_basic(self, value, context=None): 407 | print(str(value)) 408 | if value: 409 | return format_html('{0}
{1}
', 410 | self.child_blocks['term'].render(value['term'], context), 411 | self.child_blocks['definition'].render(value['definition'], context), 412 | self.render_css_classes(context) 413 | ) 414 | else: 415 | return 416 | 417 | 418 | 419 | class BlockQuoteBlock(TextBlock): 420 | 421 | def render_basic(self, value, context=None): 422 | if value: 423 | return format_html('{0}', 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('{0}', 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('{0}', 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('{0}', 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 = "
      {0}
    " 305 | if (html_ordered): 306 | wrap_template = "
      {0}
    " 307 | super().__init__( 308 | self.block_type, 309 | "
  • {0}
  • ", 310 | wrap_template, 311 | **kwargs 312 | ) 313 | 314 | def deconstruct(self): 315 | name, path, args, kwargs = super().deconstruct() 316 | kwargs['block_type'] = self.root_block.child_block 317 | if (self.html_ordered): 318 | kwargs['html_ordered'] = self.html_ordered 319 | return name, path, args, kwargs 320 | 321 | 322 | 323 | from streamfield.blocks.field_block import DefinitionBlock 324 | 325 | class DefinitionListField(ListFieldBase): 326 | ''' 327 | A field rendering as an HTML definition list. 328 | term_block_type 329 | definition_block_type 330 | block to be used as definition 331 | ''' 332 | term_block = CharBlock 333 | definition_block = CharBlock 334 | 335 | def __init__(self, 336 | term_block = CharBlock, 337 | definition_block = CharBlock, 338 | **kwargs 339 | ): 340 | self.term_block = term_block or self.term_block 341 | self.definition_block = definition_block or self.definition_block 342 | super().__init__( 343 | DefinitionBlock(self.term_block, self.definition_block), 344 | "{0}", 345 | "
    {0}
    ", 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 |
    17 | 18 | 19 | {% block hidden_fields %}{% endblock %} 20 | 21 |
    22 |
    23 |
    24 |
    25 |
    26 | {% block block_type_label %}{% endblock %} 27 | {% block header_controls %}{% endblock %} 28 |
    29 |
    30 |
    31 |
    32 | {{ child.render_form }} 33 |
    34 |
    35 |
    36 | {% block footer_controls %}{% endblock %} 37 |
    38 |
    39 |
    40 | -------------------------------------------------------------------------------- /streamfield/templates/streamfield/block_forms/stream.html: -------------------------------------------------------------------------------- 1 | {% extends "streamfield/block_forms/sequence.html" %} 2 | 3 | {% block header %} 4 | {% if list_members_html %} 5 | {% include "streamfield/block_forms/stream_menu.html" with menu_id=prefix|add:"-prependmenu" state="closed" %} 6 | {% else %} 7 | {% include "streamfield/block_forms/stream_menu.html" with menu_id=prefix|add:"-prependmenu" state="open" %} 8 | {% endif %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /streamfield/templates/streamfield/block_forms/stream_member.html: -------------------------------------------------------------------------------- 1 | {% extends "streamfield/block_forms/sequence_member.html" %} 2 | {% load i18n %} 3 | 4 | {% block hidden_fields %} 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block block_type_label %}{{ child_block.label }}{% endblock %} 10 | 11 | {% block header_controls %} 12 | 15 | 18 | 21 | {% endblock %} 22 | 23 | {% block footer_controls %} 24 | {% include "streamfield/block_forms/stream_menu.html" with menu_id=prefix|add:"-appendmenu" state="closed" %} 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /streamfield/templates/streamfield/block_forms/stream_menu.html: -------------------------------------------------------------------------------- 1 | 4 | 21 | -------------------------------------------------------------------------------- /streamfield/templates/streamfield/block_forms/struct.html: -------------------------------------------------------------------------------- 1 |
    2 | {% if help_text %} 3 | 4 |
    5 | {{ help_text }} 6 |
    7 |
    8 | {% endif %} 9 | 10 | {% for child in children.values %} 11 |
    12 | {% if child.block.label %} 13 | 14 | {% endif %} 15 | {{ child.render_form }} 16 |
    17 | {% endfor %} 18 |
    19 | -------------------------------------------------------------------------------- /streamfield/templates/streamfield/widgets/split_datetime.html: -------------------------------------------------------------------------------- 1 |

    2 | {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}
    3 | {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %} 4 |

    5 | -------------------------------------------------------------------------------- /streamfield/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcrowther/django-streamfield-w/9dcdaf2e9a566aebb3957904974f5c7df4be2308/streamfield/templatetags/__init__.py -------------------------------------------------------------------------------- /streamfield/templatetags/streamfield_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.text import ( 3 | get_valid_filename, 4 | camel_case_to_spaces 5 | ) 6 | from django.utils.safestring import mark_safe 7 | from django.template import loader 8 | 9 | register = template.Library() 10 | 11 | 12 | # @register.simple_tag 13 | # def format_field(field): 14 | # widget_name = get_widget_name(field) 15 | 16 | # t = loader.select_template([ 17 | #'streamblocks/admin/fields/%s.html' % widget_name, 18 | # 'streamfield/admin/fields/%s.html' % widget_name, 19 | # 'streamfield/admin/fields/default.html' 20 | # ]) 21 | 22 | # if widget_name == 'select': 23 | 24 | # # ForeignKey Field 25 | # if hasattr(field.field, '_queryset'): 26 | # for obj in field.field._queryset: 27 | # if obj.pk == field.value(): 28 | # field.obj = obj 29 | 30 | # # CharField choices 31 | # if hasattr(field.field, '_choices'): 32 | # for obj in field.field._choices: 33 | # if obj[0] == field.value(): 34 | # field.obj = obj[1] 35 | 36 | 37 | # return mark_safe(t.render(dict( 38 | # field=field 39 | # ))) 40 | 41 | # def get_widget_name(field): 42 | # return get_valid_filename( 43 | # camel_case_to_spaces(field.field.widget.__class__.__name__) 44 | # ) 45 | 46 | # @register.simple_tag 47 | # def stream_render(stream_obj, **kwargs): 48 | # return stream_obj._render(kwargs) 49 | 50 | ############################################# 51 | 52 | class IncludeBlockNode(template.Node): 53 | def __init__(self, block_var, extra_context, use_parent_context): 54 | self.block_var = block_var 55 | self.extra_context = extra_context 56 | self.use_parent_context = use_parent_context 57 | 58 | def render(self, context): 59 | try: 60 | value = self.block_var.resolve(context) 61 | except template.VariableDoesNotExist: 62 | return '' 63 | 64 | if hasattr(value, 'render_as_block'): 65 | if self.use_parent_context: 66 | new_context = context.flatten() 67 | else: 68 | new_context = {} 69 | 70 | if self.extra_context: 71 | for var_name, var_value in self.extra_context.items(): 72 | new_context[var_name] = var_value.resolve(context) 73 | 74 | return value.render_as_block(context=new_context) 75 | else: 76 | return force_str(value) 77 | 78 | @register.tag 79 | def streamfield(parser, token): 80 | """ 81 | Render the passed item of StreamField content, passing the current template context 82 | if there's an identifiable way of doing so (i.e. if it has a `render_as_block` method). 83 | """ 84 | tokens = token.split_contents() 85 | 86 | try: 87 | tag_name = tokens.pop(0) 88 | block_var_token = tokens.pop(0) 89 | except IndexError: 90 | raise template.TemplateSyntaxError("%r tag requires at least one argument" % tag_name) 91 | 92 | block_var = parser.compile_filter(block_var_token) 93 | 94 | if tokens and tokens[0] == 'with': 95 | tokens.pop(0) 96 | extra_context = token_kwargs(tokens, parser) 97 | else: 98 | extra_context = None 99 | 100 | use_parent_context = True 101 | if tokens and tokens[0] == 'only': 102 | tokens.pop(0) 103 | use_parent_context = False 104 | 105 | if tokens: 106 | raise template.TemplateSyntaxError("Unexpected argument to %r tag: %r" % (tag_name, tokens[0])) 107 | 108 | return IncludeBlockNode(block_var, extra_context, use_parent_context) 109 | -------------------------------------------------------------------------------- /streamfield/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcrowther/django-streamfield-w/9dcdaf2e9a566aebb3957904974f5c7df4be2308/streamfield/tests/__init__.py -------------------------------------------------------------------------------- /streamfield/tests/fixture_test_model.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from streamfield import StreamField 4 | from streamfield.model_fields import ListField, DefinitionListField 5 | from streamfield import blocks 6 | from streamfield.blocks.field_block import DefinitionBlock 7 | 8 | 9 | 10 | class Creepy(models.TextChoices): 11 | SPIDER = 'SP','Spider' 12 | ANT = 'AT','Ant' 13 | PYTHON = 'PY','Python' 14 | BAT = 'BT','Bat' 15 | CRICKET = 'CR','Cricket' 16 | MOTH = 'MO','Moth' 17 | 18 | class Licence(models.TextChoices): 19 | NO_RIGHTS = 'CCO', 'Creative Commons ("No Rights Reserved")' 20 | CREDIT = 'CC_BY', 'Creative Commons (credit)' 21 | CREDIT_NC = 'CC_BY-NC-ND', 'Creative Commons (credit, non-commercial, no adaption)' 22 | NON_EXCLUSIVE = 'NE','Non-exclusive Rights available' 23 | EXCLUSIVE = 'EX','Exclusive rights available' 24 | 25 | 26 | class QuoteBlock(blocks.StructBlock): 27 | quote = blocks.BlockQuoteBlock() 28 | author = blocks.CharBlock() 29 | date = blocks.DateBlock() 30 | licence = blocks.ChoiceBlock(choices=Licence.choices) 31 | 32 | 33 | 34 | from django.forms import widgets 35 | class Page(models.Model): 36 | title = models.CharField('Title', 37 | max_length=255, 38 | null=True, 39 | blank=True 40 | ) 41 | 42 | # stream = StreamField( 43 | # block_types = [ 44 | # ('chars', blocks.CharBlock( 45 | # required=False, 46 | # help_text="A block inputting a short length of chars", 47 | # max_length=5, 48 | # ), 49 | # ), 50 | # ('subtitle', blocks.HeaderBlock()), 51 | # ('subsubtitle', blocks.HeaderBlock(level=4)), 52 | # ('quote', blocks.QuoteBlock( 53 | # required=False, 54 | # ), 55 | # ), 56 | # ('url', blocks.URLBlock), 57 | # ('relurl', blocks.RelURLBlock), 58 | # ('email', blocks.EmailBlock(css_classes=['email'])), 59 | # ('regex', blocks.RegexBlock(regex='\w+')), 60 | # ('text', blocks.TextBlock()), 61 | # ('blockquote', blocks.BlockQuoteBlock()), 62 | # ('html', blocks.RawHTMLBlock()), 63 | # ('bool', blocks.BooleanBlock()), 64 | # ('choice', blocks.ChoiceBlock(choices=Creepy.choices)), 65 | # ('choices', blocks.MultipleChoiceBlock(choices=Creepy.choices)), 66 | # ('integer', blocks.IntegerBlock()), 67 | # ('decimal', blocks.DecimalBlock()), 68 | # ('float', blocks.FloatBlock()), 69 | # ('date', blocks.DateBlock()), 70 | # ('time', blocks.TimeBlock()), 71 | # ('datetime', blocks.DateTimeBlock(css_classes=['datetime'])), 72 | # ('rawanchor', blocks.RawAnchorBlock()), 73 | # ('anchor', blocks.AnchorBlock()), 74 | # ], 75 | # verbose_name="Streamfield field block sampler" 76 | # ) 77 | 78 | # stream = StreamField( 79 | # block_types = [ 80 | # ('chars', blocks.CharBlock( 81 | # required=True, 82 | # help_text="A block inputting a short length of chars", 83 | # max_length=5, 84 | # ), 85 | # ), 86 | # ], 87 | # verbose_name="Streamfield with CharBlock attributes" 88 | # ) 89 | 90 | # stream = StreamField( 91 | # block_types = [ 92 | # ('subtitle', blocks.HeaderBlock()), 93 | # ('subsubtitle', blocks.HeaderBlock(level=4)), 94 | # ('text', blocks.TextBlock(placeholder='qzapp')), 95 | # ('blockquote', blocks.BlockQuoteBlock), 96 | # ('quote', QuoteBlock()), 97 | # ('anchor', blocks.AnchorBlock), 98 | # ('date', blocks.DateBlock), 99 | # ], 100 | # verbose_name="Streamfield for text content" 101 | # ) 102 | 103 | # stream = ListField( 104 | # html_ordered = True, 105 | # verbose_name="ListField ordered" 106 | # ) 107 | 108 | # stream = DefinitionListField( 109 | # definition_block = blocks.RawAnchorBlock, 110 | # verbose_name="DefinitionListField for raw web links" 111 | # ) 112 | 113 | # stream = StreamField( 114 | # block_types = [ 115 | # ('subtitle', blocks.CharBlock()), 116 | # ('text', blocks.TextBlock()), 117 | # ('quote', QuoteBlock()), 118 | # ], 119 | # verbose_name="StreamField with StructBlock quotes " 120 | # ) 121 | -------------------------------------------------------------------------------- /streamfield/tests/test_blocks.py: -------------------------------------------------------------------------------- 1 | from streamfield import blocks 2 | from django.test import TestCase 3 | 4 | 5 | # ./manage.py test streamfield.tests.test_blocks 6 | class TestBlock(TestCase): 7 | ''' 8 | Block can be passed by itself. Not a lot to test which makes 9 | significant, though. 10 | ''' 11 | def setUp(self): 12 | self.block = blocks.Block() 13 | 14 | def test_all_blocks(self): 15 | self.assertEqual(self.block.all_blocks(), [self.block]) 16 | 17 | def test_bind(self): 18 | bb = self.block.bind(5, prefix=None, errors=None) 19 | self.assertEqual(type(bb), blocks.BoundBlock) 20 | self.assertEqual(bb.value, 5) 21 | 22 | def test_get_default(self): 23 | with self.assertRaises(AttributeError): 24 | self.block.get_default() 25 | 26 | 27 | # FIELD_BLOCKS = [ 28 | # CharBlock, URLBlock, 29 | # #RichTextBlock, 30 | # RawHTMLBlock, ChooserBlock, 31 | # #PageChooserBlock, 32 | # TextBlock, BooleanBlock, DateBlock, TimeBlock, 33 | # DateTimeBlock, ChoiceBlock, EmailBlock, IntegerBlock, FloatBlock, 34 | # DecimalBlock, RegexBlock, BlockQuoteBlock 35 | # ] 36 | 37 | #for b in blocks.block_classes: 38 | 39 | # class TestFieldBlocks(TestCase): 40 | 41 | # # def setUp(self): 42 | # # self.block = blocks.CharBlock() 43 | 44 | # for b, data in blocks.FIELD_BLOCKS.items: 45 | # value = data[0] 46 | # value = data[0] 47 | 48 | # b.to_python(value) 49 | 50 | 51 | class TestCharBlock(TestCase): 52 | 53 | def test_render(self): 54 | block = blocks.CharBlock() 55 | value = 'off' 56 | r = block.render(value, context=None) 57 | self.assertHTMLEqual(r, 'off') 58 | 59 | class TestListBlock(TestCase): 60 | 61 | def setUp(self): 62 | self.block = blocks.ListBlock(blocks.CharBlock) 63 | self.maxDiff=None 64 | 65 | def test_render_list_member(self): 66 | value = ['hi', 'ho', 'off', 'to', 'work'] 67 | r = self.block.render(value, context=None) 68 | self.assertHTMLEqual(r, '
    • hi
    • ho
    • off
    • to
    • work
    ') 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 | --------------------------------------------------------------------------------