diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index c9af87684..000000000 --- a/.drone.yml +++ /dev/null @@ -1,11 +0,0 @@ -image: kaedroho/django-base -env: - - DATABASE_HOST=postgres - - ELASTICSEARCH_URL=http://elasticsearch:9200/ -script: - - pip3.4 install mock python-dateutil pytz elasticsearch - - python3.4 setup.py install - - python3.4 runtests.py -services: - - postgres - - dockerfile/elasticsearch diff --git a/.gitignore b/.gitignore index bd904efc9..c8ceb2743 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.swp *.pyc .DS_Store /.coverage @@ -6,3 +7,4 @@ /wagtail.egg-info/ /docs/_build/ /.tox/ +/venv diff --git a/CHANGELOG.txt b/CHANGELOG.txt index e90148ae7..41adcf38d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -31,6 +31,11 @@ Changelog * The `document_served` signal now correctly passes the Document class as `sender` and the document as `instance` * Image/Document edit page no longer throws OSError when the original image is missing * Page classes can specify an edit_handler property to override the default Content / Promote / Settings tabbed interface + * The Page model now records the date/time that a page was first published, as the field `first_published_at` + * Increased the maximum length of a page slug from 50 to 255 characters + * Plain text fields in the page editor now use auto-expanding text areas + * Date / time pickers now consistently use times without seconds, to prevent Javascript behaviour glitches when focusing / unfocusing fields + * Added hooks `register_rich_text_embed_handler` and `register_rich_text_link_handler` for customising link / embed handling within rich text fields 0.8.6 (10.03.2015) diff --git a/README.rst b/README.rst index 910036050..d5eaccd99 100644 --- a/README.rst +++ b/README.rst @@ -52,12 +52,12 @@ Available at `wagtail.readthedocs.org `_ and al Need Support? ~~~~~~~~~~~~~~~ -Ask your questions on our `Google Group `_. +Ask your questions on our `Wagtail support group `_. Compatibility ~~~~~~~~~~~~~ -Wagtail supports Django 1.7.0+ on Python 2.7, 3.3 and 3.4. +Wagtail supports Django 1.7.1+ on Python 2.7, 3.3 and 3.4. Wagtail's dependencies are summarised at `requirements.io `_. @@ -71,3 +71,5 @@ We suggest you start by checking the `Help develop me! `_. We also welcome `translations `_ for Wagtail's interface. + +We run a separate Wagtail developers group here: https://groups.google.com/forum/#!forum/wagtail-developers please not that this is not for support requests. diff --git a/docs/getting_started/installation.rst b/docs/getting_started/installation.rst index 213c0bb7c..fac423fa2 100644 --- a/docs/getting_started/installation.rst +++ b/docs/getting_started/installation.rst @@ -8,7 +8,7 @@ Before you start You can get basic Wagtail setup installed on your machine with only a few prerequisites. See the full `Dependencies`_ list below. -There are various optional components that will improve the performance and feature set of Wagtail. Our recommended software stack includes the PostgreSQL database, ElasticSearch (for free-text searching), the OpenCV library (for image feature detection), and Redis (as a cache and message queue backend). This would be a lot to install in one go. For this reason we provide a virtual machine image to use with `Vagrant `__, with all of these components ready installed. +There are various optional components that will improve the performance and feature set of Wagtail. Our recommended software stack includes the PostgreSQL database, Elasticsearch (for free-text searching), the OpenCV library (for image feature detection), and Redis (as a cache and message queue backend). This would be a lot to install in one go. For this reason we provide a virtual machine image to use with `Vagrant `__, with all of these components ready installed. Whether you just want to try out the demo site, or you're ready to dive in and create a Wagtail site with all bells and whistles enabled, we strongly recommend the Vagrant approach. Nevertheless, if you're the sort of person who balks at the idea of downloading a whole operating system just to run a web app, we've got you covered too. Start from `Install Python`_. @@ -16,26 +16,9 @@ Whether you just want to try out the demo site, or you're ready to dive in and c Dependencies ============ -Barebones ---------- +Wagtail is based on the Django web framework and various other Python libraries. For the full list of absolutely required libraries, see `setup.py `__. -The basic Wagtail installation is pure Python. No build tools are required on the host machine. -For the full list of absolutely required libraries, see `setup.py `__. - -If you are installing Wagtail differently (e.g. from the Git repository), you must run ``python setup.py install`` from the repository root. The above command will install all Wagtail dependencies. - -Administration UI ------------------ - -.. warning:: - - The administrative interface requires django-libsass and Pillow. The project template bundled with Wagtail includes them (see :doc:`creating_your_project`). You must add the above libraries if you are adding Wagtail to an existing project, unless you will be using it - in a purely framework fashion without visiting wagtailadmin or hooking it up to the urlconf. - - * django-libsass>=0.2 - * Pillow>=2.6.1 - -Both django-libsass and Pillow have native-code components that require further attention: +Most of Wagtail's dependencies are pure Python and will install automatically using ``pip``, but there are a few native-code components that require further attention: * libsass-python (for compiling SASS stylesheets) - requires a C++ compiler and the Python development headers. * Pillow (for image processing) - additionally requires libjpeg and zlib. @@ -78,6 +61,8 @@ The quickest way to install Wagtail is using pip. To get the latest stable versi pip install wagtail +If you are installing Wagtail differently (e.g. from the Git repository), you must run ``python setup.py install`` from the repository root. The above command will install all Wagtail dependencies. + To check that Wagtail can be seen by Python, type ``python`` in your shell then try to import ``wagtail`` from the prompt: .. code-block:: python @@ -107,12 +92,12 @@ To enable Postgres for your project, uncomment the ``psycopg2`` line from your p This assumes that your PostgreSQL instance is configured to allow you to connect as the 'postgres' user - if not, you'll need to adjust the ``createdb`` line and the database settings in settings/base.py accordingly. -ElasticSearch +Elasticsearch ------------- -Wagtail integrates with ElasticSearch to provide full-text searching of your content, both within the Wagtail interface and on your site's front-end. If ElasticSearch is not available, Wagtail will fall back to much more basic search functionality using database queries. ElasticSearch is pre-installed as part of the Vagrant virtual machine image; non-Vagrant users can use the `debian.sh `__ or `ubuntu.sh `__ installation scripts as a guide. +Wagtail integrates with Elasticsearch to provide full-text searching of your content, both within the Wagtail interface and on your site's front-end. If Elasticsearch is not available, Wagtail will fall back to much more basic search functionality using database queries. Elasticsearch is pre-installed as part of the Vagrant virtual machine image; non-Vagrant users can use the `debian.sh `__ or `ubuntu.sh `__ installation scripts as a guide. -To enable ElasticSearch for your project, uncomment the ``elasticsearch`` line from your project's requirements.txt, and in ``myprojectname/settings/base.py``, uncomment the WAGTAILSEARCH_BACKENDS section. Then run:: +To enable Elasticsearch for your project, uncomment the ``elasticsearch`` line from your project's requirements.txt, and in ``myprojectname/settings/base.py``, uncomment the WAGTAILSEARCH_BACKENDS section. Then run:: pip install -r requirements.txt ./manage.py update_index diff --git a/docs/howto/contributing.rst b/docs/howto/contributing.rst index 797176997..f43c63d46 100644 --- a/docs/howto/contributing.rst +++ b/docs/howto/contributing.rst @@ -18,6 +18,70 @@ Coding guidelines * Python 2 and 3 compatibility. All contributions should support Python 2 and 3 and we recommend using the `six `_ compatibility library (use the pip version installed as a dependency, not the version bundled with Django). * Tests. Wagtail has a suite of tests, which we are committed to improving and expanding. We run continuous integration at `travis-ci.org/torchbox/wagtail `_ to ensure that no commits or pull requests introduce test failures. If your contributions add functionality to Wagtail, please include the additional tests to cover it; if your contributions alter existing functionality, please update the relevant tests accordingly. +Running the unit tests +~~~~~~~~~~~~~~~~~~~~~~ + +In order to run Wagtail's test suite, you will need to install some dependencies first. We recommend installing these into a virtual environment. + + +**Setting up the virtual environment** + +If you are using Python 3.3 or above, run the following commands in your shell +at the root of the Wagtail repo:: + + pyvenv venv + source venv/bin/activate + python setup.py develop + pip install -r requirements-dev.txt + +For Python 2, you will need to install the ``virtualenv`` package and replace +the first line above with: + + virtualenv venv + +**Running the tests** + +With your virtual environment active, run the following command to run all the +tests:: + + python runtests.py + +**Running only some of the tests** + +At the time of writing, Wagtail has nearly 1000 tests which takes a while to +run. You can run tests for only one part of Wagtail by passing in the path as +an argument to ``runtests.py``:: + + python runtests.py wagtail.wagtailcore + +**Testing against PostgreSQL** + +By default, Wagtail tests against SQLite. If you need to test against a +different database, set the ``DATABASE_ENGINE`` environment variable to the +name of the Django database backend to test against:: + + DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 python runtests.py + +This will create a new database called ``test_wagtail`` in PostgreSQL and run +the tests against it. + +If you need to use a different user, password or host. Use the ``PGUSER``, ``PGPASSWORD`` and ``PGHOST`` environment variables. + +**Testing Elasticsearch** + +To test Elasticsearch, you need to have the ``elasticsearch`` package installed. + +Once installed, Wagtail will attempt to connect to a local instance of +Elasticsearch (``http://localhost:9200``) and use the index ``test_wagtail``. + +If your Elasticsearch instance is located somewhere else, you can set the +``ELASTICSEARCH_URL`` environment variable to point to its location:: + + ELASTICSEARCH_URL=http://my-elasticsearch-instance:9200 python runtests.py + +If you no longer want Wagtail to test against Elasticsearch, uninstall the +``elasticsearch`` package. + Styleguide ~~~~~~~~~~ diff --git a/docs/howto/performance.rst b/docs/howto/performance.rst index 57bac2131..924e49d9f 100644 --- a/docs/howto/performance.rst +++ b/docs/howto/performance.rst @@ -13,14 +13,14 @@ We have tried to minimise external dependencies for a working installation of Wa Cache ----- -We recommend `Redis `_ as a fast, persistent cache. Install Redis through your package manager (on Debian or Ubuntu: ``sudo apt-get install redis-server``), add ``django-redis-cache`` to your requirements.txt, and enable it as a cache backend:: +We recommend `Redis `_ as a fast, persistent cache. Install Redis through your package manager (on Debian or Ubuntu: ``sudo apt-get install redis-server``), add ``django-redis`` to your requirements.txt, and enable it as a cache backend:: CACHES = { 'default': { - 'BACKEND': 'redis_cache.cache.RedisCache', + 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': '127.0.0.1:6379', 'OPTIONS': { - 'CLIENT_CLASS': 'redis_cache.client.DefaultClient', + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', } } } diff --git a/docs/howto/settings.rst b/docs/howto/settings.rst index 61c546eb2..54a2c4b04 100644 --- a/docs/howto/settings.rst +++ b/docs/howto/settings.rst @@ -63,11 +63,9 @@ Apps (settings.py) 'django.contrib.messages', 'django.contrib.staticfiles', - 'south', 'compressor', 'taggit', 'modelcluster', - 'django.contrib.admin', 'wagtail.wagtailcore', 'wagtail.wagtailadmin', @@ -89,11 +87,6 @@ Wagtail requires several Django app modules, third-party apps, and defines sever Third-Party Apps ---------------- -``south`` - Used for database migrations. See `South Documentation`_. - -.. _South Documentation: http://south.readthedocs.org/en/latest/ - ``compressor`` Static asset combiner and minifier for Django. Compressor also enables for the use of preprocessors. See `Compressor Documentation`_. @@ -109,9 +102,6 @@ Third-Party Apps .. _the django-modelcluster github project page: https://github.com/torchbox/django-modelcluster -``django.contrib.admin`` - The Django admin module. While Wagtail will eventually provide a sites-editing interface, the Django admin is included for now to provide that functionality. - Wagtail Apps ------------ @@ -270,13 +260,13 @@ URL Patterns from wagtail.wagtailcore import urls as wagtail_urls from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.wagtaildocs import urls as wagtaildocs_urls - from wagtail.wagtailsearch.urls import frontend as wagtailsearch_frontend_urls + from wagtail.wagtailsearch import urls as wagtailsearch_urls urlpatterns = patterns('', url(r'^django-admin/', include(admin.site.urls)), url(r'^admin/', include(wagtailadmin_urls)), - url(r'^search/', include(wagtailsearch_frontend_urls)), + url(r'^search/', include(wagtailsearch_urls)), url(r'^documents/', include(wagtaildocs_urls)), # Optional urlconf for including your own vanilla Django urls/views @@ -438,11 +428,9 @@ settings.py 'django.contrib.messages', 'django.contrib.staticfiles', - 'south', 'compressor', 'taggit', 'modelcluster', - 'django.contrib.admin', 'wagtail.wagtailcore', 'wagtail.wagtailadmin', @@ -538,14 +526,14 @@ urls.py from wagtail.wagtailcore import urls as wagtail_urls from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.wagtaildocs import urls as wagtaildocs_urls - from wagtail.wagtailsearch.urls import frontend as wagtailsearch_frontend_urls + from wagtail.wagtailsearch import urls as wagtailsearch__urls urlpatterns = patterns('', url(r'^django-admin/', include(admin.site.urls)), url(r'^admin/', include(wagtailadmin_urls)), - url(r'^search/', include(wagtailsearch_frontend_urls)), + url(r'^search/', include(wagtailsearch_urls)), url(r'^documents/', include(wagtaildocs_urls)), # For anything not caught by a more specific rule above, hand over to diff --git a/docs/releases/0.9.rst b/docs/releases/0.9.rst index 1a526d305..31ad530d3 100644 --- a/docs/releases/0.9.rst +++ b/docs/releases/0.9.rst @@ -22,6 +22,9 @@ Core ---- * Added validation to prevent pages being created with only whitespace characters in their title fields + * The Page model now records the date/time that a page was first published, as the field ``first_published_at`` + * Increased the maximum length of a page slug from 50 to 255 characters + * Added hooks ``register_rich_text_embed_handler`` and ``register_rich_text_link_handler`` for customising link / embed handling within rich text fields Admin @@ -29,12 +32,13 @@ Admin **UI** - * Improvements to the layout of the admin menu footer + * Improvements to the layout of the left-hand menu footer * Added thousands separator for counters on dashboard * Added contextual links to admin notification messages * When copying pages, it is now possible to specify a place to copy to * Added pagination to the snippets listing and chooser * Page / document / image / snippet choosers now include a link to edit the chosen item + * Plain text fields in the page editor now use auto-expanding text areas **Page editor** @@ -60,6 +64,7 @@ Admin * Removed the need to add permission check on admin views (now automated) * Reversing ``django.contrib.auth.admin.login`` will no longer lead to Wagtails login view (making it easier to have front end views) * Added cache-control headers to all admin views. This allows Varnish/Squid/CDN to run on vanilla settings in front of a Wagtail site + * Date / time pickers now consistently use times without seconds, to prevent Javascript behaviour glitches when focusing / unfocusing fields Project template @@ -105,6 +110,15 @@ It is no longer necessary to pass the base model as a parameter, so this declara The old format is now deprecated; all existing ``InlinePanel`` declarations should be updated to the new format. +You no longer need ``LOGIN_URL`` and ``LOGIN_REDIRECT_URL`` to point to Wagtail admin. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are upgrading from an older version of Wagtail, you probably want to remove these from your project settings. + +Prevously, these two settings needed to be set to ``wagtailadmin_login`` and ``wagtailadmin_dashboard`` +respectively or Wagtail would become very tricky to log in to. This is no longer the case and Wagtail +should work fine without them. + Login/Password reset views renamed ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -113,9 +127,6 @@ This is no longer possible. Update any references to ``wagtailadmin_login``. Password reset view name has changed from ``password_reset`` to ``wagtailadmin_password_reset``. -You no longer need ``LOGIN_URL`` and ``LOGIN_REDIRECT_URL`` to point to Wagtail admin. -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Javascript includes in admin backend have been moved ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/releases/index.rst b/docs/releases/index.rst index b6e231e7d..8c6fd48d9 100644 --- a/docs/releases/index.rst +++ b/docs/releases/index.rst @@ -4,7 +4,6 @@ Release notes .. toctree:: :maxdepth: 1 - roadmap 0.9 0.8.6 0.8.5 diff --git a/docs/releases/roadmap.rst b/docs/releases/roadmap.rst deleted file mode 100644 index 9efcfd9da..000000000 --- a/docs/releases/roadmap.rst +++ /dev/null @@ -1,28 +0,0 @@ -Roadmap -------- - -The story so far -~~~~~~~~~~~~~~~~ - -Wagtail was developed for the Royal College of Art in 2013, and launched as an open source project in February 2014. The changes since its launch are recorded in the codebase: - -https://raw.github.com/torchbox/wagtail/master/CHANGELOG.txt - -In summary: - - * February 2014: Reduced dependencies, basic documentation, translations, tests - -What's next -~~~~~~~~~~~ - -The `issue list `_ gives a detailed view of the immediate tasks, but our current broad priorities are: - - * Better documentation: simple setup guides for all levels of user, a manual for editors and administrators, in-depth intstructions for Django developers. - * Block-level editing UI (see `Sir Trevor `_) - * Support for an HTML content type - * Simple inline stats - -You decide -~~~~~~~~~~ - -Please help us focus on the right things by raising issues for new features, or joining the discussion on existing issues. diff --git a/requirements-dev.txt b/requirements-dev.txt index 166dd353d..d4a4634a4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,9 @@ -# For coverage and PEP8 linting -coverage>=3.7.0 -flake8>=2.2.0 +# Required for running the tests mock>=1.0.0 python-dateutil>=2.2 pytz>=2014.7 +Pillow>=2.7.0 + +# For coverage and PEP8 linting +coverage>=3.7.0 +flake8>=2.2.0 diff --git a/setup.py b/setup.py index 2cf645026..d2641fc91 100644 --- a/setup.py +++ b/setup.py @@ -28,11 +28,13 @@ PY3 = sys.version_info[0] == 3 install_requires = [ - "Django>=1.7.0,<1.8", + "Django>=1.7.1,<1.8", "django-compressor>=1.4", + "django-libsass>=0.2", "django-modelcluster>=0.5", "django-taggit==0.12.3", "django-treebeard==3.0", + "Pillow>=2.6.1", "beautifulsoup4>=4.3.2", "html5lib==0.999", "Unidecode>=0.04.14", diff --git a/tox.ini b/tox.ini index 49db8ec7c..1f40d9017 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ base = coverage dj17 = - Django>=1.7,<1.8 + Django>=1.7.1,<1.8 dj18 = https://github.com/django/django/archive/stable/1.8.x.zip#egg=django diff --git a/wagtail/project_template/requirements.txt b/wagtail/project_template/requirements.txt index 844a4df90..2ff3ab199 100644 --- a/wagtail/project_template/requirements.txt +++ b/wagtail/project_template/requirements.txt @@ -1,13 +1,11 @@ # Minimal requirements -Django>=1.7,<1.8 +Django>=1.7.1,<1.8 wagtail==0.8.6 -django-libsass>=0.2 -Pillow>=2.6.1 # Recommended components (require additional setup): # psycopg2==2.5.2 # elasticsearch==1.1.1 # Recommended components to improve performance in production: -# django-redis-cache==0.13.0 +# django-redis==3.8.2 # django-celery==3.1.10 diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py index 96d919cbd..03f2d72e8 100644 --- a/wagtail/tests/settings.py +++ b/wagtail/tests/settings.py @@ -11,13 +11,8 @@ MEDIA_URL = '/media/' DATABASES = { 'default': { - 'ENGINE': os.environ.get('DATABASE_ENGINE', 'django.db.backends.postgresql_psycopg2'), - 'NAME': os.environ.get('DATABASE_NAME', 'wagtaildemo'), - 'TEST_NAME': os.environ.get('DATABASE_NAME', 'test_wagtaildemo'), - 'USER': os.environ.get('DATABASE_USER', 'postgres'), - 'PASSWORD': os.environ.get('DATABASE_PASS', None), - 'HOST': os.environ.get('DATABASE_HOST', None), - 'PORT': os.environ.get('DATABASE_PORT', None), + 'ENGINE': os.environ.get('DATABASE_ENGINE', 'django.db.backends.sqlite3'), + 'NAME': os.environ.get('DATABASE_NAME', 'wagtail'), } } diff --git a/wagtail/wagtailadmin/edit_handlers.py b/wagtail/wagtailadmin/edit_handlers.py index a694d9b5b..4990485b0 100644 --- a/wagtail/wagtailadmin/edit_handlers.py +++ b/wagtail/wagtailadmin/edit_handlers.py @@ -25,26 +25,43 @@ from wagtail.wagtailcore.utils import camelcase_to_underscore, resolve_model_str from wagtail.utils.deprecation import RemovedInWagtail11Warning +# Form field properties to override whenever we encounter a model field +# that matches one of these types - including subclasses FORM_FIELD_OVERRIDES = { models.DateField: {'widget': widgets.AdminDateInput}, models.TimeField: {'widget': widgets.AdminTimeInput}, models.DateTimeField: {'widget': widgets.AdminDateTimeInput}, - TaggableManager: {'widget': widgets.AdminTagWidget} + TaggableManager: {'widget': widgets.AdminTagWidget}, +} + +# Form field properties to override whenever we encounter a model field +# that matches one of these types exactly, ignoring subclasses. +# (This allows us to override the widget for models.TextField, but leave +# the RichTextField widget alone) +DIRECT_FORM_FIELD_OVERRIDES = { + models.TextField: {'widget': widgets.AdminAutoHeightTextInput}, } # Callback to allow us to override the default form fields provided for each model field. def formfield_for_dbfield(db_field, **kwargs): - # snarfed from django/contrib/admin/options.py + # adapted from django/contrib/admin/options.py + + overrides = None # If we've got overrides for the formfield defined, use 'em. **kwargs # passed to formfield_for_dbfield override the defaults. - for klass in db_field.__class__.mro(): - if klass in FORM_FIELD_OVERRIDES: - kwargs = dict(copy.deepcopy(FORM_FIELD_OVERRIDES[klass]), **kwargs) - return db_field.formfield(**kwargs) + if db_field.__class__ in DIRECT_FORM_FIELD_OVERRIDES: + overrides = DIRECT_FORM_FIELD_OVERRIDES[db_field.__class__] + else: + for klass in db_field.__class__.mro(): + if klass in FORM_FIELD_OVERRIDES: + overrides = FORM_FIELD_OVERRIDES[klass] + break + + if overrides: + kwargs = dict(copy.deepcopy(overrides), **kwargs) - # For any other type of field, just call its formfield() method. return db_field.formfield(**kwargs) diff --git a/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js b/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js index 4f53792b9..448d4c052 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js +++ b/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js @@ -104,7 +104,7 @@ function initDateTimeChooser(id) { if (window.dateTimePickerTranslations) { $('#' + id).datetimepicker({ closeOnDateSelect: true, - format: 'Y-m-d H:i:s', + format: 'Y-m-d H:i', scrollInput:false, i18n: { lang: window.dateTimePickerTranslations @@ -113,7 +113,7 @@ function initDateTimeChooser(id) { }); } else { $('#' + id).datetimepicker({ - format: 'Y-m-d H:i:s', + format: 'Y-m-d H:i', }); } } diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/layouts/page-editor.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/layouts/page-editor.scss index 329f39386..e9de153fa 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/layouts/page-editor.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/layouts/page-editor.scss @@ -267,7 +267,8 @@ } .struct-block .widget-text_input > label, - .struct-block .widget-textarea > label{ + .struct-block .widget-textarea > label, + .struct-block .widget-admin_auto_height_text_input > label{ display:none; } diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html b/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html index 70221d38e..c6aea6d41 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html @@ -18,7 +18,7 @@ diff --git a/wagtail/wagtailadmin/tests/test_edit_handlers.py b/wagtail/wagtailadmin/tests/test_edit_handlers.py index f9cbdd020..0e38456ed 100644 --- a/wagtail/wagtailadmin/tests/test_edit_handlers.py +++ b/wagtail/wagtailadmin/tests/test_edit_handlers.py @@ -16,10 +16,11 @@ from wagtail.wagtailadmin.edit_handlers import ( InlinePanel, ) -from wagtail.wagtailadmin.widgets import AdminPageChooser, AdminDateInput +from wagtail.wagtailadmin.widgets import AdminPageChooser, AdminDateInput, AdminAutoHeightTextInput from wagtail.wagtailimages.edit_handlers import ImageChooserPanel from wagtail.wagtailcore.models import Page, Site -from wagtail.tests.models import PageChooserModel, EventPageChooserModel, EventPage, EventPageSpeaker +from wagtail.wagtailcore.fields import RichTextArea +from wagtail.tests.models import PageChooserModel, EventPageChooserModel, EventPage, EventPageSpeaker, SimplePage from wagtail.tests.utils import WagtailTestUtils from wagtail.utils.deprecation import RemovedInWagtail11Warning @@ -43,6 +44,22 @@ class TestGetFormForModel(TestCase): self.assertIn('speakers', form.formsets) self.assertIn('related_links', form.formsets) + def test_direct_form_field_overrides(self): + # Test that field overrides defined through DIRECT_FORM_FIELD_OVERRIDES + # are applied + + SimplePageForm = get_form_for_model(SimplePage) + simple_form = SimplePageForm() + # plain TextFields should use AdminAutoHeightTextInput as the widget + self.assertEqual(type(simple_form.fields['content'].widget), AdminAutoHeightTextInput) + + # This override should NOT be applied to subclasses of TextField such as + # RichTextField - they should retain their default widgets + EventPageForm = get_form_for_model(EventPage) + event_form = EventPageForm() + self.assertEqual(type(event_form.fields['body'].widget), RichTextArea) + + def test_get_form_for_model_with_specific_fields(self): EventPageForm = get_form_for_model(EventPage, fields=['date_from'], formsets=['speakers']) form = EventPageForm() diff --git a/wagtail/wagtailadmin/tests/test_pages_views.py b/wagtail/wagtailadmin/tests/test_pages_views.py index 0d4b5e93c..8bd3634a7 100644 --- a/wagtail/wagtailadmin/tests/test_pages_views.py +++ b/wagtail/wagtailadmin/tests/test_pages_views.py @@ -1,5 +1,6 @@ from datetime import timedelta import unittest +import mock from django.test import TestCase from django.core.urlresolvers import reverse @@ -212,6 +213,7 @@ class TestPageCreation(TestCase, WagtailTestUtils): self.assertEqual(page.title, post_data['title']) self.assertIsInstance(page, SimplePage) self.assertFalse(page.live) + self.assertFalse(page.first_published_at) # treebeard should report no consistency problems with the tree self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems') @@ -273,12 +275,8 @@ class TestPageCreation(TestCase, WagtailTestUtils): def test_create_simplepage_post_publish(self): # Connect a mock signal handler to page_published signal - signal_fired = [False] - signal_page = [None] - def page_published_handler(sender, instance, **kwargs): - signal_fired[0] = True - signal_page[0] = instance - page_published.connect(page_published_handler) + mock_handler = mock.MagicMock() + page_published.connect(mock_handler) # Post post_data = { @@ -298,11 +296,15 @@ class TestPageCreation(TestCase, WagtailTestUtils): self.assertEqual(page.title, post_data['title']) self.assertIsInstance(page, SimplePage) self.assertTrue(page.live) + self.assertTrue(page.first_published_at) # Check that the page_published signal was fired - self.assertTrue(signal_fired[0]) - self.assertEqual(signal_page[0], page) - self.assertEqual(signal_page[0], signal_page[0].specific) + self.assertEqual(mock_handler.call_count, 1) + mock_call = mock_handler.mock_calls[0][2] + + self.assertEqual(mock_call['sender'], page.specific_class) + self.assertEqual(mock_call['instance'], page) + self.assertIsInstance(mock_call['instance'], page.specific_class) # treebeard should report no consistency problems with the tree self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems') @@ -333,6 +335,7 @@ class TestPageCreation(TestCase, WagtailTestUtils): self.assertTrue(PageRevision.objects.filter(page=page).exclude(approved_go_live_at__isnull=True).exists()) # But Page won't be live self.assertFalse(page.live) + self.assertFalse(page.first_published_at) self.assertTrue(page.status_string, "scheduled") def test_create_simplepage_post_submit(self): @@ -357,6 +360,7 @@ class TestPageCreation(TestCase, WagtailTestUtils): self.assertEqual(page.title, post_data['title']) self.assertIsInstance(page, SimplePage) self.assertFalse(page.live) + self.assertFalse(page.first_published_at) # The latest revision for the page should now be in moderation self.assertTrue(page.get_latest_revision().submitted_for_moderation) @@ -438,12 +442,13 @@ class TestPageEdit(TestCase, WagtailTestUtils): self.root_page = Page.objects.get(id=2) # Add child page - self.child_page = SimplePage() - self.child_page.title = "Hello world!" - self.child_page.slug = "hello-world" - self.child_page.live = True - self.root_page.add_child(instance=self.child_page) - self.child_page.save_revision() + child_page = SimplePage( + title="Hello world!", + slug="hello-world", + ) + self.root_page.add_child(instance=child_page) + child_page.save_revision().publish() + self.child_page = SimplePage.objects.get(id=child_page.id) # Add event page (to test edit handlers) self.event_page = EventPage() @@ -572,18 +577,17 @@ class TestPageEdit(TestCase, WagtailTestUtils): def test_page_edit_post_publish(self): # Connect a mock signal handler to page_published signal - signal_fired = [False] - signal_page = [None] - def page_published_handler(sender, instance, **kwargs): - signal_fired[0] = True - signal_page[0] = instance - page_published.connect(page_published_handler) + mock_handler = mock.MagicMock() + page_published.connect(mock_handler) # Set has_unpublished_changes=True on the existing record to confirm that the publish action # is resetting it (and not just leaving it alone) self.child_page.has_unpublished_changes = True self.child_page.save() + # Save current value of first_published_at so we can check that it doesn't change + first_published_at = SimplePage.objects.get(id=self.child_page.id).first_published_at + # Tests publish from edit page post_data = { 'title': "I've been edited!", @@ -601,13 +605,19 @@ class TestPageEdit(TestCase, WagtailTestUtils): self.assertEqual(child_page_new.title, post_data['title']) # Check that the page_published signal was fired - self.assertTrue(signal_fired[0]) - self.assertEqual(signal_page[0], child_page_new) - self.assertEqual(signal_page[0], signal_page[0].specific) + self.assertEqual(mock_handler.call_count, 1) + mock_call = mock_handler.mock_calls[0][2] + + self.assertEqual(mock_call['sender'], child_page_new.specific_class) + self.assertEqual(mock_call['instance'], child_page_new) + self.assertIsInstance(mock_call['instance'], child_page_new.specific_class) # The page shouldn't have "has_unpublished_changes" flag set self.assertFalse(child_page_new.has_unpublished_changes) + # first_published_at should not change as it was already set + self.assertEqual(first_published_at, child_page_new.first_published_at) + def test_edit_post_publish_scheduled(self): go_live_at = timezone.now() + timedelta(days=1) expire_at = timezone.now() + timedelta(days=2) @@ -902,12 +912,8 @@ class TestPageDelete(TestCase, WagtailTestUtils): def test_page_delete_post(self): # Connect a mock signal handler to page_unpublished signal - signal_fired = [False] - signal_page = [None] - def page_unpublished_handler(sender, instance, **kwargs): - signal_fired[0] = True - signal_page[0] = instance - page_unpublished.connect(page_unpublished_handler) + mock_handler = mock.MagicMock() + page_unpublished.connect(mock_handler) # Post response = self.client.post(reverse('wagtailadmin_pages_delete', args=(self.child_page.id, ))) @@ -922,9 +928,12 @@ class TestPageDelete(TestCase, WagtailTestUtils): self.assertEqual(Page.objects.filter(path__startswith=self.root_page.path, slug='hello-world').count(), 0) # Check that the page_unpublished signal was fired - self.assertTrue(signal_fired[0]) - self.assertEqual(signal_page[0], self.child_page) - self.assertEqual(signal_page[0], signal_page[0].specific) + self.assertEqual(mock_handler.call_count, 1) + mock_call = mock_handler.mock_calls[0][2] + + self.assertEqual(mock_call['sender'], self.child_page.specific_class) + self.assertEqual(mock_call['instance'], self.child_page) + self.assertIsInstance(mock_call['instance'], self.child_page.specific_class) def test_page_delete_notlive_post(self): # Same as above, but this makes sure the page_unpublished signal is not fired @@ -935,10 +944,8 @@ class TestPageDelete(TestCase, WagtailTestUtils): self.child_page.save() # Connect a mock signal handler to page_unpublished signal - signal_fired = [False] - def page_unpublished_handler(sender, instance, **kwargs): - signal_fired[0] = True - page_unpublished.connect(page_unpublished_handler) + mock_handler = mock.MagicMock() + page_unpublished.connect(mock_handler) # Post response = self.client.post(reverse('wagtailadmin_pages_delete', args=(self.child_page.id, ))) @@ -953,7 +960,7 @@ class TestPageDelete(TestCase, WagtailTestUtils): self.assertEqual(Page.objects.filter(path__startswith=self.root_page.path, slug='hello-world').count(), 0) # Check that the page_unpublished signal was not fired - self.assertFalse(signal_fired[0]) + self.assertEqual(mock_handler.call_count, 0) def test_subpage_deletion(self): # Connect mock signal handlers to page_unpublished, pre_delete and post_delete signals @@ -1472,12 +1479,8 @@ class TestPageUnpublish(TestCase, WagtailTestUtils): This posts to the unpublish view and checks that the page was unpublished """ # Connect a mock signal handler to page_unpublished signal - signal_fired = [False] - signal_page = [None] - def page_unpublished_handler(sender, instance, **kwargs): - signal_fired[0] = True - signal_page[0] = instance - page_unpublished.connect(page_unpublished_handler) + mock_handler = mock.MagicMock() + page_unpublished.connect(mock_handler) # Post to the unpublish page response = self.client.post(reverse('wagtailadmin_pages_unpublish', args=(self.page.id, ))) @@ -1489,9 +1492,12 @@ class TestPageUnpublish(TestCase, WagtailTestUtils): self.assertFalse(SimplePage.objects.get(id=self.page.id).live) # Check that the page_unpublished signal was fired - self.assertTrue(signal_fired[0]) - self.assertEqual(signal_page[0], self.page) - self.assertEqual(signal_page[0], signal_page[0].specific) + self.assertEqual(mock_handler.call_count, 1) + mock_call = mock_handler.mock_calls[0][2] + + self.assertEqual(mock_call['sender'], self.page.specific_class) + self.assertEqual(mock_call['instance'], self.page) + self.assertIsInstance(mock_call['instance'], self.page.specific_class) class TestApproveRejectModeration(TestCase, WagtailTestUtils): @@ -1522,12 +1528,8 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils): This posts to the approve moderation view and checks that the page was approved """ # Connect a mock signal handler to page_published signal - signal_fired = [False] - signal_page = [None] - def page_published_handler(sender, instance, **kwargs): - signal_fired[0] = True - signal_page[0] = instance - page_published.connect(page_published_handler) + mock_handler = mock.MagicMock() + page_published.connect(mock_handler) # Post response = self.client.post(reverse('wagtailadmin_pages_approve_moderation', args=(self.revision.id, ))) @@ -1542,9 +1544,12 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils): self.assertFalse(page.has_unpublished_changes, "Approving moderation failed to set has_unpublished_changes=False") # Check that the page_published signal was fired - self.assertTrue(signal_fired[0]) - self.assertEqual(signal_page[0], self.page) - self.assertEqual(signal_page[0], signal_page[0].specific) + self.assertEqual(mock_handler.call_count, 1) + mock_call = mock_handler.mock_calls[0][2] + + self.assertEqual(mock_call['sender'], self.page.specific_class) + self.assertEqual(mock_call['instance'], self.page) + self.assertIsInstance(mock_call['instance'], self.page.specific_class) def test_approve_moderation_when_later_revision_exists(self): self.page.title = "Goodbye world!" diff --git a/wagtail/wagtailadmin/widgets.py b/wagtail/wagtailadmin/widgets.py index 4e65201f2..19c35a7e0 100644 --- a/wagtail/wagtailadmin/widgets.py +++ b/wagtail/wagtailadmin/widgets.py @@ -15,17 +15,41 @@ from wagtail.wagtailcore.models import Page from taggit.forms import TagWidget +class AdminAutoHeightTextInput(WidgetWithScript, widgets.Textarea): + def __init__(self, attrs=None): + # Use more appropriate rows default, given autoheight will alter this anyway + default_attrs = {'rows': '1'} + if attrs: + default_attrs.update(attrs) + + super(AdminAutoHeightTextInput, self).__init__(default_attrs) + + def render_js_init(self, id_, name, value): + return '$("#{0}").autosize();'.format(id_) + class AdminDateInput(WidgetWithScript, widgets.DateInput): + # Set a default date format to match the one that our JS date picker expects - + # it can still be overridden explicitly, but this way it won't be affected by + # the DATE_INPUT_FORMATS setting + def __init__(self, attrs=None, format='%Y-%m-%d'): + super(AdminDateInput, self).__init__(attrs=attrs, format=format) + def render_js_init(self, id_, name, value): return 'initDateChooser({0});'.format(json.dumps(id_)) class AdminTimeInput(WidgetWithScript, widgets.TimeInput): + def __init__(self, attrs=None, format='%H:%M'): + super(AdminTimeInput, self).__init__(attrs=attrs, format=format) + def render_js_init(self, id_, name, value): return 'initTimeChooser({0});'.format(json.dumps(id_)) class AdminDateTimeInput(WidgetWithScript, widgets.DateTimeInput): + def __init__(self, attrs=None, format='%Y-%m-%d %H:%M'): + super(AdminDateTimeInput, self).__init__(attrs=attrs, format=format) + def render_js_init(self, id_, name, value): return 'initDateTimeChooser({0});'.format(json.dumps(id_)) diff --git a/wagtail/wagtailcore/blocks/field_block.py b/wagtail/wagtailcore/blocks/field_block.py index c6dd7b166..76c2dd9f4 100644 --- a/wagtail/wagtailcore/blocks/field_block.py +++ b/wagtail/wagtailcore/blocks/field_block.py @@ -68,13 +68,18 @@ class CharBlock(FieldBlock): class TextBlock(FieldBlock): - def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): - self.field = forms.CharField( - widget=forms.Textarea(), - required=required, help_text=help_text, - max_length=max_length, min_length=min_length) + def __init__(self, required=True, help_text=None, rows=1, max_length=None, min_length=None, **kwargs): + self.field_options = {'required': required, 'help_text': help_text, 'max_length': max_length, 'min_length': min_length} + self.rows = rows super(TextBlock, self).__init__(**kwargs) + @cached_property + def field(self): + from wagtail.wagtailadmin.widgets import AdminAutoHeightTextInput + field_kwargs = {'widget': AdminAutoHeightTextInput(attrs={'rows':self.rows})} + field_kwargs.update(self.field_options) + return forms.CharField(**field_kwargs) + def get_searchable_content(self, value): return [force_text(value)] diff --git a/wagtail/wagtailcore/blocks/stream_block.py b/wagtail/wagtailcore/blocks/stream_block.py index ad3a0a7c8..c491c4aa1 100644 --- a/wagtail/wagtailcore/blocks/stream_block.py +++ b/wagtail/wagtailcore/blocks/stream_block.py @@ -6,7 +6,7 @@ from django import forms from django.core.exceptions import ValidationError from django.forms.utils import ErrorList from django.template.loader import render_to_string -from django.utils.encoding import python_2_unicode_compatible +from django.utils.encoding import python_2_unicode_compatible, force_text from django.utils.html import format_html_join from django.utils.safestring import mark_safe @@ -192,7 +192,7 @@ class BaseStreamBlock(Block): def render_basic(self, value): return format_html_join('\n', '
{0}
', - [(child, child.block_type) for child in value] + [(force_text(child), child.block_type) for child in value] ) def get_searchable_content(self, value): diff --git a/wagtail/wagtailcore/blocks/struct_block.py b/wagtail/wagtailcore/blocks/struct_block.py index f4e37066f..2373ef75d 100644 --- a/wagtail/wagtailcore/blocks/struct_block.py +++ b/wagtail/wagtailcore/blocks/struct_block.py @@ -74,15 +74,13 @@ class BaseStructBlock(Block): for child_rendering in child_renderings ]) - - # Can these be rendered with a template? if self.label: return format_html('
    {1}
', self.label, list_items) else: return format_html('
    {0}
', list_items) def value_from_datadict(self, data, files, prefix): - return dict([ + return StructValue(self, [ (name, block.value_from_datadict(data, files, '%s-%s' % (prefix, name))) for name, block in self.child_blocks.items() ]) diff --git a/wagtail/wagtailcore/migrations/0011_page_first_published_at.py b/wagtail/wagtailcore/migrations/0011_page_first_published_at.py new file mode 100644 index 000000000..c1466240a --- /dev/null +++ b/wagtail/wagtailcore/migrations/0011_page_first_published_at.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0010_change_page_owner_to_null_on_delete'), + ] + + operations = [ + migrations.AddField( + model_name='page', + name='first_published_at', + field=models.DateTimeField(editable=False, null=True), + preserve_default=True, + ), + ] diff --git a/wagtail/wagtailcore/migrations/0012_extend_page_slug_field.py b/wagtail/wagtailcore/migrations/0012_extend_page_slug_field.py new file mode 100644 index 000000000..7fa57b132 --- /dev/null +++ b/wagtail/wagtailcore/migrations/0012_extend_page_slug_field.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0011_page_first_published_at'), + ] + + operations = [ + migrations.AlterField( + model_name='page', + name='slug', + field=models.SlugField(help_text='The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/', max_length=255), + preserve_default=True, + ), + ] diff --git a/wagtail/wagtailcore/migrations/0013_update_golive_expire_help_text.py b/wagtail/wagtailcore/migrations/0013_update_golive_expire_help_text.py new file mode 100644 index 000000000..dea1ca318 --- /dev/null +++ b/wagtail/wagtailcore/migrations/0013_update_golive_expire_help_text.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0012_extend_page_slug_field'), + ] + + operations = [ + migrations.AlterField( + model_name='page', + name='expire_at', + field=models.DateTimeField(help_text='Please add a date-time in the form YYYY-MM-DD hh:mm.', null=True, verbose_name='Expiry date/time', blank=True), + preserve_default=True, + ), + migrations.AlterField( + model_name='page', + name='go_live_at', + field=models.DateTimeField(help_text='Please add a date-time in the form YYYY-MM-DD hh:mm.', null=True, verbose_name='Go live date/time', blank=True), + preserve_default=True, + ), + ] diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 2688f4b1e..7889a5b57 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -265,7 +265,7 @@ class PageBase(models.base.ModelBase): @python_2_unicode_compatible class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed)): title = models.CharField(max_length=255, help_text=_("The page title as you'd like it to be seen by the public")) - slug = models.SlugField(help_text=_("The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/")) + slug = models.SlugField(max_length=255, help_text=_("The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/")) # TODO: enforce uniqueness on slug field per parent (will have to be done at the Django # level rather than db, since there is no explicit parent relation in the db) content_type = models.ForeignKey('contenttypes.ContentType', related_name='pages') @@ -278,12 +278,13 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed show_in_menus = models.BooleanField(default=False, help_text=_("Whether a link to this page will appear in automatically generated menus")) search_description = models.TextField(blank=True) - go_live_at = models.DateTimeField(verbose_name=_("Go live date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm:ss."), blank=True, null=True) - expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm:ss."), blank=True, null=True) + go_live_at = models.DateTimeField(verbose_name=_("Go live date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) + expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) expired = models.BooleanField(default=False, editable=False) locked = models.BooleanField(default=False, editable=False) + first_published_at = models.DateTimeField(null=True, editable=False) latest_revision_created_at = models.DateTimeField(null=True, editable=False) search_fields = ( @@ -1099,6 +1100,7 @@ class PageRevision(models.Model): obj.owner = self.page.owner obj.locked = self.page.locked obj.latest_revision_created_at = self.page.latest_revision_created_at + obj.first_published_at = self.page.first_published_at return obj @@ -1139,6 +1141,11 @@ class PageRevision(models.Model): # If page goes live clear the approved_go_live_at of all revisions page.revisions.update(approved_go_live_at=None) page.expired = False # When a page is published it can't be expired + + # Set first_published_at if the page is being published now + if page.live and page.first_published_at is None: + page.first_published_at = timezone.now() + page.save() self.submitted_for_moderation = False page.revisions.update(submitted_for_moderation=False) diff --git a/wagtail/wagtailcore/rich_text.py b/wagtail/wagtailcore/rich_text.py index 16d64be51..d4ac8d656 100644 --- a/wagtail/wagtailcore/rich_text.py +++ b/wagtail/wagtailcore/rich_text.py @@ -4,15 +4,6 @@ from django.utils.html import escape from wagtail.wagtailcore.whitelist import Whitelister from wagtail.wagtailcore.models import Page - -from wagtail.wagtaildocs.models import Document - -# FIXME: we don't really want to import wagtailimages within core. -# For that matter, we probably don't want core to be concerned about translating -# HTML for the benefit of the hallo.js editor... -from wagtail.wagtailimages.models import get_image_model -from wagtail.wagtailimages.formats import get_image_format - from wagtail.wagtailcore import hooks @@ -22,80 +13,6 @@ from wagtail.wagtailcore import hooks # elsewhere in the database and is liable to change - from real HTML representation # to DB representation and back again. -class ImageEmbedHandler(object): - """ - ImageEmbedHandler will be invoked whenever we encounter an element in HTML content - with an attribute of data-embedtype="image". The resulting element in the database - representation will be: - - """ - @staticmethod - def get_db_attributes(tag): - """ - Given a tag that we've identified as an image embed (because it has a - data-embedtype="image" attribute), return a dict of the attributes we should - have on the resulting element. - """ - return { - 'id': tag['data-id'], - 'format': tag['data-format'], - 'alt': tag['data-alt'], - } - - @staticmethod - def expand_db_attributes(attrs, for_editor): - """ - Given a dict of attributes from the tag, return the real HTML - representation. - """ - Image = get_image_model() - try: - image = Image.objects.get(id=attrs['id']) - format = get_image_format(attrs['format']) - - if for_editor: - try: - return format.image_to_editor_html(image, attrs['alt']) - except: - return '' - else: - return format.image_to_html(image, attrs['alt']) - - except Image.DoesNotExist: - return "" - - -class MediaEmbedHandler(object): - """ - MediaEmbedHandler will be invoked whenever we encounter an element in HTML content - with an attribute of data-embedtype="media". The resulting element in the database - representation will be: - - """ - @staticmethod - def get_db_attributes(tag): - """ - Given a tag that we've identified as a media embed (because it has a - data-embedtype="media" attribute), return a dict of the attributes we should - have on the resulting element. - """ - return { - 'url': tag['data-url'], - } - - @staticmethod - def expand_db_attributes(attrs, for_editor): - """ - Given a dict of attributes from the tag, return the real HTML - representation. - """ - from wagtail.wagtailembeds import format - if for_editor: - return format.embed_to_editor_html(attrs['url']) - else: - return format.embed_to_frontend_html(attrs['url']) - - class PageLinkHandler(object): """ PageLinkHandler will be invoked whenever we encounter an element in HTML content @@ -127,35 +44,38 @@ class PageLinkHandler(object): return "" -class DocumentLinkHandler(object): - @staticmethod - def get_db_attributes(tag): - return {'id': tag['data-id']} - - @staticmethod - def expand_db_attributes(attrs, for_editor): - try: - doc = Document.objects.get(id=attrs['id']) - - if for_editor: - editor_attrs = 'data-linktype="document" data-id="%d" ' % doc.id - else: - editor_attrs = '' - - return '' % (editor_attrs, escape(doc.url)) - except Document.DoesNotExist: - return "" - - -EMBED_HANDLERS = { - 'image': ImageEmbedHandler, - 'media': MediaEmbedHandler, -} +EMBED_HANDLERS = {} LINK_HANDLERS = { 'page': PageLinkHandler, - 'document': DocumentLinkHandler, } +has_loaded_embed_handlers = False +has_loaded_link_handlers = False + +def get_embed_handler(embed_type): + global EMBED_HANDLERS, has_loaded_embed_handlers + + if not has_loaded_embed_handlers: + for hook in hooks.get_hooks('register_rich_text_embed_handler'): + handler_name, handler = hook() + EMBED_HANDLERS[handler_name] = handler + + has_loaded_embed_handlers = True + + return EMBED_HANDLERS[embed_type] + +def get_link_handler(link_type): + global LINK_HANDLERS, has_loaded_link_handlers + + if not has_loaded_link_handlers: + for hook in hooks.get_hooks('register_rich_text_link_handler'): + handler_name, handler = hook() + LINK_HANDLERS[handler_name] = handler + + has_loaded_link_handlers = True + + return LINK_HANDLERS[link_type] + class DbWhitelister(Whitelister): """ @@ -189,7 +109,7 @@ class DbWhitelister(Whitelister): if 'data-embedtype' in tag.attrs: embed_type = tag['data-embedtype'] # fetch the appropriate embed handler for this embedtype - embed_handler = EMBED_HANDLERS[embed_type] + embed_handler = get_embed_handler(embed_type) embed_attrs = embed_handler.get_db_attributes(tag) embed_attrs['embedtype'] = embed_type @@ -202,7 +122,7 @@ class DbWhitelister(Whitelister): cls.clean_node(doc, child) link_type = tag['data-linktype'] - link_handler = LINK_HANDLERS[link_type] + link_handler = get_link_handler(link_type) link_attrs = link_handler.get_db_attributes(tag) link_attrs['linktype'] = link_type tag.attrs.clear() @@ -238,12 +158,12 @@ def expand_db_html(html, for_editor=False): if 'linktype' not in attrs: # return unchanged return m.group(0) - handler = LINK_HANDLERS[attrs['linktype']] + handler = get_link_handler(attrs['linktype']) return handler.expand_db_attributes(attrs, for_editor) def replace_embed_tag(m): attrs = extract_attrs(m.group(1)) - handler = EMBED_HANDLERS[attrs['embedtype']] + handler = get_embed_handler(attrs['embedtype']) return handler.expand_db_attributes(attrs, for_editor) html = FIND_A_TAG.sub(replace_a_tag, html) diff --git a/wagtail/wagtailcore/tests/test_blocks.py b/wagtail/wagtailcore/tests/test_blocks.py index f1a839af4..458fbb341 100644 --- a/wagtail/wagtailcore/tests/test_blocks.py +++ b/wagtail/wagtailcore/tests/test_blocks.py @@ -438,6 +438,23 @@ class TestStructBlock(unittest.TestCase): self.assertEqual(content, ["Wagtail site"]) + def test_value_from_datadict(self): + block = blocks.StructBlock([ + ('title', blocks.CharBlock()), + ('link', blocks.URLBlock()), + ]) + + struct_val = block.value_from_datadict({ + 'mylink-title': "Torchbox", + 'mylink-link': "http://www.torchbox.com" + }, {}, 'mylink') + + self.assertEqual(struct_val['title'], "Torchbox") + self.assertEqual(struct_val['link'], "http://www.torchbox.com") + self.assertTrue(isinstance(struct_val, blocks.StructValue)) + self.assertTrue(isinstance(struct_val.bound_blocks['link'].block, blocks.URLBlock)) + + class TestListBlock(unittest.TestCase): def test_initialise_with_class(self): @@ -663,7 +680,7 @@ class TestStreamBlock(unittest.TestCase): def render_article(self, data): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() - paragraph = blocks.CharBlock() + paragraph = blocks.RichTextBlock() block = ArticleBlock() value = block.to_python(data) @@ -678,7 +695,7 @@ class TestStreamBlock(unittest.TestCase): }, { 'type': 'paragraph', - 'value': 'My first paragraph', + 'value': 'My first paragraph', }, { 'type': 'paragraph', @@ -687,8 +704,8 @@ class TestStreamBlock(unittest.TestCase): ]) self.assertIn('
My title
', html) - self.assertIn('
My first paragraph
', html) - self.assertIn('
My second paragraph
', html) + self.assertIn('
My first paragraph
', html) + self.assertIn('
My second paragraph
', html) def test_render_unknown_type(self): # This can happen if a developer removes a type from their StreamBlock @@ -704,7 +721,7 @@ class TestStreamBlock(unittest.TestCase): ]) self.assertNotIn('foo', html) self.assertNotIn('Hello', html) - self.assertIn('
My first paragraph
', html) + self.assertIn('
My first paragraph
', html) def render_form(self): class ArticleBlock(blocks.StreamBlock): diff --git a/wagtail/wagtailcore/tests/test_management_commands.py b/wagtail/wagtailcore/tests/test_management_commands.py index d631617b3..91fc727dc 100644 --- a/wagtail/wagtailcore/tests/test_management_commands.py +++ b/wagtail/wagtailcore/tests/test_management_commands.py @@ -188,6 +188,7 @@ class TestPublishScheduledPagesCommand(TestCase): p = Page.objects.get(slug='hello-world') self.assertTrue(p.live) + self.assertTrue(p.first_published_at) self.assertFalse(p.has_unpublished_changes) self.assertFalse(PageRevision.objects.filter(page=p).exclude(approved_go_live_at__isnull=True).exists()) diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py index be549f502..a5d0a125b 100644 --- a/wagtail/wagtailcore/tests/test_page_model.py +++ b/wagtail/wagtailcore/tests/test_page_model.py @@ -463,7 +463,7 @@ class TestCopyPage(TestCase): # Set the created_at of the revision to a time in the past revision = christmas_event.get_latest_revision() - revision.created_at = datetime.datetime(2014, 1, 1) + revision.created_at = datetime.datetime(2014, 1, 1, 0, 0, 0, tzinfo=pytz.utc) revision.save() # Copy it diff --git a/wagtail/wagtailcore/tests/test_rich_text.py b/wagtail/wagtailcore/tests/test_rich_text.py index 9c98c6d3f..8ffb917d8 100644 --- a/wagtail/wagtailcore/tests/test_rich_text.py +++ b/wagtail/wagtailcore/tests/test_rich_text.py @@ -3,10 +3,7 @@ from mock import patch from django.test import TestCase from wagtail.wagtailcore.rich_text import ( - ImageEmbedHandler, - MediaEmbedHandler, PageLinkHandler, - DocumentLinkHandler, DbWhitelister, extract_attrs, expand_db_html @@ -14,112 +11,6 @@ from wagtail.wagtailcore.rich_text import ( from bs4 import BeautifulSoup -class TestImageEmbedHandler(TestCase): - fixtures = ['wagtail/tests/fixtures/test.json'] - - def test_get_db_attributes(self): - soup = BeautifulSoup( - 'foo' - ) - tag = soup.b - result = ImageEmbedHandler.get_db_attributes(tag) - self.assertEqual(result, - {'alt': 'test-alt', - 'id': 'test-id', - 'format': 'test-format'}) - - def test_expand_db_attributes_page_does_not_exist(self): - result = ImageEmbedHandler.expand_db_attributes( - {'id': 0}, - False - ) - self.assertEqual(result, '') - - @patch('wagtail.wagtailimages.models.Image') - @patch('django.core.files.File') - def test_expand_db_attributes_not_for_editor(self, mock_file, mock_image): - result = ImageEmbedHandler.expand_db_attributes( - {'id': 1, - 'alt': 'test-alt', - 'format': 'left'}, - False - ) - self.assertIn('foo' - ) - tag = soup.b - result = MediaEmbedHandler.get_db_attributes(tag) - self.assertEqual(result, - {'url': 'test-url'}) - - @patch('wagtail.wagtailembeds.embeds.oembed') - def test_expand_db_attributes_for_editor(self, oembed): - oembed.return_value = { - 'title': 'test title', - 'author_name': 'test author name', - 'provider_name': 'test provider name', - 'type': 'test type', - 'thumbnail_url': 'test thumbnail url', - 'width': 'test width', - 'height': 'test height', - 'html': 'test html' - } - result = MediaEmbedHandler.expand_db_attributes( - {'url': 'http://www.youtube.com/watch/'}, - True - ) - self.assertIn('
', result) - self.assertIn('

test title

', result) - self.assertIn('

URL: http://www.youtube.com/watch/

', result) - self.assertIn('

Provider: test provider name

', result) - self.assertIn('

Author: test author name

', result) - self.assertIn('test title', result) - - @patch('wagtail.wagtailembeds.embeds.oembed') - def test_expand_db_attributes_not_for_editor(self, oembed): - oembed.return_value = { - 'title': 'test title', - 'author_name': 'test author name', - 'provider_name': 'test provider name', - 'type': 'test type', - 'thumbnail_url': 'test thumbnail url', - 'width': 'test width', - 'height': 'test height', - 'html': 'test html' - } - result = MediaEmbedHandler.expand_db_attributes( - {'url': 'http://www.youtube.com/watch/'}, - False - ) - self.assertIn('test html', result) - - class TestPageLinkHandler(TestCase): fixtures = ['wagtail/tests/fixtures/test.json'] @@ -155,42 +46,6 @@ class TestPageLinkHandler(TestCase): self.assertEqual(result, '
') -class TestDocumentLinkHandler(TestCase): - fixtures = ['wagtail/tests/fixtures/test.json'] - - def test_get_db_attributes(self): - soup = BeautifulSoup( - 'foo' - ) - tag = soup.a - result = DocumentLinkHandler.get_db_attributes(tag) - self.assertEqual(result, - {'id': 'test-id'}) - - def test_expand_db_attributes_document_does_not_exist(self): - result = DocumentLinkHandler.expand_db_attributes( - {'id': 0}, - False - ) - self.assertEqual(result, '') - - def test_expand_db_attributes_for_editor(self): - result = DocumentLinkHandler.expand_db_attributes( - {'id': 1}, - True - ) - self.assertEqual(result, - '') - - def test_expand_db_attributes_not_for_editor(self): - result = DocumentLinkHandler.expand_db_attributes( - {'id': 1}, - False - ) - self.assertEqual(result, - '') - - class TestDbWhiteLister(TestCase): def test_clean_tag_node_div(self): soup = BeautifulSoup( diff --git a/wagtail/wagtaildocs/rich_text.py b/wagtail/wagtaildocs/rich_text.py new file mode 100644 index 000000000..44f42a2e9 --- /dev/null +++ b/wagtail/wagtaildocs/rich_text.py @@ -0,0 +1,23 @@ +from django.utils.html import escape + +from wagtail.wagtaildocs.models import Document + + +class DocumentLinkHandler(object): + @staticmethod + def get_db_attributes(tag): + return {'id': tag['data-id']} + + @staticmethod + def expand_db_attributes(attrs, for_editor): + try: + doc = Document.objects.get(id=attrs['id']) + + if for_editor: + editor_attrs = 'data-linktype="document" data-id="%d" ' % doc.id + else: + editor_attrs = '' + + return '' % (editor_attrs, escape(doc.url)) + except Document.DoesNotExist: + return "" diff --git a/wagtail/wagtaildocs/tests.py b/wagtail/wagtaildocs/tests.py index 1a38f9ea3..863ad8e9c 100644 --- a/wagtail/wagtaildocs/tests.py +++ b/wagtail/wagtaildocs/tests.py @@ -1,6 +1,7 @@ from six import b import unittest import mock +from bs4 import BeautifulSoup from django.test import TestCase from django.contrib.auth import get_user_model @@ -16,6 +17,7 @@ from wagtail.tests.models import EventPage, EventPageRelatedLink from wagtail.wagtaildocs.models import Document from wagtail.wagtaildocs import models +from wagtail.wagtaildocs.rich_text import DocumentLinkHandler class TestDocumentPermissions(TestCase): @@ -576,3 +578,39 @@ class TestServeView(TestCase): def test_with_incorrect_filename(self): response = self.client.get(reverse('wagtaildocs_serve', args=(self.document.id, 'incorrectfilename'))) self.assertEqual(response.status_code, 404) + + +class TestDocumentRichTextLinkHandler(TestCase): + fixtures = ['wagtail/tests/fixtures/test.json'] + + def test_get_db_attributes(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.a + result = DocumentLinkHandler.get_db_attributes(tag) + self.assertEqual(result, + {'id': 'test-id'}) + + def test_expand_db_attributes_document_does_not_exist(self): + result = DocumentLinkHandler.expand_db_attributes( + {'id': 0}, + False + ) + self.assertEqual(result, '') + + def test_expand_db_attributes_for_editor(self): + result = DocumentLinkHandler.expand_db_attributes( + {'id': 1}, + True + ) + self.assertEqual(result, + '') + + def test_expand_db_attributes_not_for_editor(self): + result = DocumentLinkHandler.expand_db_attributes( + {'id': 1}, + False + ) + self.assertEqual(result, + '') diff --git a/wagtail/wagtaildocs/wagtail_hooks.py b/wagtail/wagtaildocs/wagtail_hooks.py index 41ce020bf..597d1652d 100644 --- a/wagtail/wagtaildocs/wagtail_hooks.py +++ b/wagtail/wagtaildocs/wagtail_hooks.py @@ -10,6 +10,7 @@ from wagtail.wagtailcore import hooks from wagtail.wagtailadmin.menu import MenuItem from wagtail.wagtaildocs import admin_urls +from wagtail.wagtaildocs.rich_text import DocumentLinkHandler @hooks.register('register_admin_urls') @@ -53,3 +54,8 @@ def register_permissions(): document_content_type = ContentType.objects.get(app_label='wagtaildocs', model='document') document_permissions = Permission.objects.filter(content_type = document_content_type) return document_permissions + + +@hooks.register('register_rich_text_link_handler') +def register_document_link_handler(): + return ('document', DocumentLinkHandler) diff --git a/wagtail/wagtailembeds/rich_text.py b/wagtail/wagtailembeds/rich_text.py new file mode 100644 index 000000000..763693c40 --- /dev/null +++ b/wagtail/wagtailembeds/rich_text.py @@ -0,0 +1,31 @@ +from wagtail.wagtailembeds import format + + +class MediaEmbedHandler(object): + """ + MediaEmbedHandler will be invoked whenever we encounter an element in HTML content + with an attribute of data-embedtype="media". The resulting element in the database + representation will be: + + """ + @staticmethod + def get_db_attributes(tag): + """ + Given a tag that we've identified as a media embed (because it has a + data-embedtype="media" attribute), return a dict of the attributes we should + have on the resulting element. + """ + return { + 'url': tag['data-url'], + } + + @staticmethod + def expand_db_attributes(attrs, for_editor): + """ + Given a dict of attributes from the tag, return the real HTML + representation. + """ + if for_editor: + return format.embed_to_editor_html(attrs['url']) + else: + return format.embed_to_frontend_html(attrs['url']) diff --git a/wagtail/wagtailembeds/tests.py b/wagtail/wagtailembeds/tests.py index 93231372e..b0799da38 100644 --- a/wagtail/wagtailembeds/tests.py +++ b/wagtail/wagtailembeds/tests.py @@ -2,8 +2,10 @@ import six.moves.urllib.request from six.moves.urllib.error import URLError from mock import patch -import warnings import unittest +from bs4 import BeautifulSoup + +from wagtail.wagtailembeds.rich_text import MediaEmbedHandler try: import embedly @@ -320,3 +322,55 @@ class TestEmbedBlock(TestCase): # Check that the embed was in the returned HTML self.assertIn('

Hello world!

', html) + + +class TestMediaEmbedHandler(TestCase): + def test_get_db_attributes(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.b + result = MediaEmbedHandler.get_db_attributes(tag) + self.assertEqual(result, + {'url': 'test-url'}) + + @patch('wagtail.wagtailembeds.embeds.oembed') + def test_expand_db_attributes_for_editor(self, oembed): + oembed.return_value = { + 'title': 'test title', + 'author_name': 'test author name', + 'provider_name': 'test provider name', + 'type': 'test type', + 'thumbnail_url': 'test thumbnail url', + 'width': 'test width', + 'height': 'test height', + 'html': 'test html' + } + result = MediaEmbedHandler.expand_db_attributes( + {'url': 'http://www.youtube.com/watch/'}, + True + ) + self.assertIn('
', result) + self.assertIn('

test title

', result) + self.assertIn('

URL: http://www.youtube.com/watch/

', result) + self.assertIn('

Provider: test provider name

', result) + self.assertIn('

Author: test author name

', result) + self.assertIn('test title', result) + + @patch('wagtail.wagtailembeds.embeds.oembed') + def test_expand_db_attributes_not_for_editor(self, oembed): + oembed.return_value = { + 'title': 'test title', + 'author_name': 'test author name', + 'provider_name': 'test provider name', + 'type': 'test type', + 'thumbnail_url': 'test thumbnail url', + 'width': 'test width', + 'height': 'test height', + 'html': 'test html' + } + result = MediaEmbedHandler.expand_db_attributes( + {'url': 'http://www.youtube.com/watch/'}, + False + ) + self.assertIn('test html', result) diff --git a/wagtail/wagtailembeds/wagtail_hooks.py b/wagtail/wagtailembeds/wagtail_hooks.py index 955f8216b..863544c5a 100644 --- a/wagtail/wagtailembeds/wagtail_hooks.py +++ b/wagtail/wagtailembeds/wagtail_hooks.py @@ -5,6 +5,7 @@ from django.utils.html import format_html from wagtail.wagtailcore import hooks from wagtail.wagtailembeds import urls +from wagtail.wagtailembeds.rich_text import MediaEmbedHandler @hooks.register('register_admin_urls') @@ -27,3 +28,8 @@ def editor_js(): 'wagtailembeds/js/hallo-plugins/hallo-wagtailembeds.js', urlresolvers.reverse('wagtailembeds_chooser') ) + + +@hooks.register('register_rich_text_embed_handler') +def register_media_embed_handler(): + return ('media', MediaEmbedHandler) diff --git a/wagtail/wagtailforms/tests.py b/wagtail/wagtailforms/tests.py index 6b064fd35..25571bcc1 100644 --- a/wagtail/wagtailforms/tests.py +++ b/wagtail/wagtailforms/tests.py @@ -79,29 +79,6 @@ class TestFormSubmission(TestCase): self.assertIn("baz", submission[0].form_data) -class TestPageModes(TestCase): - fixtures = ['test.json'] - - def setUp(self): - self.form_page = Page.objects.get(url_path='/home/contact-us/').specific - - def test_form(self): - response = self.form_page.serve_preview(self.form_page.dummy_request(), 'form') - - # Check response - self.assertContains(response, """""") - self.assertTemplateUsed(response, 'tests/form_page.html') - self.assertTemplateNotUsed(response, 'tests/form_page_landing.html') - - def test_landing(self): - response = self.form_page.serve_preview(self.form_page.dummy_request(), 'landing') - - # Check response - self.assertContains(response, "Thank you for your feedback.") - self.assertTemplateNotUsed(response, 'tests/form_page.html') - self.assertTemplateUsed(response, 'tests/form_page_landing.html') - - class TestFormBuilder(TestCase): fixtures = ['test.json'] diff --git a/wagtail/wagtailimages/rich_text.py b/wagtail/wagtailimages/rich_text.py new file mode 100644 index 000000000..9caaed3bd --- /dev/null +++ b/wagtail/wagtailimages/rich_text.py @@ -0,0 +1,45 @@ +from wagtail.wagtailimages.models import get_image_model +from wagtail.wagtailimages.formats import get_image_format + + +class ImageEmbedHandler(object): + """ + ImageEmbedHandler will be invoked whenever we encounter an element in HTML content + with an attribute of data-embedtype="image". The resulting element in the database + representation will be: + + """ + @staticmethod + def get_db_attributes(tag): + """ + Given a tag that we've identified as an image embed (because it has a + data-embedtype="image" attribute), return a dict of the attributes we should + have on the resulting element. + """ + return { + 'id': tag['data-id'], + 'format': tag['data-format'], + 'alt': tag['data-alt'], + } + + @staticmethod + def expand_db_attributes(attrs, for_editor): + """ + Given a dict of attributes from the tag, return the real HTML + representation. + """ + Image = get_image_model() + try: + image = Image.objects.get(id=attrs['id']) + format = get_image_format(attrs['format']) + + if for_editor: + try: + return format.image_to_editor_html(image, attrs['alt']) + except: + return '' + else: + return format.image_to_html(image, attrs['alt']) + + except Image.DoesNotExist: + return "" diff --git a/wagtail/wagtailimages/tests/test_rich_text.py b/wagtail/wagtailimages/tests/test_rich_text.py new file mode 100644 index 000000000..a9f91a468 --- /dev/null +++ b/wagtail/wagtailimages/tests/test_rich_text.py @@ -0,0 +1,60 @@ +from django.test import TestCase + +from bs4 import BeautifulSoup +from mock import patch + +from wagtail.wagtailimages.rich_text import ImageEmbedHandler + + +class TestImageEmbedHandler(TestCase): + fixtures = ['wagtail/tests/fixtures/test.json'] + + def test_get_db_attributes(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.b + result = ImageEmbedHandler.get_db_attributes(tag) + self.assertEqual(result, + {'alt': 'test-alt', + 'id': 'test-id', + 'format': 'test-format'}) + + def test_expand_db_attributes_page_does_not_exist(self): + result = ImageEmbedHandler.expand_db_attributes( + {'id': 0}, + False + ) + self.assertEqual(result, '') + + @patch('wagtail.wagtailimages.models.Image') + @patch('django.core.files.File') + def test_expand_db_attributes_not_for_editor(self, mock_file, mock_image): + result = ImageEmbedHandler.expand_db_attributes( + {'id': 1, + 'alt': 'test-alt', + 'format': 'left'}, + False + ) + self.assertIn('