From 895196695e6c58b35033267148336a1c1e391e98 Mon Sep 17 00:00:00 2001 From: James Murty Date: Mon, 10 Oct 2016 14:06:55 +1100 Subject: [PATCH 001/184] Make publishing filters work with generic Fluent models, re #32 Make the `PublishingPublishedFilter` and `PublishingStatusFilter` publishing status admin filters compatible with models that do not provide the standard ICEKit publishing features (and DB fields) from `PublishingModel`. This change is necessary to make these filters usable on parent admin pages that can include non-`PublishingModel` class, in particular the Fluent Pages default parent page admin listing /admin/fluent_pages/page/ This change is a hack and requires iterating over the actual instances of all models so it will be slow in cases where we cannot rely on `PublishingModel` fields being present, but works around the problem for now of filtering by two different publishing notions in the one polymorphic parent admin page. --- icekit/publishing/admin.py | 84 +++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/icekit/publishing/admin.py b/icekit/publishing/admin.py index 08bb366e..c8e308ef 100644 --- a/icekit/publishing/admin.py +++ b/icekit/publishing/admin.py @@ -56,8 +56,30 @@ def queryset(self, request, queryset): except TypeError: return queryset - isnull = not value - return queryset.filter(publishing_linked__isnull=isnull) + show_published = bool(value) + + # If admin is for a `PublishingModel` subclass use simple query... + if issubclass(queryset.model, PublishingModel): + return queryset.filter( + publishing_linked__isnull=not show_published) + + # ...if admin is not for a `PublishingModel` subclass we must iterate + # over child model instances to keep compatibility with Fluent page + # admin and models not derived from `PublishingModel`. + pks_to_exclude = [] + for item in queryset.get_real_instances(): + if show_published: + if item.status == UrlNode.PUBLISHED: + continue # Published according to Fluent Pages' UrlNode + elif getattr(item, 'has_been_published', False): + continue # Published according to ICEKit Publishing + else: + if item.status == UrlNode.DRAFT \ + and not getattr(item, 'has_been_published', False): + # Unpublished according to both Fluent and ICEKit + continue + pks_to_exclude.append(item.pk) + return queryset.exclude(pk__in=pks_to_exclude) class PublishingStatusFilter(SimpleListFilter): @@ -100,18 +122,52 @@ def lookups(self, request, model_admin): return lookups def queryset(self, request, queryset): - if self.value() == 'unpublished': - return queryset.filter(publishing_linked__isnull=True) - elif self.value() == 'published': - return queryset.filter(publishing_linked__isnull=False) - elif self.value() == 'out_of_date': - return queryset.filter( - publishing_modified_at__gt=F( - 'publishing_linked__publishing_modified_at')) - elif self.value() == 'up_to_date': - return queryset.filter( - publishing_modified_at__lte=F( - 'publishing_linked__publishing_modified_at')) + value = self.value() + if not value: + return queryset + # If admin is for a `PublishingModel` subclass use simple queries... + if issubclass(queryset.model, PublishingModel): + if value == 'unpublished': + return queryset.filter(publishing_linked__isnull=True) + elif value == 'published': + return queryset.filter(publishing_linked__isnull=False) + elif value == 'out_of_date': + return queryset.filter( + publishing_modified_at__gt=F( + 'publishing_linked__publishing_modified_at')) + elif value == 'up_to_date': + return queryset.filter( + publishing_modified_at__lte=F( + 'publishing_linked__publishing_modified_at')) + # ...if admin is not for a `PublishingModel` subclass we must iterate + # over child model instances to keep compatibility with Fluent page + # admin and models not derived from `PublishingModel`. + pks_to_exclude = [] + for item in queryset.get_real_instances(): + if value == 'unpublished': + if item.status == UrlNode.DRAFT \ + and not getattr(item, 'has_been_published', False): + # Unpublished according to both Fluent and ICEKit + continue + elif value == 'published': + if item.status == UrlNode.PUBLISHED: + continue # Published according to Fluent Pages' UrlNode + elif getattr(item, 'has_been_published', False): + continue # Published according to ICEKit Publishing + elif value == 'out_of_date': + if (getattr(item, 'publishing_linked', None) + and item.publishing_modified_at + > item.publishing_linked.publishing_modified_at + ): + continue # Published and outdated according to ICEKit + elif value == 'up_to_date': + if (getattr(item, 'publishing_linked', None) + and item.publishing_modified_at + <= item.publishing_linked.publishing_modified_at + ): + continue # Published and up-to-date according to ICEKit + pks_to_exclude.append(item.pk) + return queryset.exclude(pk__in=pks_to_exclude) class PublishingAdminForm(forms.ModelForm): From fe32cdc0858f94b088c5345041442552d7287b2a Mon Sep 17 00:00:00 2001 From: James Murty Date: Thu, 27 Oct 2016 14:40:11 +1100 Subject: [PATCH 002/184] Add tests for publishing basic M2M relationships, re #42 Add unit tests for M2M relationships between publishable models based on the tests from SFMOMA's Events. --- icekit/publishing/tests.py | 90 +++++++++++++++++-- .../migrations/0005_auto_20161027_1428.py | 58 ++++++++++++ icekit/tests/models.py | 22 +++++ 3 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 icekit/tests/migrations/0005_auto_20161027_1428.py diff --git a/icekit/publishing/tests.py b/icekit/publishing/tests.py index b953e18e..1fd3401a 100644 --- a/icekit/publishing/tests.py +++ b/icekit/publishing/tests.py @@ -39,7 +39,8 @@ get_draft_url, PublishingException, NotDraftException from icekit.publishing.tests_base import BaseAdminTest from icekit.tests.models import LayoutPageWithRelatedPages, \ - UnpublishableLayoutPage, Article, ArticleListing + UnpublishableLayoutPage, Article, ArticleListing, PublishingM2MModelA, \ + PublishingM2MModelB User = get_user_model() @@ -1271,9 +1272,86 @@ def test_verified_draft_url_for_non_publishingmodel(self): class TestPublishingOfM2MRelationships(TestCase): """ Test publishing works correctly with complex M2M relationships """ - def setUp(self): - self.skipTest("Complex M2M relationships not yet present in ICEKit") - - # TODO Add test_m2m_handling_in_publishing_clone_relations from SFMOMA - # TODO Add test_contentitem_m2m_backrefs_maintained_on_publish from SFMOMA + + def setUp(self): + pass + + def test_m2m_handling_in_publishing_clone_relations(self): + model_a = PublishingM2MModelA.objects.create() + model_b = PublishingM2MModelB.objects.create() + + ############################################################# + # Start by testing basic M2M functionality without publishing + ############################################################# + + # Start with a clean slate + self.assertEqual(0, model_a.related_b_models.count()) + self.assertEqual(0, model_b.related_a_models.count()) + + # Add/remove M2M draft relationships applies to reverse: + # PublishingM2MModelA -> PublishingM2MModelB + model_a.related_b_models.add(model_b) + self.assertEqual([model_a], list(model_b.related_a_models.all())) + model_a.related_b_models.remove(model_b) + self.assertEqual([], list(model_b.related_a_models.all())) + + # Add/remove M2M draft relationships applies to reverse: + # PublishingM2MModelB -> PublishingM2MModelA + model_b.related_a_models.add(model_a) + self.assertEqual([model_b], list(model_a.related_b_models.all())) + model_b.related_a_models.remove(model_a) + self.assertEqual([], list(model_a.related_b_models.all())) + + ############################################ + # Now test M2M functionality with publishing + ############################################ + + # Publish both sides + model_a.publish() + model_b.publish() + self.assertEqual( + [], list(model_a.publishing_linked.related_b_models.all())) + self.assertEqual( + [], list(model_b.publishing_linked.related_a_models.all())) + # Add M2M relationship: applies to draft copy, not published copies + model_a.related_b_models.add(model_b) + model_a.save() + self.assertEqual([model_b], list(model_a.related_b_models.all())) + self.assertEqual([model_a], list(model_b.related_a_models.all())) + self.assertEqual( + [], list(model_a.publishing_linked.related_b_models.all())) + self.assertEqual( + [], list(model_b.publishing_linked.related_a_models.all())) + # Published PublishingM2MModelB is reverse-related to draft + # PublishingM2MModelA *after* PublishingM2MModelA's relationship + # addition is published + model_a.publish() + self.assertEqual( + [model_b], list(model_a.publishing_linked.related_b_models.all())) + self.assertEqual( + [model_a], list(model_b.publishing_linked.related_a_models.all())) + # Published PublishingM2MModelB remains reverse-related to draft + # PublishingM2MModelA when relationship is removed from drafts but not + # yet published + model_a.related_b_models.remove(model_b) + model_a.save() + self.assertEqual( + [model_b], list(model_a.publishing_linked.related_b_models.all())) + self.assertEqual( + [model_a], list(model_b.publishing_linked.related_a_models.all())) + # Remaining reverse relationship manifests as draft-to-published + # relationships on our draft copies + self.assertEqual( + [model_b.publishing_linked], list(model_a.related_b_models.all())) + self.assertEqual( + [model_a.publishing_linked], list(model_b.related_a_models.all())) + # Published PublishingM2MModelB is no longer reverse-related to draft + # PublishingM2MModelA *after* relationship removal is published + model_a.publish() + self.assertEqual( + [], list(model_a.publishing_linked.related_b_models.all())) + self.assertEqual( + [], list(model_b.publishing_linked.related_a_models.all())) + self.assertEqual([], list(model_b.related_a_models.all())) + self.assertEqual([], list(model_a.related_b_models.all())) diff --git a/icekit/tests/migrations/0005_auto_20161027_1428.py b/icekit/tests/migrations/0005_auto_20161027_1428.py new file mode 100644 index 00000000..c3a54f15 --- /dev/null +++ b/icekit/tests/migrations/0005_auto_20161027_1428.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0004_auto_20160925_0758'), + ] + + operations = [ + migrations.CreateModel( + name='PublishingM2MModelA', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), + ('publishing_is_draft', models.BooleanField(db_index=True, editable=False, default=True)), + ('publishing_modified_at', models.DateTimeField(editable=False, default=django.utils.timezone.now)), + ('publishing_published_at', models.DateTimeField(null=True, editable=False)), + ('publishing_linked', models.OneToOneField(on_delete=django.db.models.deletion.SET_NULL, related_name='publishing_draft', to='tests.PublishingM2MModelA', null=True, editable=False)), + ], + options={ + 'abstract': False, + 'permissions': (('can_publish', 'Can publish'),), + }, + ), + migrations.CreateModel( + name='PublishingM2MModelB', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), + ('publishing_is_draft', models.BooleanField(db_index=True, editable=False, default=True)), + ('publishing_modified_at', models.DateTimeField(editable=False, default=django.utils.timezone.now)), + ('publishing_published_at', models.DateTimeField(null=True, editable=False)), + ('publishing_linked', models.OneToOneField(on_delete=django.db.models.deletion.SET_NULL, related_name='publishing_draft', to='tests.PublishingM2MModelB', null=True, editable=False)), + ('related_a_models', models.ManyToManyField(to='tests.PublishingM2MModelA', related_name='related_b_models')), + ], + options={ + 'abstract': False, + 'permissions': (('can_publish', 'Can publish'),), + }, + ), + migrations.CreateModel( + name='PublishingM2MThroughTable', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), + ('a_model', models.ForeignKey(to='tests.PublishingM2MModelA')), + ('b_model', models.ForeignKey(to='tests.PublishingM2MModelB')), + ], + ), + migrations.AddField( + model_name='publishingm2mmodelb', + name='through_related_a_models', + field=models.ManyToManyField(through='tests.PublishingM2MThroughTable', to='tests.PublishingM2MModelA', related_name='through_related_b_models'), + ), + ] diff --git a/icekit/tests/models.py b/icekit/tests/models.py index 0255e6b8..6f510679 100644 --- a/icekit/tests/models.py +++ b/icekit/tests/models.py @@ -16,6 +16,7 @@ from icekit.page_types.layout_page.abstract_models import \ AbstractLayoutPage, AbstractUnpublishableLayoutPage +from icekit.publishing.models import PublishingModel from icekit.plugins import ICEkitFluentContentsPagePlugin from icekit import mixins @@ -99,3 +100,24 @@ class UnpublishableLayoutPagePlugin(ICEkitFluentContentsPagePlugin): @page_type_pool.register class ArticleListingPlugin(ListingPagePlugin): model = ArticleListing + + +class PublishingM2MModelA(PublishingModel): + pass + + +class PublishingM2MModelB(PublishingModel): + related_a_models = models.ManyToManyField( + PublishingM2MModelA, + related_name='related_b_models', + ) + through_related_a_models = models.ManyToManyField( + PublishingM2MModelA, + related_name='through_related_b_models', + through='PublishingM2MThroughTable', + ) + + +class PublishingM2MThroughTable(models.Model): + a_model = models.ForeignKey(PublishingM2MModelA) + b_model = models.ForeignKey(PublishingM2MModelB) From 6a03a35f774aca0aadd7f11c3cc561dd19e39574 Mon Sep 17 00:00:00 2001 From: James Murty Date: Thu, 27 Oct 2016 14:51:03 +1100 Subject: [PATCH 003/184] Add unit tests for publishing M2M relationships with through tables, re #42 --- icekit/publishing/tests.py | 54 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/icekit/publishing/tests.py b/icekit/publishing/tests.py index 1fd3401a..12d4b69e 100644 --- a/icekit/publishing/tests.py +++ b/icekit/publishing/tests.py @@ -40,7 +40,7 @@ from icekit.publishing.tests_base import BaseAdminTest from icekit.tests.models import LayoutPageWithRelatedPages, \ UnpublishableLayoutPage, Article, ArticleListing, PublishingM2MModelA, \ - PublishingM2MModelB + PublishingM2MModelB, PublishingM2MThroughTable User = get_user_model() @@ -1303,17 +1303,31 @@ def test_m2m_handling_in_publishing_clone_relations(self): model_b.related_a_models.remove(model_a) self.assertEqual([], list(model_a.related_b_models.all())) + # Add/remove M2M draft relationships works with *through* table + through_rel = PublishingM2MThroughTable.objects.create( + a_model=model_a, b_model=model_b) + self.assertEqual([model_b], list(model_a.through_related_b_models.all())) + self.assertEqual([model_a], list(model_b.through_related_a_models.all())) + through_rel.delete() + self.assertEqual([], list(model_a.through_related_b_models.all())) + self.assertEqual([], list(model_b.through_related_a_models.all())) + ############################################ # Now test M2M functionality with publishing ############################################ - # Publish both sides + # Publish both sides: no relationships yet to published copies model_a.publish() model_b.publish() self.assertEqual( [], list(model_a.publishing_linked.related_b_models.all())) self.assertEqual( [], list(model_b.publishing_linked.related_a_models.all())) + self.assertEqual( + [], list(model_a.publishing_linked.through_related_b_models.all())) + self.assertEqual( + [], list(model_b.publishing_linked.through_related_a_models.all())) + # Add M2M relationship: applies to draft copy, not published copies model_a.related_b_models.add(model_b) model_a.save() @@ -1323,6 +1337,16 @@ def test_m2m_handling_in_publishing_clone_relations(self): [], list(model_a.publishing_linked.related_b_models.all())) self.assertEqual( [], list(model_b.publishing_linked.related_a_models.all())) + # Add through M2M relationship: applies to draft copy, not published copies + through_rel = PublishingM2MThroughTable.objects.create( + a_model=model_a, b_model=model_b) + self.assertEqual([model_b], list(model_a.through_related_b_models.all())) + self.assertEqual([model_a], list(model_b.through_related_a_models.all())) + self.assertEqual( + [], list(model_a.publishing_linked.through_related_b_models.all())) + self.assertEqual( + [], list(model_b.publishing_linked.through_related_a_models.all())) + # Published PublishingM2MModelB is reverse-related to draft # PublishingM2MModelA *after* PublishingM2MModelA's relationship # addition is published @@ -1331,6 +1355,12 @@ def test_m2m_handling_in_publishing_clone_relations(self): [model_b], list(model_a.publishing_linked.related_b_models.all())) self.assertEqual( [model_a], list(model_b.publishing_linked.related_a_models.all())) + # Same applies to the through relationship + self.assertEqual( + [model_b], list(model_a.publishing_linked.through_related_b_models.all())) + self.assertEqual( + [model_a], list(model_b.publishing_linked.through_related_a_models.all())) + # Published PublishingM2MModelB remains reverse-related to draft # PublishingM2MModelA when relationship is removed from drafts but not # yet published @@ -1340,12 +1370,25 @@ def test_m2m_handling_in_publishing_clone_relations(self): [model_b], list(model_a.publishing_linked.related_b_models.all())) self.assertEqual( [model_a], list(model_b.publishing_linked.related_a_models.all())) + # Same applies to the through relationship + through_rel.delete() + self.assertEqual( + [model_b], list(model_a.publishing_linked.through_related_b_models.all())) + self.assertEqual( + [model_a], list(model_b.publishing_linked.through_related_a_models.all())) + # Remaining reverse relationship manifests as draft-to-published # relationships on our draft copies self.assertEqual( [model_b.publishing_linked], list(model_a.related_b_models.all())) self.assertEqual( [model_a.publishing_linked], list(model_b.related_a_models.all())) + # Same applies to the through relationship + self.assertEqual( + [model_b.publishing_linked], list(model_a.through_related_b_models.all())) + self.assertEqual( + [model_a.publishing_linked], list(model_b.through_related_a_models.all())) + # Published PublishingM2MModelB is no longer reverse-related to draft # PublishingM2MModelA *after* relationship removal is published model_a.publish() @@ -1355,3 +1398,10 @@ def test_m2m_handling_in_publishing_clone_relations(self): [], list(model_b.publishing_linked.related_a_models.all())) self.assertEqual([], list(model_b.related_a_models.all())) self.assertEqual([], list(model_a.related_b_models.all())) + # Same applies to the through relationship + self.assertEqual( + [], list(model_a.publishing_linked.through_related_b_models.all())) + self.assertEqual( + [], list(model_b.publishing_linked.through_related_a_models.all())) + self.assertEqual([], list(model_b.through_related_a_models.all())) + self.assertEqual([], list(model_a.through_related_b_models.all())) From 28edb279e8aff7d586506f4fc70933b2d8f877c0 Mon Sep 17 00:00:00 2001 From: James Murty Date: Thu, 27 Oct 2016 17:02:29 +1100 Subject: [PATCH 004/184] Fix publishing for M2M relationships with explicit through tables, re #42 When publishable models have M2M relationships with other publishable models the `publishing_clone_relations` method does extra work to maintain the appropriate relationships between the draft and published copies when a model at either end of the relationship is published. This change fixes this M2M handling code to also work for M2Ms that have an explicit through table defined, in which case it is harder to manage the relationships since the management must be done through the intermediate model's manager. NOTE: this fix only works for very simple (an unrealistic) through tables M2M relationships that have no additional fields defined on the through table itself. The next step will be to improve this handling to clone the through table instance itself to retain its extra field values rather than creating a new one. --- icekit/publishing/models.py | 132 +++++++++++++++++++++++++++--------- icekit/publishing/tests.py | 14 ++-- 2 files changed, 108 insertions(+), 38 deletions(-) diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index f0f8f64f..b8591924 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -347,53 +347,104 @@ def publishing_clone_relations(self, src_obj): confusing, but here is a summary that might help: - when a draft object is published, the "current" and definitive - relationships are cloned to the published copy. The definitive - relationships are the draft-to-draft ones, as set in the admin. + relationships are cloned to the published copy. The definitive + relationships are the draft-to-draft ones, as set in the admin. - a "related draft" is the draft object at the other side of - a draft-to-draft M2M relationship + a draft-to-draft M2M relationship - if a related draft also has a published copy, a draft-to- - published relationship is added to that published copy. This - makes our newly-published item also "published" from the reverse - direction + published relationship is added to that published copy. This + makes our newly-published item also "published" from the reverse + direction - if our draft object has a related published copy without a - correponding related draft -- that is, a draft-to-published - relation without a definitive draft-to-draft relation -- then - we remove that relation as it is no longer "current". This - makes our newly-published item "unpublished" from the reverse - direction when an admin removes the underlying relationship. + correponding related draft -- that is, a draft-to-published + relation without a definitive draft-to-draft relation -- then + we remove that relation as it is no longer "current". This + makes our newly-published item "unpublished" from the reverse + direction when an admin removes the underlying relationship. An example case: - We have Event "E" (unpublished) and Program "P" (published) - We add an M2M relationship from E to P. Until the relationship - change is published it only affects drafts. Relationships are: - E draft <-> P draft + change is published it only affects drafts. Relationships are: + E draft <-> P draft - We publish E, applying the relationship to published copies on - both sides: - E draft <-> P draft - E published <-> P draft - P published <-> E draft + both sides: + E draft <-> P draft + E published <-> P draft + P published <-> E draft - We remove the M2M relationship between E and P (We could do this - from either side: remove E from P; or, remove P from E). The - draft-to-draft relation is removed but published copy - relationships are not affected: - E published <-> P draft - P published <-> E draft + from either side: remove E from P; or, remove P from E). The + draft-to-draft relation is removed but published copy + relationships are not affected: + E published <-> P draft + P published <-> E draft - We publish P (or E) to apply the relationshp removal to - published copies on both sides. No relationships remain. + published copies on both sides. No relationships remain. - See unit test ``.test_m2m_handling_in_publishing_clone_relations`` - in ``sfmoma.tests.tests.TestPagePublishing`` + There are two kinds of M2M relationships to handle: + + - standard M2M relationships with no explicit through table defined + (these get an auto-generated through table) which are easier to + handle because we can add/remove items with the relationship's + queryset directly + - M2M relationships with an explicit through table defined, which + are more difficult to handle because we must use the through + model's manager to add/remove relationships. For this case we + have the `add_through_model_relationship` and + `remove_through_model_relationship` functions and helpers. + + See unit tests in ``TestPublishingOfM2MRelationships``. """ + def get_through_model_fields(through_model, src_obj, rel_obj): + # Lookup fields for through model's to/from relationships + through_fks_by_class = {} + for field in through_model._meta.local_fields: + if not getattr(field, 'rel'): + continue + rel_model = field.rel.model + if rel_model in through_fks_by_class: + raise Exception( + "Found multiple FK fields for model %r in through" + " table model %r; this is a situation we don't handle." + % (rel_model, through_model)) + through_fks_by_class[rel_model] = field + src_field = through_fks_by_class[type(src_obj)] + dst_field = through_fks_by_class[type(rel_obj)] + return (src_field.name, dst_field.name) + + def add_through_model_relationship(through_model, src_obj, rel_obj): + src_field_name, dst_field_name = get_through_model_fields( + through_model, src_obj, rel_obj) + through_model.objects.create(**{ + src_field_name: src_obj, + dst_field_name: rel_obj, + }) + + def remove_through_model_relationship(through_model, src_obj, rel_obj): + src_field_name, dst_field_name = get_through_model_fields( + through_model, src_obj, rel_obj) + through_model.objects.filter(**{ + src_field_name: src_obj, + dst_field_name: rel_obj, + }).delete() + def clone(src, dst): published_rel_obj_copies_to_add = [] published_rel_objs_maybe_obsolete = [] for rel_obj in src.all(): - # If the object referenced by the M2M is publishable we - # clone it only if it is a draft copy. If it is not a - # publishable object we also clone it (True by default). + # If the object referenced by the M2M is publishable we only + # clone the relationship if it is a draft copy, not if it is + # a published copy. If it is not a publishable object at all, + # then we always clone the relationship (True by default). if getattr(rel_obj, 'publishing_is_draft', True): - dst.add(rel_obj) + # Handle standard M2M relationship (without through table) + if dst.through._meta.auto_created: + dst.add(rel_obj) + # Handle M2M *through* relationship + else: + add_through_model_relationship( + dst.through, self, rel_obj) # If the related object also has a published copy, we # need to make sure the published copy also knows about # this newly-published draft. We defer this until below @@ -412,7 +463,14 @@ def clone(src, dst): # Make published copies of related objects aware of our # newly-published draft, in case they weren't already. if published_rel_obj_copies_to_add: - src.add(*published_rel_obj_copies_to_add) + # Handle standard M2M relationship (without through table) + if src.through._meta.auto_created: + src.add(*published_rel_obj_copies_to_add) + # Handle M2M *through* relationship + else: + for rel_obj in published_rel_obj_copies_to_add: + add_through_model_relationship( + src.through, src_obj, rel_obj) # If related published copies have no corresponding related # draft after all the previous processing, the relationship is # obsolete and must be removed. @@ -422,24 +480,32 @@ def clone(src, dst): for published_rel_obj in published_rel_objs_maybe_obsolete: draft = published_rel_obj.get_draft() if not draft or draft.pk not in current_draft_rel_pks: - src.remove(published_rel_obj) + # Handle standard M2M relationship (without through table) + if src.through._meta.auto_created: + src.remove(published_rel_obj) + # Handle M2M *through* relationship + else: + remove_through_model_relationship( + dst.through, src_obj, rel_obj) + # Track the relationship through-tables we have processed to avoid # processing the same relationships in both forward and reverse # directions, which could otherwise happen in unusual cases like # for SFMOMA event M2M inter-relationships which are explicitly # defined both ways as a hack to expose form widgets. seen_rel_through_tables = set() + # Forward. for field in src_obj._meta.many_to_many: src = getattr(src_obj, field.name) dst = getattr(self, field.name) clone(src, dst) seen_rel_through_tables.add(field.rel.through) + # Reverse. for field in \ src_obj._meta.get_all_related_many_to_many_objects(): - # Skip reverse relationship if we have already seen, see note - # about `seen_rel_through_tables` above. + # Skip reverse relationship we have already seen if field.field.rel.through in seen_rel_through_tables: continue field_accessor_name = field.get_accessor_name() diff --git a/icekit/publishing/tests.py b/icekit/publishing/tests.py index 12d4b69e..e5e936e1 100644 --- a/icekit/publishing/tests.py +++ b/icekit/publishing/tests.py @@ -1347,17 +1347,21 @@ def test_m2m_handling_in_publishing_clone_relations(self): self.assertEqual( [], list(model_b.publishing_linked.through_related_a_models.all())) - # Published PublishingM2MModelB is reverse-related to draft - # PublishingM2MModelA *after* PublishingM2MModelA's relationship - # addition is published + # Published PublishingM2MModelA is related to draft PublishingModelB + # when it is published model_a.publish() self.assertEqual( [model_b], list(model_a.publishing_linked.related_b_models.all())) - self.assertEqual( - [model_a], list(model_b.publishing_linked.related_a_models.all())) # Same applies to the through relationship self.assertEqual( [model_b], list(model_a.publishing_linked.through_related_b_models.all())) + + # Published PublishingM2MModelB is reverse-related to draft + # PublishingM2MModelA *after* PublishingM2MModelA's relationship + # addition is published + self.assertEqual( + [model_a], list(model_b.publishing_linked.related_a_models.all())) + # Same applies to the through relationship self.assertEqual( [model_a], list(model_b.publishing_linked.through_related_a_models.all())) From b1c7cf8ee147017324731d257b4e04f219c220bc Mon Sep 17 00:00:00 2001 From: James Murty Date: Thu, 27 Oct 2016 17:21:05 +1100 Subject: [PATCH 005/184] Clone M2M through model relationships on publish, re #42 When managing M2M relationships with through tables between publishable models, clone the entire M2M record when adding a new relationship to keep the additional field information in the record. --- icekit/publishing/models.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index b8591924..53bdb669 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -391,7 +391,7 @@ def publishing_clone_relations(self, src_obj): - M2M relationships with an explicit through table defined, which are more difficult to handle because we must use the through model's manager to add/remove relationships. For this case we - have the `add_through_model_relationship` and + have the `clone_through_model_relationship` and `remove_through_model_relationship` functions and helpers. See unit tests in ``TestPublishingOfM2MRelationships``. @@ -413,13 +413,18 @@ def get_through_model_fields(through_model, src_obj, rel_obj): dst_field = through_fks_by_class[type(rel_obj)] return (src_field.name, dst_field.name) - def add_through_model_relationship(through_model, src_obj, rel_obj): + def clone_through_model_relationship( + through_model, src_obj, dst_obj, rel_obj): src_field_name, dst_field_name = get_through_model_fields( through_model, src_obj, rel_obj) - through_model.objects.create(**{ + through_rel = through_model.objects.get(**{ src_field_name: src_obj, - dst_field_name: rel_obj, + dst_field_name: rel_obj.get_draft(), }) + through_rel.pk = None + setattr(through_rel, src_field_name, dst_obj) + setattr(through_rel, dst_field_name, rel_obj) + through_rel.save() def remove_through_model_relationship(through_model, src_obj, rel_obj): src_field_name, dst_field_name = get_through_model_fields( @@ -443,8 +448,8 @@ def clone(src, dst): dst.add(rel_obj) # Handle M2M *through* relationship else: - add_through_model_relationship( - dst.through, self, rel_obj) + clone_through_model_relationship( + dst.through, src_obj, self, rel_obj) # If the related object also has a published copy, we # need to make sure the published copy also knows about # this newly-published draft. We defer this until below @@ -469,8 +474,8 @@ def clone(src, dst): # Handle M2M *through* relationship else: for rel_obj in published_rel_obj_copies_to_add: - add_through_model_relationship( - src.through, src_obj, rel_obj) + clone_through_model_relationship( + src.through, src_obj, src_obj, rel_obj) # If related published copies have no corresponding related # draft after all the previous processing, the relationship is # obsolete and must be removed. From 56828add3654e649322942d306228a2f85cc3cc2 Mon Sep 17 00:00:00 2001 From: James Murty Date: Mon, 31 Oct 2016 16:32:28 +1100 Subject: [PATCH 006/184] Refactor M2M handling to iterate through table entries, re #42 Improve handling of M2M relationships with through tables by refactoring the M2M cloning process to iterate over entries of the through table model, rather than trying to reverse engineer the through table entry from the two ends of the relationship. This change should improve our ability to clone M2M relationships with explicit through tables to keep the relationships of published item copies in sync with their corresponding draft copies. --- icekit/publishing/models.py | 147 ++++++++++++++---------------------- 1 file changed, 56 insertions(+), 91 deletions(-) diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index 53bdb669..04ff9fd7 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -382,7 +382,10 @@ def publishing_clone_relations(self, src_obj): - We publish P (or E) to apply the relationshp removal to published copies on both sides. No relationships remain. - There are two kinds of M2M relationships to handle: + To handle M2M relationships in general we iterate over entries in the + through-table relationship table to clone these entries, or remove + them, as appropriate. By processing the M2M relationships in this way + we can handle both kinds of M2M relationship: - standard M2M relationships with no explicit through table defined (these get an auto-generated through table) which are easier to @@ -390,108 +393,73 @@ def publishing_clone_relations(self, src_obj): queryset directly - M2M relationships with an explicit through table defined, which are more difficult to handle because we must use the through - model's manager to add/remove relationships. For this case we - have the `clone_through_model_relationship` and - `remove_through_model_relationship` functions and helpers. + model's manager to add/remove relationships. See unit tests in ``TestPublishingOfM2MRelationships``. """ - def get_through_model_fields(through_model, src_obj, rel_obj): - # Lookup fields for through model's to/from relationships - through_fks_by_class = {} - for field in through_model._meta.local_fields: - if not getattr(field, 'rel'): - continue - rel_model = field.rel.model - if rel_model in through_fks_by_class: - raise Exception( - "Found multiple FK fields for model %r in through" - " table model %r; this is a situation we don't handle." - % (rel_model, through_model)) - through_fks_by_class[rel_model] = field - src_field = through_fks_by_class[type(src_obj)] - dst_field = through_fks_by_class[type(rel_obj)] - return (src_field.name, dst_field.name) - - def clone_through_model_relationship( - through_model, src_obj, dst_obj, rel_obj): - src_field_name, dst_field_name = get_through_model_fields( - through_model, src_obj, rel_obj) - through_rel = through_model.objects.get(**{ - src_field_name: src_obj, - dst_field_name: rel_obj.get_draft(), - }) - through_rel.pk = None - setattr(through_rel, src_field_name, dst_obj) - setattr(through_rel, dst_field_name, rel_obj) - through_rel.save() - - def remove_through_model_relationship(through_model, src_obj, rel_obj): - src_field_name, dst_field_name = get_through_model_fields( - through_model, src_obj, rel_obj) - through_model.objects.filter(**{ - src_field_name: src_obj, - dst_field_name: rel_obj, + + def clone_through_model_relationship(src_manager, through_entry, + dst_obj, rel_obj): + if src_manager.through.objects.filter(**{ + src_manager.source_field_name: dst_obj, + src_manager.target_field_name: rel_obj, + }).exists(): + return + through_entry.pk = None + setattr(through_entry, src_manager.source_field_name, dst_obj) + setattr(through_entry, src_manager.target_field_name, rel_obj) + through_entry.save() + + def delete_through_model_relationship(src_manager, rel_obj): + src_manager.through.objects.filter(**{ + src_manager.source_field_name: src_obj, + src_manager.target_field_name: rel_obj, }).delete() - def clone(src, dst): - published_rel_obj_copies_to_add = [] + def clone(src_manager): + through_qs = src_manager.through.objects \ + .filter(**{src_manager.source_field_name: src_obj}) \ + .select_related('%s__publishing_linked' + % src_manager.target_field_name) published_rel_objs_maybe_obsolete = [] - for rel_obj in src.all(): + current_draft_rel_pks = set() + for through_entry in through_qs: + rel_obj = getattr( + through_entry, src_manager.target_field_name) # If the object referenced by the M2M is publishable we only - # clone the relationship if it is a draft copy, not if it is - # a published copy. If it is not a publishable object at all, - # then we always clone the relationship (True by default). + # clone the relationship if it is to a draft copy, not if it is + # to a published copy. If it is not a publishable object at + # all then we always clone the relationship (True by default). if getattr(rel_obj, 'publishing_is_draft', True): - # Handle standard M2M relationship (without through table) - if dst.through._meta.auto_created: - dst.add(rel_obj) - # Handle M2M *through* relationship - else: - clone_through_model_relationship( - dst.through, src_obj, self, rel_obj) - # If the related object also has a published copy, we - # need to make sure the published copy also knows about - # this newly-published draft. We defer this until below - # when we are no longer iterating over the queryset - # we need to modify. + clone_through_model_relationship( + src_manager, through_entry, self, rel_obj) + # If the related draft object also has a published copy, + # we need to make sure the published copy also knows about + # this newly-published draft. try: - if rel_obj.publishing_linked: - published_rel_obj_copies_to_add.append( - rel_obj.publishing_linked) + # Get published copy for related object, if any + rel_obj_published = rel_obj.publishing_linked except AttributeError: - pass # No `publishing_linked` attr to handle + pass # Related item has no published copy + else: + clone_through_model_relationship( + src_manager, through_entry, src_obj, + rel_obj_published) + # Track IDs of related draft copies, so we can tell later + # whether relationshps with published copies are obsolete + current_draft_rel_pks.add(rel_obj.pk) else: # Track related published copies, in case they have # become obsolete published_rel_objs_maybe_obsolete.append(rel_obj) - # Make published copies of related objects aware of our - # newly-published draft, in case they weren't already. - if published_rel_obj_copies_to_add: - # Handle standard M2M relationship (without through table) - if src.through._meta.auto_created: - src.add(*published_rel_obj_copies_to_add) - # Handle M2M *through* relationship - else: - for rel_obj in published_rel_obj_copies_to_add: - clone_through_model_relationship( - src.through, src_obj, src_obj, rel_obj) # If related published copies have no corresponding related # draft after all the previous processing, the relationship is # obsolete and must be removed. - current_draft_rel_pks = set([ - i.pk for i in src.all() if getattr(i, 'is_draft', False) - ]) for published_rel_obj in published_rel_objs_maybe_obsolete: draft = published_rel_obj.get_draft() if not draft or draft.pk not in current_draft_rel_pks: - # Handle standard M2M relationship (without through table) - if src.through._meta.auto_created: - src.remove(published_rel_obj) - # Handle M2M *through* relationship - else: - remove_through_model_relationship( - dst.through, src_obj, rel_obj) + delete_through_model_relationship( + src_manager, published_rel_obj) # Track the relationship through-tables we have processed to avoid # processing the same relationships in both forward and reverse @@ -502,14 +470,12 @@ def clone(src, dst): # Forward. for field in src_obj._meta.many_to_many: - src = getattr(src_obj, field.name) - dst = getattr(self, field.name) - clone(src, dst) + src_manager = getattr(src_obj, field.name) + clone(src_manager) seen_rel_through_tables.add(field.rel.through) # Reverse. - for field in \ - src_obj._meta.get_all_related_many_to_many_objects(): + for field in src_obj._meta.get_all_related_many_to_many_objects(): # Skip reverse relationship we have already seen if field.field.rel.through in seen_rel_through_tables: continue @@ -517,9 +483,8 @@ def clone(src, dst): # M2M relationships with `self` don't have accessor names if not field_accessor_name: continue - src = getattr(src_obj, field_accessor_name) - dst = getattr(self, field_accessor_name) - clone(src, dst) + src_manager = getattr(src_obj, field_accessor_name) + clone(src_manager) def has_placeholder_relationships(self): return hasattr(self, 'placeholder_set') \ From 84d5d0576cd256171408d17e0e224d711ae18815 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 1 Nov 2016 09:54:08 +1100 Subject: [PATCH 007/184] Guard against non-existent published copy on M2M publish, re #42 Recent changes mistakenly dropped the existence check for the published copy of related objects when handling M2M relationships. This fix checks for both non-existing published copies of the `rel_obj` being processed, as well as invalid use of the `publishing_linked` attribute. --- icekit/publishing/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index 04ff9fd7..f50d0621 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -442,9 +442,10 @@ def clone(src_manager): except AttributeError: pass # Related item has no published copy else: - clone_through_model_relationship( - src_manager, through_entry, src_obj, - rel_obj_published) + if rel_obj_published: + clone_through_model_relationship( + src_manager, through_entry, src_obj, + rel_obj_published) # Track IDs of related draft copies, so we can tell later # whether relationshps with published copies are obsolete current_draft_rel_pks.add(rel_obj.pk) From 043cf40e189e204d071b75ebc9b2c03872732ec9 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 1 Nov 2016 12:10:28 +1100 Subject: [PATCH 008/184] Remove unsafe select-related performance improvement, re #42 Do not assume that all M2M through tables with a publishable item at one end or the other have a publishable target item. This unfortunately reduces performance, but avoids crashing errors. --- icekit/publishing/models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index f50d0621..de28caff 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -418,9 +418,7 @@ def delete_through_model_relationship(src_manager, rel_obj): def clone(src_manager): through_qs = src_manager.through.objects \ - .filter(**{src_manager.source_field_name: src_obj}) \ - .select_related('%s__publishing_linked' - % src_manager.target_field_name) + .filter(**{src_manager.source_field_name: src_obj}) published_rel_objs_maybe_obsolete = [] current_draft_rel_pks = set() for through_entry in through_qs: From 933bd59dac303967d24ce163a5b8ad2f08a00f42 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 1 Nov 2016 22:25:43 +1100 Subject: [PATCH 009/184] Documenting some useful docker-cloud commands --- docs/intro/docker.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/intro/docker.md b/docs/intro/docker.md index 19acd12e..ea5e5b62 100644 --- a/docs/intro/docker.md +++ b/docs/intro/docker.md @@ -68,3 +68,25 @@ Here are some of the most commonly used Docker commands when getting started: docker rm -f $(docker ps -a -q) docker rmi $(docker images -q) docker volume rm $(docker volume ls -q) + +# Docker-cloud commands + +The following commands can be run on a terminal on the docker-cloud container. +First run `entrypoint.sh bash` to set up the environment for the following +commands. + + # Run Django's debug server on a cloud container - ge + $ supervisorctl.sh stop all + $ runserver.sh + # then when you've finished and Ctrl-C exited runserver + $ supervisorctl.sh start all + + # Dump a database, encrypt it, and upload to the transfer.sh service. + $ pg_dump -O -x -f ~/dump.sql + $ cat ~/dump.sql|gpg -ac -o-|curl -X PUT --upload-file "-" https://transfer.sh/dump.x + $ rm ~/dump.sql # don't leave database dumps lying around + + # then on the destination machine, to download and decrypt: + $ curl [transfer.sh url] | gpg -o- > dump.sql + $ psql < dump.sql + $ rm dump.sql \ No newline at end of file From d5a1a70217a6754724f8bda0efd12cb0b0868b86 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 13:19:04 +1100 Subject: [PATCH 010/184] Add Haystack search index mixin for fluent content page modes, re #44 Add base mixin class implementing fields for generic publishable fluent content pages used within ICEKit. --- icekit/utils/search.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 icekit/utils/search.py diff --git a/icekit/utils/search.py b/icekit/utils/search.py new file mode 100644 index 00000000..b718cd23 --- /dev/null +++ b/icekit/utils/search.py @@ -0,0 +1,49 @@ +from haystack import indexes + +from icekit.publishing.models import PublishableFluentContentsPage + + +# Doesn't extend `indexes.Indexable` to avoid auto-detection for 'Search In' +class FluentContentsPageIndexMixin(indexes.SearchIndex): + """ + Base search index class for a publishable fluent contents page. + + Derived classes must override the `get_model()` method to return the + specific class (not an instance) that the search index will use. + """ + text = indexes.CharField(document=True, use_template=True) + title = indexes.CharField(model_attr='title', boost=2.0) + slug = indexes.CharField(model_attr='slug') + url = indexes.CharField(model_attr='get_absolute_url') + author = indexes.CharField() + modification_date = indexes.DateTimeField(model_attr='modification_date') + + language_code = indexes.CharField(model_attr='language_code') + + boosted_search_terms = indexes.CharField(boost=2.0, null=True) + + # SEO Translations + meta_keywords = indexes.CharField(model_attr='meta_keywords') + meta_description = indexes.CharField(model_attr='meta_description') + meta_title = indexes.CharField(model_attr='meta_title') + + def get_model(self): + """ + Get the model for the search index. + """ + return PublishableFluentContentsPage + + def index_queryset(self, using=None): + """ + Index current language translation of published pages. + + TODO: Find a way to index all translations of the given model, not just + the current site language's translation. + """ + return self.get_model().objects.published().language() + + def prepare_author(self, obj): + return obj.author.get_full_name() + + def prepare_boosted_search_terms(self, obj): + return getattr(obj, 'boosted_search_terms', '') From f28201e8dedc8df862a377274f2b5e7cc3b6e2b4 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 13:28:37 +1100 Subject: [PATCH 011/184] Add search indexing for LayoutPage models, re #44 --- icekit/page_types/layout_page/search_indexes.py | 11 +++++++++++ .../search/indexes/layout_page/layoutpage_text.txt | 6 ++++++ 2 files changed, 17 insertions(+) create mode 100644 icekit/page_types/layout_page/search_indexes.py create mode 100644 icekit/templates/search/indexes/layout_page/layoutpage_text.txt diff --git a/icekit/page_types/layout_page/search_indexes.py b/icekit/page_types/layout_page/search_indexes.py new file mode 100644 index 00000000..51337922 --- /dev/null +++ b/icekit/page_types/layout_page/search_indexes.py @@ -0,0 +1,11 @@ +from haystack import indexes + +from icekit.utils.search import FluentContentsPageIndexMixin + +from . import models + + +class LayoutPageIndex(indexes.Indexable, FluentContentsPageIndexMixin): + + def get_model(self): + return models.LayoutPage diff --git a/icekit/templates/search/indexes/layout_page/layoutpage_text.txt b/icekit/templates/search/indexes/layout_page/layoutpage_text.txt new file mode 100644 index 00000000..cf7a27cc --- /dev/null +++ b/icekit/templates/search/indexes/layout_page/layoutpage_text.txt @@ -0,0 +1,6 @@ +{% load fluent_contents_tags search_tags %} +{{ object.title }} +{{ object.author.get_full_name }} +{# As fluent requires a request object to process the page place holders we need to fake a request object here #} +{% fake_request %} +{% page_placeholder object "main" role="m" %} From 8967ee42a4c91c8c967ff73ec800625faf312916 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 13:30:34 +1100 Subject: [PATCH 012/184] Remove unnecessary search indexes for base Fluent and Flat pages, re #44 Since ICEKit does not by default install or expose Fluent's base `FluentPage` and `FlatPage` page types, it isn't very useful to define search indexes for these by default. --- icekit/search_indexes.py | 43 ------------------- .../search/indexes/flatpage/flatpage_text.txt | 1 - .../indexes/fluentpage/fluentpage_text.txt | 6 --- 3 files changed, 50 deletions(-) delete mode 100644 icekit/search_indexes.py delete mode 100644 icekit/templates/search/indexes/flatpage/flatpage_text.txt delete mode 100644 icekit/templates/search/indexes/fluentpage/fluentpage_text.txt diff --git a/icekit/search_indexes.py b/icekit/search_indexes.py deleted file mode 100644 index 0c25867a..00000000 --- a/icekit/search_indexes.py +++ /dev/null @@ -1,43 +0,0 @@ -from fluent_pages.pagetypes.flatpage.models import FlatPage -from fluent_pages.pagetypes.fluentpage.models import FluentPage -from haystack import indexes -from django.conf import settings - - -# Optional search indexes which can be used with the default FluentPage and FlatPage models. -if getattr(settings, 'ICEKIT_USE_SEARCH_INDEXES', True): - class FluentPageIndex(indexes.SearchIndex, indexes.Indexable): - """ - Search index for a fluent page. - """ - text = indexes.CharField(document=True, use_template=True) - author = indexes.CharField(model_attr='author') - publication_date = indexes.DateTimeField(model_attr='publication_date', null=True) - - @staticmethod - def get_model(): - """ - Get the model for the search index. - """ - return FluentPage - - def index_queryset(self, using=None): - """ - Queryset appropriate for this object to allow search for. - """ - return self.get_model().objects.published() - - - class FlatPageIndex(FluentPageIndex): - """ - Search index for a flat page. - - As everything except the model is the same as for a FluentPageIndex - we shall subclass it and overwrite the one part we need. - """ - @staticmethod - def get_model(): - """ - Get the model for the search index. - """ - return FlatPage diff --git a/icekit/templates/search/indexes/flatpage/flatpage_text.txt b/icekit/templates/search/indexes/flatpage/flatpage_text.txt deleted file mode 100644 index 4d872088..00000000 --- a/icekit/templates/search/indexes/flatpage/flatpage_text.txt +++ /dev/null @@ -1 +0,0 @@ -{% include 'search/indexes/fluentpage/fluentpage_text.txt' %} diff --git a/icekit/templates/search/indexes/fluentpage/fluentpage_text.txt b/icekit/templates/search/indexes/fluentpage/fluentpage_text.txt deleted file mode 100644 index 919bb99d..00000000 --- a/icekit/templates/search/indexes/fluentpage/fluentpage_text.txt +++ /dev/null @@ -1,6 +0,0 @@ -{% load fluent_contents_tags search_tags %} -{{ object.title }} -{{ object.user.get_full_name }} -{# As fluent requires a request object to process the page place holders we need to fake a request object here #} -{% fake_request %} -{% page_placeholder object "main" role="m" %} From 3b0284772d3a147d0c7e16066c951303f0f7bf34 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 14:29:41 +1100 Subject: [PATCH 013/184] Improve SearchPage searches to query all indexed fields, re #44 - add a custom model search form `FluentContentsPageModelSearchForm` that queries against (almost) all of the indexed fluent contents page fields, not only the fields included in the text document. This is necessary in particular to query the `boosted_search_terms` field - make the custom `FluentSearchView` used by `SearchPage` use our custom form. --- .../search_page/page_type_plugins.py | 14 ++++- icekit/utils/search.py | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/icekit/page_types/search_page/page_type_plugins.py b/icekit/page_types/search_page/page_type_plugins.py index be79e69d..569dbf7e 100644 --- a/icekit/page_types/search_page/page_type_plugins.py +++ b/icekit/page_types/search_page/page_type_plugins.py @@ -3,11 +3,23 @@ from fluent_pages.models import UrlNode from haystack.views import SearchView -from . import admin, models from icekit.plugins import ICEkitFluentContentsPagePlugin +from icekit.utils.search import FluentContentsPageModelSearchForm + +from . import admin, models class FluentSearchView(SearchView): + + def __init__(self, *args, **kwargs): + """ + This is the ludicrous way it seems you need to override the form class + used by Haystack, to use our custom version with added field querying + features. + """ + super(FluentSearchView, self).__init__(*args, **kwargs) + self.form_class = FluentContentsPageModelSearchForm + def extra_context(self): return { 'instance': UrlNode.objects.get_for_path(self.request.path) diff --git a/icekit/utils/search.py b/icekit/utils/search.py index b718cd23..e14ffd1f 100644 --- a/icekit/utils/search.py +++ b/icekit/utils/search.py @@ -1,4 +1,7 @@ from haystack import indexes +from haystack.backends import SQ +from haystack.inputs import AutoQuery +from haystack.forms import ModelSearchForm from icekit.publishing.models import PublishableFluentContentsPage @@ -47,3 +50,54 @@ def prepare_author(self, obj): def prepare_boosted_search_terms(self, obj): return getattr(obj, 'boosted_search_terms', '') + + +class FluentContentsPageModelSearchForm(ModelSearchForm): + """ Custom search form to use the indexed fields defined above """ + + def get_searchqueryset(self, query): + """ + Add non-document fields to search query set so a) they are searched + when querying, and b) any customisations like `boost` are applied. + """ + return self.searchqueryset.filter( + SQ(content=AutoQuery(query)) | # Search `text` document + SQ(title=AutoQuery(query)) | + SQ(slug=AutoQuery(query)) | + SQ(author=AutoQuery(query)) | + + SQ(boosted_search_terms=AutoQuery(query)) | + + SQ(meta_keywords=AutoQuery(query)) | + SQ(meta_description=AutoQuery(query)) | + SQ(meta_title=AutoQuery(query)) + ) + + # TODO This is mostly a copy/paste of `haystack.forms:SearchForm.search` + # and `haystack.forms.ModelSearchForm.search` except for customisation of + # the `SearchQuerySet` to include our fields. There should be a better way + # of doing this, though this is what the docs recommend: + # http://django-haystack.readthedocs.io/en/v2.5.1/boost.html + def search(self): + if not self.is_valid(): + return self.no_query_found() + + if not self.cleaned_data.get('q'): + return self.no_query_found() + + q = self.cleaned_data['q'] + + ####################################################################### + # Customised + sqs = self.get_searchqueryset(q) + ####################################################################### + + if self.load_all: + sqs = sqs.load_all() + + ####################################################################### + # Customised - per `ModelSearchForm.search()` + sqs = sqs.models(*self.get_models()) + ####################################################################### + + return sqs From 451bcd32e97831899d15baa7374eac5331dc4d3d Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 15:27:01 +1100 Subject: [PATCH 014/184] Fix search indexing of Author models, re #44 - only index published authors - relocated text template to expected location - hack to activate site's default translation language if necessary, so following the `parent` link to a fluent page doesn't crash indexing. --- icekit/page_types/author/search_indexes.py | 17 +++++++++++++++-- .../{author => icekit_authors}/author_text.txt | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) rename icekit/page_types/author/templates/search/indexes/{author => icekit_authors}/author_text.txt (80%) diff --git a/icekit/page_types/author/search_indexes.py b/icekit/page_types/author/search_indexes.py index 3af7da25..c99732b0 100644 --- a/icekit/page_types/author/search_indexes.py +++ b/icekit/page_types/author/search_indexes.py @@ -1,4 +1,8 @@ from haystack import indexes + +from django.conf import settings +from django.utils import translation + from . import models @@ -7,11 +11,20 @@ class AuthorIndex(indexes.SearchIndex, indexes.Indexable): Search index for `Author`. """ text = indexes.CharField(document=True, use_template=True) - name = indexes.CharField(model_attr='get_full_name', boost=2.0) + name = indexes.CharField(model_attr='title', boost=2.0) url = indexes.CharField(model_attr='get_absolute_url') has_url = indexes.BooleanField(model_attr='get_absolute_url') # We add this for autocomplete. - content_auto = indexes.EdgeNgramField(model_attr='get_full_name') + content_auto = indexes.EdgeNgramField(model_attr='title') + + def index_queryset(self, using=None): + """ Index only published authors """ + # TODO Hack to activate the site language if none is yet active, to + # avoid complaints about null language_code when traversing the + # `parent` relationship -- should probably do this elsewhere? + if not translation.get_language(): + translation.activate(settings.LANGUAGE_CODE) + return self.get_model().objects.published() def get_model(self): """ diff --git a/icekit/page_types/author/templates/search/indexes/author/author_text.txt b/icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt similarity index 80% rename from icekit/page_types/author/templates/search/indexes/author/author_text.txt rename to icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt index cad18445..0f0c6235 100644 --- a/icekit/page_types/author/templates/search/indexes/author/author_text.txt +++ b/icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt @@ -1,10 +1,10 @@ -{% load fluent_contents_tags %}{% spaceless %} +{% load fluent_contents_tags search_tags %}{% spaceless %} {{ object.get_full_name|safe }} {{ object.introduction|safe }} {# As fluent requires a request object to process the page place holders we need to fake a request object here #} -{% fake_request_indicating_search %} +{% fake_request %} {% if object.content.contentitems.exists %} {% render_placeholder object.content as content %} From e90399cdb0b083ed65c67a08a1f87c6a47ab26e3 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 15:27:19 +1100 Subject: [PATCH 015/184] Relocated LayoutPage search index template into app, re #44 --- .../templates/search/indexes/layout_page/layoutpage_text.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename icekit/{ => page_types/layout_page}/templates/search/indexes/layout_page/layoutpage_text.txt (100%) diff --git a/icekit/templates/search/indexes/layout_page/layoutpage_text.txt b/icekit/page_types/layout_page/templates/search/indexes/layout_page/layoutpage_text.txt similarity index 100% rename from icekit/templates/search/indexes/layout_page/layoutpage_text.txt rename to icekit/page_types/layout_page/templates/search/indexes/layout_page/layoutpage_text.txt From 984e6cfd87d84cb896514ee95658cefb325ee25f Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 16:07:38 +1100 Subject: [PATCH 016/184] Fix publishing of Author.content placeholder field, re #44 The `Author.content` placeholder field was not being cloned to the published copy when an author was published. - don't assume all placeholder fields are named `placeholder_set` or `placeholders` because `Author.content` doesn't follow this pattern. Instead, interrogate the model fields to find placeholder relationship fields regardless of the name - make placeholder field lookups for `cms.models`-flavoured versus `fluent_contents.models`-flavoured placeholder fields explicit. --- icekit/publishing/models.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index de28caff..467f9e3c 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -78,13 +78,12 @@ def is_dirty(self): return True # Get all placeholders + their plugins to find their modified date - for placeholder_field in self.get_placeholder_fields(): + for placeholder_field in self.get_cms_placeholder_fields(): placeholder = getattr(self, placeholder_field) for plugin in placeholder.get_plugins_list(): if plugin.changed_date \ > self.publishing_linked.publishing_modified_at: return True - return False @property @@ -184,17 +183,18 @@ def get_field(self, field_name): except models.fields.FieldDoesNotExist: return None - def get_placeholder_fields(self, obj=None): - placeholder_fields = [] - + def get_cms_placeholder_fields(self, obj=None): try: from cms.models.placeholdermodel import Placeholder + return self.get_placeholder_fields(Placeholder, obj=obj) except ImportError: - return placeholder_fields + return [] + def get_placeholder_fields(self, placeholder_class, obj=None): if obj is None: obj = self + placeholder_fields = [] model_fields = obj.__class__._meta.get_all_field_names() for field in model_fields: if field in self.publishing_ignore_fields: @@ -206,7 +206,6 @@ def get_placeholder_fields(self, obj=None): placeholder_fields.append(field) except (ObjectDoesNotExist, AttributeError): continue - return placeholder_fields @assert_draft @@ -487,7 +486,8 @@ def clone(src_manager): def has_placeholder_relationships(self): return hasattr(self, 'placeholder_set') \ - or hasattr(self, 'placeholders') + or hasattr(self, 'placeholders') \ + or len(self.get_placeholder_fields(Placeholder)) > 0 @assert_draft def patch_placeholders(self): From ed2760b5c695590bd46c080cf8a11d037ff71182 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 16:32:56 +1100 Subject: [PATCH 017/184] Add TODO to improve search querying of indexed fields, re #44 --- icekit/utils/search.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/icekit/utils/search.py b/icekit/utils/search.py index e14ffd1f..de9abe56 100644 --- a/icekit/utils/search.py +++ b/icekit/utils/search.py @@ -60,6 +60,9 @@ def get_searchqueryset(self, query): Add non-document fields to search query set so a) they are searched when querying, and b) any customisations like `boost` are applied. """ + # TODO Find a way to detect all indexed fields across models and + # automatically add them to this filter, instead of requiring explicit + # naming of every indexed field. return self.searchqueryset.filter( SQ(content=AutoQuery(query)) | # Search `text` document SQ(title=AutoQuery(query)) | From 9ad1da86fbacbfb80961f0f15c808c045fec92ca Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 16:40:49 +1100 Subject: [PATCH 018/184] Remove default Haystack search URLs at /search/, re #44 ICEKit's `SearchPage`-based searching features are more sophisticated and customisable than the default Haystack implementations previously exposed at the /search/ URL path, so we will remove Haystack's defaults. --- icekit/project/urls.py | 1 - 1 file changed, 1 deletion(-) diff --git a/icekit/project/urls.py b/icekit/project/urls.py index 88fef689..e7a83f81 100644 --- a/icekit/project/urls.py +++ b/icekit/project/urls.py @@ -39,7 +39,6 @@ # Installed apps. url(r'^api/pages/', include('icekit.pages_api.urls')), url(r'^forms/', include('forms_builder.forms.urls')), - url(r'^search/', include('haystack.urls')), # Get admin URLs prefix from settings. # Handle admin and front-end authentication separately. From 22b61ddee606ee51d161b708ea7a7cfe38952c83 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 17:00:53 +1100 Subject: [PATCH 019/184] Add celerybeat scheduled task to update search indexes, re #44 Add celerybeat config settings and a `UpdateSearchIndexTask` task definition to periodically update search indexes. NOTE: This code is drawn almost directly from SFMOMA without changes or reconsideration, so it may need further thought for ICEKit. --- icekit/project/settings/_base.py | 20 +++++++++++ icekit/tasks.py | 59 +++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 98ae2c45..a1cf714f 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -15,6 +15,8 @@ import os import re +from celery.schedules import crontab + from django.core.urlresolvers import reverse_lazy from django.utils.text import slugify from kombu import Exchange, Queue @@ -343,6 +345,9 @@ BROKER_URL = CELERY_RESULT_BACKEND = 'redis://%s/0' % REDIS_ADDRESS CELERY_ACCEPT_CONTENT = ['json', 'msgpack', 'yaml'] # 'pickle' CELERY_DEFAULT_QUEUE = PROJECT_SLUG +CELERY_TASK_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERYD_MAX_TASKS_PER_CHILD = 20 CELERY_QUEUES = ( Queue( @@ -352,8 +357,23 @@ ), ) +# crontab(minute='*/15') = every 15 minutes +# crontab(minute=0, hour=0) = daily at midnight +CELERYBEAT_SCHEDULE = { + 'UpdateSearchIndexTask': { + 'task': 'icekit.tasks.UpdateSearchIndexTask', + 'schedule': crontab(minute='*/15'), # Every 15 minutes. + }, +} + CELERY_RESULT_BACKEND = 'djcelery.backends.database:DatabaseBackend' +# Log the celerybeat lock actions +LOGGING['loggers']['icekit.tasks'] = { + 'handlers': ['logfile'], + 'level': 'DEBUG', +} + INSTALLED_APPS += ( 'djcelery', 'kombu.transport.django', diff --git a/icekit/tasks.py b/icekit/tasks.py index da11fbb2..03fa0d05 100644 --- a/icekit/tasks.py +++ b/icekit/tasks.py @@ -1,4 +1,7 @@ -import json +import os +from celery.task import Task +import logging +import redis try: from celery import shared_task @@ -7,8 +10,56 @@ def shared_task(f): f.delay = f return f +from django.conf import settings +from django.core.management import call_command from django.db.models.loading import get_model + +logger = logging.getLogger(__name__) + +# Resources for `one_instance` task execution management decorator. +REDIS_CLIENT = redis.Redis() +DEFAULT_ONE_INSTANCE_TIMEOUT = getattr(settings, 'CELERY_TIMEOUT', 7200) * 1.1 + + +def one_instance(function=None, key='', timeout=DEFAULT_ONE_INSTANCE_TIMEOUT): + """ + Decorator to enforce only one Celery task execution at a time when multiple + workers are available. + + See http://loose-bits.com/2010/10/distributed-task-locking-in-celery.html + """ + def _dec(run_func): + def _caller(*args, **kwargs): + ret_value = None + have_lock = False + # Use Redis AOF for persistent lock. + if REDIS_CLIENT.config_get('appendonly').values()[0] == 'no': + REDIS_CLIENT.config_set('appendonly', 'yes') + lock = REDIS_CLIENT.lock(key, timeout=timeout) + try: + logger.debug( + '%s: trying to acquire lock (PID %d).' + % (key, os.getpid())) + have_lock = lock.acquire(blocking=False) + if have_lock: + logger.debug( + '%s: acquired lock (PID %d).' % (key, os.getpid())) + ret_value = run_func(*args, **kwargs) + else: + logger.error( + '%s: did not acquire lock (PID %d).' + % (key, os.getpid())) + finally: + if have_lock: + lock.release() + logger.debug( + '%s: released lock (PID %d).' % (key, os.getpid())) + return ret_value + return _caller + return _dec(function) if function is not None else _dec + + @shared_task def store_readability_score(app_label, model_name, pk): # non-blockingly update the readability score for this work. Needs to happen after save, as all the m2m content @@ -18,3 +69,9 @@ def store_readability_score(app_label, model_name, pk): obj.readability_score = obj.calculate_readability_score() # avoid calling save() recursively type(obj).objects.filter(pk=obj.pk).update(readability_score=obj.readability_score) + + +class UpdateSearchIndexTask(Task): + @one_instance(key='UpdateSearchIndexTask') + def run(self, **kwargs): + call_command('update_index', remove=True) From 9da2bd7133d000ed9ce79b0cbf3ae0ea8248c9cc Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 2 Nov 2016 17:10:02 +1100 Subject: [PATCH 020/184] Fix invalid full-name lookup when indexing Author, re #44 --- .../templates/search/indexes/icekit_authors/author_text.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt b/icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt index 0f0c6235..2682b82a 100644 --- a/icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt +++ b/icekit/page_types/author/templates/search/indexes/icekit_authors/author_text.txt @@ -1,6 +1,6 @@ {% load fluent_contents_tags search_tags %}{% spaceless %} -{{ object.get_full_name|safe }} +{{ object.title|safe }} {{ object.introduction|safe }} {# As fluent requires a request object to process the page place holders we need to fake a request object here #} From d91fb6c27fa4f89f7ae993e6033e35c04a8fa0ca Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Thu, 3 Nov 2016 18:18:13 +1100 Subject: [PATCH 021/184] Work around sed working differently on Mac and Linux On Linux, sed requires either an "=" or no space after the "-i" argument, while Mac expects a space, but can handle there not being one, except if an empty extension is desired, which is what we want. Thus we have to resort to specifying an extension, then deleting the backup files. See discussion here: http://stackoverflow.com/questions/5694228/sed-in-place-flag-that-works-both-on-mac-bsd-and-linux --- icekit/bin/startproject.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/icekit/bin/startproject.sh b/icekit/bin/startproject.sh index 3d8e88b7..ce7fcbf6 100755 --- a/icekit/bin/startproject.sh +++ b/icekit/bin/startproject.sh @@ -55,7 +55,8 @@ chmod +x go.sh touch requirements.txt # Find and replace 'project_template' with destination directory basename. -find . -type f -exec sed -e "s/project_template/$DEST_DIR_BASENAME/g" -i '' {} \; +find . -type f -exec sed -e "s/project_template/$DEST_DIR_BASENAME/g" -i.deleteme {} \; +find . -type f -iname "*.deleteme" -delete # Replace editable with package requirement. sed -e "s/-e ../django-icekit/" -i '' requirements-icekit.txt From e5a0d92953b8019de7846a6aa535013f0709a4ec Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Thu, 3 Nov 2016 18:24:27 +1100 Subject: [PATCH 022/184] Fixed another sed Mac/Linux incompatibility See previous commit description for more details. --- icekit/bin/startproject.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icekit/bin/startproject.sh b/icekit/bin/startproject.sh index ce7fcbf6..62d4ee11 100755 --- a/icekit/bin/startproject.sh +++ b/icekit/bin/startproject.sh @@ -59,7 +59,7 @@ find . -type f -exec sed -e "s/project_template/$DEST_DIR_BASENAME/g" -i.deletem find . -type f -iname "*.deleteme" -delete # Replace editable with package requirement. -sed -e "s/-e ../django-icekit/" -i '' requirements-icekit.txt +sed -e "s/-e ../django-icekit/" requirements-icekit.txt > requirements-icekit.txt if [[ -n $(which git) ]]; then echo From 8218a53d7b8cd693f2d96d0972e45d4c4fd69ee2 Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Thu, 3 Nov 2016 20:04:09 +1100 Subject: [PATCH 023/184] Fix the in-place sed replacing in the previous commit. Can't just pipe the output to the file being read from, apparently; results in blank files. --- icekit/bin/startproject.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/icekit/bin/startproject.sh b/icekit/bin/startproject.sh index 62d4ee11..9e0625af 100755 --- a/icekit/bin/startproject.sh +++ b/icekit/bin/startproject.sh @@ -59,7 +59,8 @@ find . -type f -exec sed -e "s/project_template/$DEST_DIR_BASENAME/g" -i.deletem find . -type f -iname "*.deleteme" -delete # Replace editable with package requirement. -sed -e "s/-e ../django-icekit/" requirements-icekit.txt > requirements-icekit.txt +sed -e "s/-e ../django-icekit/" requirements-icekit.txt > requirements-icekit.txt.replaced +mv requirements-icekit.txt.replaced requirements-icekit.txt if [[ -n $(which git) ]]; then echo From bb27b82a58c8bafa50e5e0ccf63d4c66f4218dfe Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Thu, 3 Nov 2016 20:12:02 +1100 Subject: [PATCH 024/184] Fix go.sh when the project path has spaces in it. Pip cannot run from a virtualenv path with spaces in it using the pip command because the shebang header doesn't have a workable way of dealing with such paths. Using `python -m pip` instead of `pip` works around this. --- project_template/go.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project_template/go.sh b/project_template/go.sh index 3352ba72..482661d5 100755 --- a/project_template/go.sh +++ b/project_template/go.sh @@ -21,8 +21,8 @@ if [[ ! -d "$ICEKIT_VENV" ]]; then fi # Install ICEKit project. -if [[ -z $("$ICEKIT_VENV/bin/pip" freeze | grep django-icekit) ]]; then - "$ICEKIT_VENV/bin/pip" install -r requirements-icekit.txt +if [[ -z $("$ICEKIT_VENV/bin/python" -m pip freeze | grep django-icekit) ]]; then + "$ICEKIT_VENV/bin/python" -m pip install -r requirements-icekit.txt fi # Get absolute directory for the `icekit` package. From 8cee487eeaca982971b144bf73bfe4e35ebffe7f Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Thu, 3 Nov 2016 20:13:35 +1100 Subject: [PATCH 025/184] Fixed running go.sh under Python 3. It will still fail eventually when icekit tries to install supervisor, though (until we remove that requirement). --- project_template/go.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_template/go.sh b/project_template/go.sh index 482661d5..3de88771 100755 --- a/project_template/go.sh +++ b/project_template/go.sh @@ -26,7 +26,7 @@ if [[ -z $("$ICEKIT_VENV/bin/python" -m pip freeze | grep django-icekit) ]]; the fi # Get absolute directory for the `icekit` package. -export ICEKIT_DIR=$("$ICEKIT_VENV/bin/python" -c 'import icekit, os; print os.path.dirname(icekit.__file__);') +export ICEKIT_DIR=$("$ICEKIT_VENV/bin/python" -c 'from __future__ import print_function; import icekit, os; print(os.path.dirname(icekit.__file__));') # Execute entrypoint and command. exec "$ICEKIT_DIR/bin/entrypoint.sh" ${@:-setup-django.sh bash.sh} From 9e7dd1707d95791b871a0e1c44f7331e52eb2026 Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Fri, 4 Nov 2016 13:03:41 +1100 Subject: [PATCH 026/184] Fixed Python 3 compatibility in the parts of Icekit encountered when setting up a new project. --- icekit/content_collections/abstract_models.py | 5 ++++- icekit/page_types/author/models.py | 5 ++++- .../0001_squashed_0003_mapwithtextitem.py | 2 +- icekit/project/settings/_base.py | 5 +++-- icekit/project/settings/calculated.py | 6 +++--- icekit/publishing/admin.py | 3 ++- icekit/publishing/utils.py | 5 ++++- icekit/validators.py | 15 ++++++++++----- 8 files changed, 31 insertions(+), 15 deletions(-) diff --git a/icekit/content_collections/abstract_models.py b/icekit/content_collections/abstract_models.py index 28980fb5..58ff69b7 100644 --- a/icekit/content_collections/abstract_models.py +++ b/icekit/content_collections/abstract_models.py @@ -1,4 +1,7 @@ -from urlparse import urljoin +try: + from urlparse import urljoin +except ImportError: + from urllib.parse import urljoin from django.core.exceptions import ValidationError from django.template.response import TemplateResponse diff --git a/icekit/page_types/author/models.py b/icekit/page_types/author/models.py index e4c2ddd1..36994379 100644 --- a/icekit/page_types/author/models.py +++ b/icekit/page_types/author/models.py @@ -2,7 +2,10 @@ Model declaration for the `author` app. """ import re -from urlparse import urljoin +try: + from urlparse import urljoin +except ImportError: + from urllib.parse import urljoin from django.db import models from django.utils.encoding import python_2_unicode_compatible diff --git a/icekit/plugins/map_with_text/migrations/0001_squashed_0003_mapwithtextitem.py b/icekit/plugins/map_with_text/migrations/0001_squashed_0003_mapwithtextitem.py index 42106fe8..d7669348 100644 --- a/icekit/plugins/map_with_text/migrations/0001_squashed_0003_mapwithtextitem.py +++ b/icekit/plugins/map_with_text/migrations/0001_squashed_0003_mapwithtextitem.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): - replaces = [(b'icekit_plugins_map_with_text', '0001_initial'), (b'icekit_plugins_map_with_text', '0002_auto_20150906_2301'), (b'icekit_plugins_map_with_text', '0003_mapwithtextitem')] + replaces = [('icekit_plugins_map_with_text', '0001_initial'), ('icekit_plugins_map_with_text', '0002_auto_20150906_2301'), ('icekit_plugins_map_with_text', '0003_mapwithtextitem')] dependencies = [ ('icekit_plugins_map', '0001_initial'), diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 98ae2c45..de118b6e 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -28,10 +28,11 @@ # Uniquely identify the base settings module, so we can avoid conflicts with # other projects running on the same system. -SETTINGS_MODULE_HASH = hashlib.md5(__file__ + BASE_SETTINGS_MODULE).hexdigest() +SETTINGS_MODULE_HASH = hashlib.md5( + u''.join((__file__, BASE_SETTINGS_MODULE)).encode('utf-8')).hexdigest() PROJECT_NAME = os.environ.get('ICEKIT_PROJECT_NAME', 'ICEkit') -PROJECT_SLUG = re.sub(r'[^0-9A-Za-z]+', '-', slugify(unicode(PROJECT_NAME))) +PROJECT_SLUG = re.sub(r'[^0-9A-Za-z]+', '-', slugify(PROJECT_NAME)) SITE_DOMAIN = os.environ.get('SITE_DOMAIN', '%s.lvh.me' % PROJECT_SLUG) SITE_NAME = os.environ.get('SITE_NAME', PROJECT_NAME) diff --git a/icekit/project/settings/calculated.py b/icekit/project/settings/calculated.py index c5b063d0..e0d24a21 100644 --- a/icekit/project/settings/calculated.py +++ b/icekit/project/settings/calculated.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import +from __future__ import absolute_import, print_function import importlib import os @@ -8,7 +8,7 @@ # Emulate `from ... import *` with base settings module from environment. BASE_SETTINGS_MODULE = os.environ.setdefault('BASE_SETTINGS_MODULE', 'base') -print '# BASE_SETTINGS_MODULE: %s' % BASE_SETTINGS_MODULE +print('# BASE_SETTINGS_MODULE: %s' % BASE_SETTINGS_MODULE) try: locals().update(importlib.import_module( 'icekit.project.settings._%s' % BASE_SETTINGS_MODULE).__dict__) @@ -95,7 +95,7 @@ secret = open(SECRET_FILE, 'w') secret.write(SECRET_KEY) secret.close() - os.chmod(SECRET_FILE, 0400) + os.chmod(SECRET_FILE, 0o400) except IOError: raise Exception( 'Please create a %s file with 50 random characters to set your ' diff --git a/icekit/publishing/admin.py b/icekit/publishing/admin.py index cdd3e8bd..10194da4 100644 --- a/icekit/publishing/admin.py +++ b/icekit/publishing/admin.py @@ -1,4 +1,5 @@ import json +import six import django from django import forms @@ -317,7 +318,7 @@ def find_first_available_template(self, template_name_list): Given a list of template names, find the first one that actually exists and is available. """ - if isinstance(template_name_list, basestring): + if isinstance(template_name_list, six.string_types): return template_name_list else: # Take advantage of fluent_pages' internal implementation diff --git a/icekit/publishing/utils.py b/icekit/publishing/utils.py index a9fe1530..c160c49a 100644 --- a/icekit/publishing/utils.py +++ b/icekit/publishing/utils.py @@ -1,4 +1,7 @@ -import urlparse +try: + import urlparse +except ImportError: + from urllib import parse as urlparse from django.apps import apps from django.http import QueryDict diff --git a/icekit/validators.py b/icekit/validators.py index bb56438a..df31009c 100644 --- a/icekit/validators.py +++ b/icekit/validators.py @@ -8,15 +8,20 @@ import platform import re -import urllib2 -import urlparse + +try: + import urllib2 + import urlparse +except ImportError: + from urllib import request as urllib2 + from urllib import parse as urlparse from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.core.urlresolvers import resolve from django.http import Http404 from django.utils.translation import ugettext_lazy as _ -from django.utils.encoding import smart_unicode +from django.utils.encoding import smart_text def template_name(value): """ @@ -57,10 +62,10 @@ def __init__(self, verify_exists=False, validator_user_agent=URL_VALIDATOR_USER_ def __call__(self, value): try: super(RelativeURLValidator, self).__call__(value) - except ValidationError, e: + except ValidationError as e: # Trivial case failed. Try for possible IDN domain if value: - value = smart_unicode(value) + value = smart_text(value) scheme, netloc, path, query, fragment = urlparse.urlsplit(value) try: netloc = netloc.encode('idna') # IDN -> ACE From 44b137c1ead42cb8c3f9fb2b26334165884c4a9a Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Fri, 4 Nov 2016 15:52:48 +1100 Subject: [PATCH 027/184] Support CSS compilation when theproject path has a space in it. --- icekit/project/settings/_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index de118b6e..da020188 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -378,13 +378,13 @@ COMPRESS_PRECOMPILERS = ( ( 'text/less', - '%s {infile} {outfile} --autoprefix' % ( + '"%s" {infile} {outfile} --autoprefix' % ( os.path.join(PROJECT_DIR, 'node_modules', '.bin', 'lessc'), ), ), ( 'text/x-scss', - '%s {infile} {outfile} --autoprefix --include-path %s' % ( + '"%s" {infile} {outfile} --autoprefix --include-path %s' % ( os.path.join(PROJECT_DIR, 'node_modules', '.bin', 'node-sass'), STATIC_ROOT, ), From 336933cb27ee82ad20f6b3ea981048a953a9ef97 Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Mon, 7 Nov 2016 16:14:15 +1100 Subject: [PATCH 028/184] Further fixes for Python 3 and spaces-in-path compatibility, plus nicer syntax for the former. --- icekit/bin/entrypoint.sh | 2 +- icekit/bin/pip-install.sh | 4 ++-- project_template/go.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/icekit/bin/entrypoint.sh b/icekit/bin/entrypoint.sh index 7f8cf59c..10bff488 100755 --- a/icekit/bin/entrypoint.sh +++ b/icekit/bin/entrypoint.sh @@ -73,7 +73,7 @@ fi export BASE_SETTINGS_MODULE="${BASE_SETTINGS_MODULE:-develop}" # Get number of CPU cores, so we know how many processes to run. -export CPU_CORES=$(python -c 'import multiprocessing; print multiprocessing.cpu_count();') +export CPU_CORES=$(python -c "import multiprocessing, sys; sys.stdout.write('%s\n' % multiprocessing.cpu_count());") # Get project name from the project directory. export ICEKIT_PROJECT_NAME=$(basename "$ICEKIT_PROJECT_DIR") diff --git a/icekit/bin/pip-install.sh b/icekit/bin/pip-install.sh index 4e93a94b..362cca69 100755 --- a/icekit/bin/pip-install.sh +++ b/icekit/bin/pip-install.sh @@ -16,7 +16,7 @@ cd "$DIR" if [[ -f requirements.txt ]]; then if [[ ! -s requirements.txt.md5 ]] || ! md5sum --status -c requirements.txt.md5 > /dev/null 2>&1; then echo "Python requirements in '$DIR' directory are out of date, 'requirements.txt' has been updated." - pip install -r requirements.txt + python -m pip install -r requirements.txt md5sum requirements.txt > requirements.txt.md5 fi fi @@ -24,7 +24,7 @@ fi if [[ -f requirements-local.txt ]]; then if [[ ! -s requirements-local.txt.md5 ]] || ! md5sum --status -c requirements-local.txt.md5 > /dev/null 2>&1; then echo "Python requirements in '$DIR' directory are out of date, 'requirements-local.txt' has been updated." - pip install -r requirements-local.txt + python -m pip install -r requirements-local.txt md5sum requirements-local.txt > requirements-local.txt.md5 fi fi diff --git a/project_template/go.sh b/project_template/go.sh index 3de88771..9ca120c3 100755 --- a/project_template/go.sh +++ b/project_template/go.sh @@ -26,7 +26,7 @@ if [[ -z $("$ICEKIT_VENV/bin/python" -m pip freeze | grep django-icekit) ]]; the fi # Get absolute directory for the `icekit` package. -export ICEKIT_DIR=$("$ICEKIT_VENV/bin/python" -c 'from __future__ import print_function; import icekit, os; print(os.path.dirname(icekit.__file__));') +export ICEKIT_DIR=$("$ICEKIT_VENV/bin/python" -c "import icekit, os, sys; sys.stdout.write('%s\n' % os.path.dirname(icekit.__file__));") # Execute entrypoint and command. exec "$ICEKIT_DIR/bin/entrypoint.sh" ${@:-setup-django.sh bash.sh} From 430ae423f4ac6074f8fd00e70f7a2d5cb431adf4 Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Mon, 7 Nov 2016 16:21:16 +1100 Subject: [PATCH 029/184] Avoid mixing tabs and spaces, even in .sh files --- icekit/bin/pip-install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/icekit/bin/pip-install.sh b/icekit/bin/pip-install.sh index b42000bb..efdbd4e4 100755 --- a/icekit/bin/pip-install.sh +++ b/icekit/bin/pip-install.sh @@ -16,7 +16,7 @@ cd "$DIR" if [[ -f requirements.txt ]]; then if [[ ! -s requirements.txt.md5 ]] || ! md5sum --status -c requirements.txt.md5 > /dev/null 2>&1; then echo "Python requirements in '$DIR' directory are out of date, 'requirements.txt' has been updated." - python -m pip install -r requirements.txt + python -m pip install -r requirements.txt md5sum requirements.txt > requirements.txt.md5 fi fi @@ -24,7 +24,7 @@ fi if [[ -f requirements-local.txt ]]; then if [[ ! -s requirements-local.txt.md5 ]] || ! md5sum --status -c requirements-local.txt.md5 > /dev/null 2>&1; then echo "Python requirements in '$DIR' directory are out of date, 'requirements-local.txt' has been updated." - python -m pip install -r requirements-local.txt + python -m pip install -r requirements-local.txt md5sum requirements-local.txt > requirements-local.txt.md5 fi fi From 232bd6cd78f5355014f4ad13e2e5ab5860c2bf4f Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 7 Nov 2016 19:45:24 +1100 Subject: [PATCH 030/184] Make abstract and derive trivial concrete subclasses for Article and ArticleCategory. --- icekit/page_types/article/abstract_models.py | 34 ++++++++++++++++++++ icekit/page_types/article/models.py | 31 ++++-------------- 2 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 icekit/page_types/article/abstract_models.py diff --git a/icekit/page_types/article/abstract_models.py b/icekit/page_types/article/abstract_models.py new file mode 100644 index 00000000..a186945a --- /dev/null +++ b/icekit/page_types/article/abstract_models.py @@ -0,0 +1,34 @@ +from django.db import models +from icekit.content_collections.abstract_models import \ + AbstractCollectedContent, AbstractListingPage, TitleSlugMixin +from icekit.publishing.models import PublishableFluentContents + + +class AbstractArticleCategoryPage(AbstractListingPage): + def get_items_to_list(self, request): + unpublished_pk = self.get_draft().pk + return Article.objects.published().filter(parent_id=unpublished_pk) + + def get_items_to_mount(self, request): + unpublished_pk = self.get_draft().pk + return Article.objects.visible().filter(parent_id=unpublished_pk) + + class Meta: + verbose_name = "Article category page" + abstract = True + + +class AbstractArticle( + PublishableFluentContents, + AbstractCollectedContent, + TitleSlugMixin +): + parent = models.ForeignKey( + 'ArticleCategoryPage', + limit_choices_to={'publishing_is_draft': True}, + on_delete=models.PROTECT, + ) + + class Meta: + unique_together = (('slug', 'parent', 'publishing_linked'), ) + abstract = True diff --git a/icekit/page_types/article/models.py b/icekit/page_types/article/models.py index a66d8141..bfef8cad 100644 --- a/icekit/page_types/article/models.py +++ b/icekit/page_types/article/models.py @@ -1,29 +1,10 @@ -from django.db import models -from icekit.content_collections.abstract_models import \ - AbstractCollectedContent, AbstractListingPage, TitleSlugMixin -from icekit.publishing.models import PublishableFluentContents +from .abstract_models import \ + AbstractArticleCategoryPage, AbstractArticle -class ArticleCategoryPage(AbstractListingPage): - def get_items_to_list(self, request): - unpublished_pk = self.get_draft().pk - return Article.objects.published().filter(parent_id=unpublished_pk) +class ArticleCategoryPage(AbstractArticleCategoryPage): + pass - def get_items_to_mount(self, request): - unpublished_pk = self.get_draft().pk - return Article.objects.visible().filter(parent_id=unpublished_pk) - class Meta: - verbose_name = "Article category page" - - -class Article( - PublishableFluentContents, AbstractCollectedContent, TitleSlugMixin): - parent = models.ForeignKey( - 'ArticleCategoryPage', - limit_choices_to={'publishing_is_draft': True}, - on_delete=models.PROTECT, - ) - - class Meta: - unique_together = (('slug', 'parent', 'publishing_linked'), ) +class Article(AbstractArticle): + pass From 9c612a58f5abe7809ed2ad995c5c86faccf56919 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 8 Nov 2016 13:22:28 +1100 Subject: [PATCH 031/184] Making publishing compatible with object actions. --- icekit/project/settings/_base.py | 1 + icekit/publishing/admin.py | 3 --- .../admin/publishing/publishing_change_form.html | 14 +++++++++++++- setup.py | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index da020188..88581d7e 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -201,6 +201,7 @@ 'forms_builder.forms', # 'ixc_redactor', 'reversion', + 'django_object_actions', # Default. 'django.contrib.admin', diff --git a/icekit/publishing/admin.py b/icekit/publishing/admin.py index 10194da4..d26b1dfb 100644 --- a/icekit/publishing/admin.py +++ b/icekit/publishing/admin.py @@ -269,9 +269,6 @@ def publishing_column(self, obj): class PublishingAdmin(ModelAdmin, _PublishingHelpersMixin): form = PublishingAdminForm - # publish or unpublish actions sometime makes the plugins disappear from - # page so we disable it for now, until we can investigate it further. - # actions = (make_published, make_unpublished, ) list_display = ('publishing_object_title', 'publishing_column', 'publishing_modified_at') list_filter = (PublishingStatusFilter, PublishingPublishedFilter) diff --git a/icekit/publishing/templates/admin/publishing/publishing_change_form.html b/icekit/publishing/templates/admin/publishing/publishing_change_form.html index e20d4786..72f53227 100644 --- a/icekit/publishing/templates/admin/publishing/publishing_change_form.html +++ b/icekit/publishing/templates/admin/publishing/publishing_change_form.html @@ -63,7 +63,19 @@ {% block object-tools-items %} -{# Overriding view on site behaviour #} +{# Overriding view on site behaviour to remove 'view on site' for unpublished items #} + {# copied from django_object_actions/change_form.html #} + {% for tool in objectactions %} +
  • + + {{ tool.label|capfirst }} + +
  • + {% endfor %}
  • {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} diff --git a/setup.py b/setup.py index 3d8cfa82..999befcc 100644 --- a/setup.py +++ b/setup.py @@ -66,6 +66,7 @@ def find_packages(*paths): 'django-multiurl', # import pattern changed in 0.8; version 1 to 1.0.1 has bug with parent # admins: github.com/django-polymorphic/django-polymorphic/pull/246 + 'django-object-actions>=0.7', # See: https://github.com/crccheck/django-object-actions/issues/45 'django-polymorphic>=0.8,!=1,!=1.0.1', 'django-wysiwyg', 'django_extensions', From 82accd8efd2b129fec6c7830d7bbe42d4f791470 Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Tue, 8 Nov 2016 15:01:28 +1100 Subject: [PATCH 032/184] Prevent port number being added to domain of default Site object for production deployments. - Using a default value of None for SITE_PORT prevents having to worry about HTTP vs HTTPS matters - Removed unused reference to SITE_PUBLIC_PORT --- icekit/apps.py | 2 +- icekit/project/settings/_base.py | 2 +- icekit/project/settings/_develop.py | 2 ++ icekit/project/settings/_production.py | 2 -- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/icekit/apps.py b/icekit/apps.py index 8c875739..d66ab8f2 100644 --- a/icekit/apps.py +++ b/icekit/apps.py @@ -20,7 +20,7 @@ def update_site(sender, **kwargs): """ Site = apps.get_model('sites', 'Site') domain = settings.SITE_DOMAIN - if settings.SITE_PORT not in (80, 443): + if settings.SITE_PORT: domain += ':%s' % settings.SITE_PORT Site.objects.update_or_create( pk=settings.SITE_ID, diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 88581d7e..0676c5af 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -37,7 +37,7 @@ SITE_DOMAIN = os.environ.get('SITE_DOMAIN', '%s.lvh.me' % PROJECT_SLUG) SITE_NAME = os.environ.get('SITE_NAME', PROJECT_NAME) -SITE_PORT = 8000 +SITE_PORT = None # FILE SYSTEM PATHS ########################################################### diff --git a/icekit/project/settings/_develop.py b/icekit/project/settings/_develop.py index 3a877682..0c60b513 100644 --- a/icekit/project/settings/_develop.py +++ b/icekit/project/settings/_develop.py @@ -1,5 +1,7 @@ from ._base import * +SITE_PORT = 8000 + # DJANGO ###################################################################### ALLOWED_HOSTS = ('*', ) diff --git a/icekit/project/settings/_production.py b/icekit/project/settings/_production.py index 3feed22a..e5005d45 100644 --- a/icekit/project/settings/_production.py +++ b/icekit/project/settings/_production.py @@ -1,7 +1,5 @@ from ._base import * -SITE_PUBLIC_PORT = None # Default: SITE_PORT - # DJANGO ###################################################################### CACHES['default'].update({ From 1949758f7a83c4ceda36a20d5b3fdefaf40907db Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Tue, 8 Nov 2016 15:02:14 +1100 Subject: [PATCH 033/184] Ignore build directory in Git. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d19e69de..94d47eec 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ /.env.local /.idea/ /.tox/ +/build/ /dist/ /docker-compose.override.yml /project_template/bower_components/ From 66bc014ce69c1520262e61b19a013fb70a41bfae Mon Sep 17 00:00:00 2001 From: Aram Dulyan Date: Tue, 8 Nov 2016 15:44:00 +1100 Subject: [PATCH 034/184] Restrict Celery to a version prior to 4 Version 4 of celery brings with it kombu version 4, which removes the kombu.transport.django app, and we need to figure out how to adjust for that. Until we do, stick to a version 3 release. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 976f89de..a0eddcee 100644 --- a/setup.py +++ b/setup.py @@ -95,7 +95,7 @@ def find_packages(*paths): 'django-forms-builder', ], 'project': [ - 'celery[redis]', + 'celery[redis]<4', 'ConcurrentLogHandler', 'django-celery', 'django-celery-email', @@ -137,7 +137,7 @@ def find_packages(*paths): 'elasticstack', ], 'test': [ - 'celery[redis]', + 'celery[redis]<4', 'coverage', 'coveralls', 'django-dynamic-fixture', From b5b362ea4f6fd7ff96e50908545fc62056c96fb1 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 8 Nov 2016 16:27:08 +1100 Subject: [PATCH 035/184] Configure `en_US.UTF-8` locale. WhiteNoise will throw a 500 error on every request for a URL with a non-ascii character (even when percent encoded). --- Dockerfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Dockerfile b/Dockerfile index 9520b6d1..f1cbd478 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update \ gettext \ gnupg2 \ jq \ + locales \ nano \ nginx \ postgresql-client \ @@ -65,6 +66,12 @@ RUN cd /usr/local/bin \ # RUN echo "int chown() { return 0; }" > preload.c && gcc -shared -o /libpreload.so preload.c && rm preload.c # ENV LD_PRELOAD=/libpreload.so +RUN echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen +RUN locale-gen +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + ENV CRONLOCK_HOST=redis ENV DOCKER=1 ENV ICEKIT_DIR=/opt/django-icekit/icekit From 2ecdaa3fef193c879a2e2ca363b421e4487c64f7 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 8 Nov 2016 16:27:27 +1100 Subject: [PATCH 036/184] Install `apt-utils` for convenience. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index f1cbd478..82eb6b9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ RUN apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends \ apt-transport-https \ + apt-utils \ gettext \ gnupg2 \ jq \ From bc3a1a8bbfee24add23376a1fededca7f0383d9d Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 8 Nov 2016 16:27:51 +1100 Subject: [PATCH 037/184] Move `pip` configuration environment variable declaration to after `pip` is installed. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 82eb6b9e..ccfed96e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,8 @@ RUN md5sum bower.json > bower.json.md5 WORKDIR /opt/django-icekit/ -ENV PIP_SRC=/opt RUN wget -nv -O - https://bootstrap.pypa.io/get-pip.py | python +ENV PIP_SRC=/opt COPY requirements.txt setup.py /opt/django-icekit/ RUN pip install --no-cache-dir -r requirements.txt -U From 72626004c3c458cbde1b22593c7462e79926fa60 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Wed, 9 Nov 2016 11:01:02 +1100 Subject: [PATCH 038/184] Generate MD5 signature for requirements.txt individually. --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ccfed96e..326dd925 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,8 +48,7 @@ ENV PIP_SRC=/opt COPY requirements.txt setup.py /opt/django-icekit/ RUN pip install --no-cache-dir -r requirements.txt -U -RUN touch requirements-local.txt -RUN md5sum requirements.txt requirements-local.txt > requirements.md5 +RUN md5sum requirements.txt > requirements.txt.md5 ENV DOCKERIZE_VERSION=0.2.0 RUN wget -nv -O - "https://github.com/jwilder/dockerize/releases/download/v${DOCKERIZE_VERSION}/dockerize-linux-amd64-v${DOCKERIZE_VERSION}.tar.gz" | tar -xz -C /usr/local/bin/ -f - From 828a401f29cfc2015037e1d9fdb08ce3a7c0c363 Mon Sep 17 00:00:00 2001 From: Mark Finger Date: Wed, 9 Nov 2016 11:24:28 +1100 Subject: [PATCH 039/184] Correct the text plugin selection --- icekit/project/settings/_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 0676c5af..c51d38d3 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -557,7 +557,7 @@ # 'fluent_contents.plugins.picture', 'fluent_contents.plugins.rawhtml', 'fluent_contents.plugins.sharedcontent', - 'fluent_contents.plugins.text', + # 'fluent_contents.plugins.text', # 'fluent_contents.plugins.twitterfeed', # Page type and content plugin dependencies. @@ -677,6 +677,7 @@ 'icekit.plugins.slideshow', 'icekit.plugins.image_gallery', 'icekit.plugins.twitter_embed', + 'icekit.plugins.text', ) MIDDLEWARE_CLASSES += ('icekit.publishing.middleware.PublishingMiddleware', ) From e4d9ec7e3cb99f2442cca0e346df4623948e34ee Mon Sep 17 00:00:00 2001 From: Mark Finger Date: Wed, 9 Nov 2016 14:22:47 +1100 Subject: [PATCH 040/184] Indicate how to override the base template - fixes #16 --- docs/howto/start.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/howto/start.md b/docs/howto/start.md index c2582831..b2393986 100644 --- a/docs/howto/start.md +++ b/docs/howto/start.md @@ -80,3 +80,13 @@ Or list just the additional packages that you have already installed: You can also create a `requirements-local.txt` file (ignored by Git) that will be installed automatically when the project is started. + +## How do I change the base template? + +All icekit templates extend a template named `base.html` which is automatically provided by +icekit. To change the base template, you can add a file named `base.html` into your root +`templates` directory. This ensures that all icekit templates will now default to using +your template as a base. + +To ensure maximum compatibility with icekit's conventions for block names, we strongly +recommend refering to [icekit's base template](/icekit/templates/icekit/base.html). From 1b46468918f18ed5e44e66ee72e6dd3630ce8046 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 11:33:11 +1100 Subject: [PATCH 041/184] Add ListableMixin to Articles --- icekit/page_types/article/abstract_models.py | 3 ++ .../migrations/0003_auto_20161110_1125.py | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 icekit/page_types/article/migrations/0003_auto_20161110_1125.py diff --git a/icekit/page_types/article/abstract_models.py b/icekit/page_types/article/abstract_models.py index a186945a..5244c3f2 100644 --- a/icekit/page_types/article/abstract_models.py +++ b/icekit/page_types/article/abstract_models.py @@ -1,6 +1,7 @@ from django.db import models from icekit.content_collections.abstract_models import \ AbstractCollectedContent, AbstractListingPage, TitleSlugMixin +from icekit.mixins import ListableMixin from icekit.publishing.models import PublishableFluentContents @@ -21,10 +22,12 @@ class Meta: class AbstractArticle( PublishableFluentContents, AbstractCollectedContent, + ListableMixin, TitleSlugMixin ): parent = models.ForeignKey( 'ArticleCategoryPage', + verbose_name="Parent listing page", limit_choices_to={'publishing_is_draft': True}, on_delete=models.PROTECT, ) diff --git a/icekit/page_types/article/migrations/0003_auto_20161110_1125.py b/icekit/page_types/article/migrations/0003_auto_20161110_1125.py new file mode 100644 index 00000000..5b1ebe7a --- /dev/null +++ b/icekit/page_types/article/migrations/0003_auto_20161110_1125.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_article', '0002_auto_20161019_1906'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='boosted_search_terms', + field=models.TextField(help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.', blank=True), + ), + migrations.AddField( + model_name='article', + name='list_image', + field=models.ImageField(help_text=b"image to use in listings. Default image is used if this isn't given", upload_to=b'icekit/listable/list_image/', blank=True), + ), + migrations.AlterField( + model_name='article', + name='parent', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, verbose_name=b'Parent listing page', to='icekit_article.ArticleCategoryPage'), + ), + ] From 77d0a59783533261ce9c965ce3be4cc997490aac Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 13:24:08 +1100 Subject: [PATCH 042/184] Adding Hero Image to Article --- icekit/page_types/article/abstract_models.py | 3 ++- .../migrations/0004_article_hero_image.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 icekit/page_types/article/migrations/0004_article_hero_image.py diff --git a/icekit/page_types/article/abstract_models.py b/icekit/page_types/article/abstract_models.py index 5244c3f2..f32dd441 100644 --- a/icekit/page_types/article/abstract_models.py +++ b/icekit/page_types/article/abstract_models.py @@ -1,7 +1,7 @@ from django.db import models from icekit.content_collections.abstract_models import \ AbstractCollectedContent, AbstractListingPage, TitleSlugMixin -from icekit.mixins import ListableMixin +from icekit.mixins import ListableMixin, HeroMixin from icekit.publishing.models import PublishableFluentContents @@ -23,6 +23,7 @@ class AbstractArticle( PublishableFluentContents, AbstractCollectedContent, ListableMixin, + HeroMixin, TitleSlugMixin ): parent = models.ForeignKey( diff --git a/icekit/page_types/article/migrations/0004_article_hero_image.py b/icekit/page_types/article/migrations/0004_article_hero_image.py new file mode 100644 index 00000000..6bd267bb --- /dev/null +++ b/icekit/page_types/article/migrations/0004_article_hero_image.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_image', '0009_auto_20161026_2044'), + ('icekit_article', '0003_auto_20161110_1125'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='hero_image', + field=models.ForeignKey(related_name='+', null=True, help_text=b'The hero image for this content.', blank=True, to='icekit_plugins_image.Image'), + ), + ] From 2744800300f43e1d56e55dae5cc1e06139c191c9 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 13:24:33 +1100 Subject: [PATCH 043/184] Convert dashboard config to list so as to support item assigment --- icekit/project/settings/_base.py | 4 ++-- icekit/project/settings/glamkit.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index c51d38d3..420bf663 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -619,7 +619,7 @@ ), ), - 'DASHBOARD_FEATURED_APPS': ( + 'DASHBOARD_FEATURED_APPS': [ { 'verbose_name': 'Content', 'icon_html': '', @@ -642,7 +642,7 @@ # 'sharedcontent.SharedContent': {}, }, }, - ), + ], } INSTALLED_APPS += ( diff --git a/icekit/project/settings/glamkit.py b/icekit/project/settings/glamkit.py index 1047f3de..f5a9dfdd 100644 --- a/icekit/project/settings/glamkit.py +++ b/icekit/project/settings/glamkit.py @@ -26,7 +26,7 @@ # ICEKIT EVENTS ############################################################### -ICEKIT['DASHBOARD_FEATURED_APPS'] = ( +ICEKIT['DASHBOARD_FEATURED_APPS'] = [ { 'verbose_name': 'Events', 'icon_html': '', @@ -37,7 +37,7 @@ }, -) + ICEKIT['DASHBOARD_FEATURED_APPS'] +] + ICEKIT['DASHBOARD_FEATURED_APPS'] # GLAMKIT SPONSORS ############################################################ From 1573d7442b7b37b6c13e9ba5de5dd45959449fcc Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 14:11:13 +1100 Subject: [PATCH 044/184] Factor out and simplify child_type_name --- icekit/admin.py | 59 +++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/icekit/admin.py b/icekit/admin.py index 07cb2516..176025f8 100644 --- a/icekit/admin.py +++ b/icekit/admin.py @@ -41,8 +41,24 @@ def queryset(self, request, queryset): # MIXINS ###################################################################### +class PolymorphicAdminUtilsMixin(admin.ModelAdmin): + """ + Utility methods for working with Polymorphic admins. + """ + def child_type_name(self, inst): + """ + e.g. for use in list_display + :param inst: a polymorphic parent instance + :return: The name of the polymorphic model + """ + return capfirst(inst.polymorphic_ctype.name) + child_type_name.short_description = "Type" + -class ChildModelPluginPolymorphicParentModelAdmin(PolymorphicParentModelAdmin): +class ChildModelPluginPolymorphicParentModelAdmin( + PolymorphicParentModelAdmin, + PolymorphicAdminUtilsMixin +): """ Get child models and choice labels from registered plugins. """ @@ -50,21 +66,6 @@ class ChildModelPluginPolymorphicParentModelAdmin(PolymorphicParentModelAdmin): child_model_plugin_class = None child_model_admin = None - def get_child_models(self): - """ - Get child models from registered plugins. Fallback to the child model - admin and its base model if no plugins are registered. - """ - child_models = [] - for plugin in self.child_model_plugin_class.get_plugins(): - child_models.append((plugin.model, plugin.model_admin)) - if not child_models: - child_models.append(( - self.child_model_admin.base_model, - self.child_model_admin, - )) - return child_models - def get_child_type_choices(self, request, action): """ Override choice labels with ``verbose_name`` from plugins and sort. @@ -82,21 +83,21 @@ def get_child_type_choices(self, request, action): return sorted(choices, lambda a, b: cmp(a[1], b[1])) return choices - def _child_model_dict(self): - if not hasattr(self, "_child_model_dict_cache"): - self._child_model_dict_cache = dict([ - (ContentType.objects.get_for_model(p.model, for_concrete_model=False).id, p.model) for p in - self.child_model_plugin_class.get_plugins() - ]) - return self._child_model_dict_cache - - def child_type_name(self, inst): + def get_child_models(self): """ - :param inst: a polymorphic parent instance - :return: The name of the polymorphic model + Get child models from registered plugins. Fallback to the child model + admin and its base model if no plugins are registered. """ - return capfirst(self._child_model_dict()[inst.polymorphic_ctype_id]._meta.verbose_name) - child_type_name.short_description = "Type" + child_models = [] + for plugin in self.child_model_plugin_class.get_plugins(): + child_models.append((plugin.model, plugin.model_admin)) + if not child_models: + child_models.append(( + self.child_model_admin.base_model, + self.child_model_admin, + )) + return child_models + # MODELS ###################################################################### From aaf3afb794ab3da237afec4e293dcc65e3c8a236 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 16:48:52 +1100 Subject: [PATCH 045/184] Add ContactPerson model, to add contact details for content. --- icekit/plugins/contact_person/__init__.py | 1 + icekit/plugins/contact_person/admin.py | 12 +++++ icekit/plugins/contact_person/apps.py | 7 +++ .../plugins/contact_person/content_plugins.py | 11 +++++ .../contact_person/migrations/0001_initial.py | 39 ++++++++++++++++ .../migrations/0002_auto_20161110_1531.py | 18 ++++++++ .../contact_person/migrations/__init__.py | 0 icekit/plugins/contact_person/models.py | 46 +++++++++++++++++++ .../plugins/contact_person/default.html | 28 +++++++++++ icekit/project/settings/_base.py | 2 + setup.py | 2 +- 11 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 icekit/plugins/contact_person/__init__.py create mode 100644 icekit/plugins/contact_person/admin.py create mode 100644 icekit/plugins/contact_person/apps.py create mode 100644 icekit/plugins/contact_person/content_plugins.py create mode 100644 icekit/plugins/contact_person/migrations/0001_initial.py create mode 100644 icekit/plugins/contact_person/migrations/0002_auto_20161110_1531.py create mode 100644 icekit/plugins/contact_person/migrations/__init__.py create mode 100644 icekit/plugins/contact_person/models.py create mode 100644 icekit/plugins/contact_person/templates/icekit/plugins/contact_person/default.html diff --git a/icekit/plugins/contact_person/__init__.py b/icekit/plugins/contact_person/__init__.py new file mode 100644 index 00000000..dd48ee45 --- /dev/null +++ b/icekit/plugins/contact_person/__init__.py @@ -0,0 +1 @@ +default_app_config = '%s.apps.AppConfig' % __name__ diff --git a/icekit/plugins/contact_person/admin.py b/icekit/plugins/contact_person/admin.py new file mode 100644 index 00000000..6f299b15 --- /dev/null +++ b/icekit/plugins/contact_person/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from icekit.publishing.admin import PublishingAdmin + +from icekit.admin_mixins import FluentLayoutsMixin +from . import models + + +class ContactPersonAdmin(admin.ModelAdmin): + pass + +admin.site.register(models.ContactPerson, ContactPersonAdmin) + diff --git a/icekit/plugins/contact_person/apps.py b/icekit/plugins/contact_person/apps.py new file mode 100644 index 00000000..0bc4e358 --- /dev/null +++ b/icekit/plugins/contact_person/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + +class AppConfig(AppConfig): + name = '.'.join(__name__.split('.')[:-1]) + label = "icekit_plugins_contact_person" + verbose_name = "Contact person" + verbose_name_plural = "Contact people" diff --git a/icekit/plugins/contact_person/content_plugins.py b/icekit/plugins/contact_person/content_plugins.py new file mode 100644 index 00000000..4475b9f9 --- /dev/null +++ b/icekit/plugins/contact_person/content_plugins.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from fluent_contents.extensions import plugin_pool, ContentPlugin +from .models import ContactPersonItem + + +@plugin_pool.register +class ContactPersonPlugin(ContentPlugin): + model = ContactPersonItem + raw_id_fields = ('contact', ) + render_template = 'icekit/plugins/contact_person/default.html' + category = _('Assets') diff --git a/icekit/plugins/contact_person/migrations/0001_initial.py b/icekit/plugins/contact_person/migrations/0001_initial.py new file mode 100644 index 00000000..41d1d83e --- /dev/null +++ b/icekit/plugins/contact_person/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fluent_contents', '0003_auto_20161021_1441'), + ] + + operations = [ + migrations.CreateModel( + name='ContactPerson', + fields=[ + ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('title', models.CharField(max_length=255, blank=True)), + ('phone', models.CharField(max_length=255, blank=True)), + ('email', models.EmailField(max_length=255, blank=True)), + ], + options={ + 'verbose_name_plural': 'Contact people', + }, + ), + migrations.CreateModel( + name='ContactPersonItem', + fields=[ + ('contentitem_ptr', models.OneToOneField(primary_key=True, serialize=False, auto_created=True, parent_link=True, to='fluent_contents.ContentItem')), + ('contact', models.ForeignKey(to='icekit_plugins_contact_person.ContactPerson')), + ], + options={ + 'db_table': 'contentitem_icekit_plugins_contact_person_contactpersonitem', + 'verbose_name': 'Contact Item', + }, + bases=('fluent_contents.contentitem',), + ), + ] diff --git a/icekit/plugins/contact_person/migrations/0002_auto_20161110_1531.py b/icekit/plugins/contact_person/migrations/0002_auto_20161110_1531.py new file mode 100644 index 00000000..1698202c --- /dev/null +++ b/icekit/plugins/contact_person/migrations/0002_auto_20161110_1531.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_contact_person', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='contactpersonitem', + options={'verbose_name': 'Contact Person'}, + ), + ] diff --git a/icekit/plugins/contact_person/migrations/__init__.py b/icekit/plugins/contact_person/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/icekit/plugins/contact_person/models.py b/icekit/plugins/contact_person/models.py new file mode 100644 index 00000000..f0a2b280 --- /dev/null +++ b/icekit/plugins/contact_person/models.py @@ -0,0 +1,46 @@ +import os +from django.core.urlresolvers import NoReverseMatch +from fluent_contents.models import ContentItem +from fluent_pages.urlresolvers import app_reverse, PageTypeNotMounted +from icekit.publishing.models import PublishingModel +from timezone import timezone +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + +from icekit.content_collections.abstract_models import AbstractCollectedContent, \ + TitleSlugMixin, AbstractListingPage +from icekit.mixins import FluentFieldsMixin + + + + +@python_2_unicode_compatible +class ContactPerson(models.Model): + name = models.CharField(max_length=255) + title = models.CharField(max_length=255, blank=True) + phone = models.CharField(max_length=255, blank=True) + email = models.EmailField(max_length=255, blank=True) + + def __str__(self): + return "{} ({})".format(self.name, self.title) + + class Meta: + verbose_name_plural = "Contact people" + + +@python_2_unicode_compatible +class ContactPersonItem(ContentItem): + """ + A content item that links to a Press Contact. + """ + contact = models.ForeignKey(ContactPerson) + + help_text = \ + 'A content plugin that allows you to add press contact information.' + + class Meta: + verbose_name = _('Contact Person') + + def __str__(self): + return str(self.contact) diff --git a/icekit/plugins/contact_person/templates/icekit/plugins/contact_person/default.html b/icekit/plugins/contact_person/templates/icekit/plugins/contact_person/default.html new file mode 100644 index 00000000..6e227e7a --- /dev/null +++ b/icekit/plugins/contact_person/templates/icekit/plugins/contact_person/default.html @@ -0,0 +1,28 @@ +{% with contact=instance.contact %} +
    +
    +
    +
    +
    + {{ contact.name }} + {% if contact.title %} + + {{ contact.title }} + + {% endif %} +
    + {% if contact.phone %} + + {{ contact.phone }} + + {% endif %} + {% if contact.email %} + + {{ contact.email }} + + {% endif %} +
    +
    +
    +
    +{% endwith %} diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 420bf663..451ac877 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -472,6 +472,7 @@ 'ImageGalleryPlugin', 'FilePlugin', 'SharedContentPlugin', + 'ContactPersonPlugin', ] _EMBED_PLUGINS = [ @@ -662,6 +663,7 @@ # 'icekit.plugins.brightcove', 'icekit.plugins.child_pages', + 'icekit.plugins.contact_person', 'icekit.plugins.faq', 'icekit.plugins.file', 'icekit.plugins.horizontal_rule', diff --git a/setup.py b/setup.py index d4fe0010..0b967002 100644 --- a/setup.py +++ b/setup.py @@ -101,7 +101,7 @@ def find_packages(*paths): 'glamkit': [ 'glamkit-sponsors', 'icekit-events', - 'icekit-press-releases>=0.8', + 'icekit-press-releases>=0.81', 'glamkit-collections>=0.35', ], 'project': [ From 43ed587a71518a099ae25fc988fc9cdc1f31e5e9 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 16:50:49 +1100 Subject: [PATCH 046/184] Change verbose_name for OEmbed w/caption --- .../migrations/0007_auto_20161110_1513.py | 21 +++++++++++++++++++ icekit/plugins/oembed_with_caption/models.py | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 icekit/plugins/oembed_with_caption/migrations/0007_auto_20161110_1513.py diff --git a/icekit/plugins/oembed_with_caption/migrations/0007_auto_20161110_1513.py b/icekit/plugins/oembed_with_caption/migrations/0007_auto_20161110_1513.py new file mode 100644 index 00000000..7858d265 --- /dev/null +++ b/icekit/plugins/oembed_with_caption/migrations/0007_auto_20161110_1513.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_oembed_with_caption', '0006_auto_20161027_2330'), + ] + + operations = [ + migrations.AlterModelOptions( + name='oembedwithcaptionitem', + options={ + 'verbose_name': 'Embedded media', + 'verbose_name_plural': 'Embedded media' + }, + ), + ] diff --git a/icekit/plugins/oembed_with_caption/models.py b/icekit/plugins/oembed_with_caption/models.py index e50c4229..df3a2637 100644 --- a/icekit/plugins/oembed_with_caption/models.py +++ b/icekit/plugins/oembed_with_caption/models.py @@ -4,3 +4,5 @@ class OEmbedWithCaptionItem(abstract_models.AbstractOEmbedWithCaptionItem): class Meta: db_table = "contentitem_oembed_with_caption_item" + verbose_name = "Embedded media" + verbose_name_plural = "Embedded media" \ No newline at end of file From fb36e3fb85acd56921102c57be56eacda92e85d3 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 17:31:48 +1100 Subject: [PATCH 047/184] Re #50 render page with slug 'home' as the homepage, or fall back to a variation of the help page. --- icekit/project/urls.py | 3 +++ icekit/templates/fluent_pages/intro_page.html | 22 ++++++++++++------- icekit/templates/icekit/index.html | 1 - icekit/views.py | 14 +++++++++--- 4 files changed, 28 insertions(+), 12 deletions(-) delete mode 100644 icekit/templates/icekit/index.html diff --git a/icekit/project/urls.py b/icekit/project/urls.py index 88fef689..98ff85ee 100644 --- a/icekit/project/urls.py +++ b/icekit/project/urls.py @@ -6,6 +6,7 @@ from fluent_pages.sitemaps import PageSitemap from icekit.admin_forms import PasswordResetForm +from icekit.views import index admin.autodiscover() @@ -16,6 +17,8 @@ urlpatterns = patterns( '', + url(r'^$', index, name="home"), + # Test error templates. url(r'^404/$', TemplateView.as_view(template_name='404.html')), url(r'^500/$', TemplateView.as_view(template_name='500.html')), diff --git a/icekit/templates/fluent_pages/intro_page.html b/icekit/templates/fluent_pages/intro_page.html index f4c4468a..e0f57644 100644 --- a/icekit/templates/fluent_pages/intro_page.html +++ b/icekit/templates/fluent_pages/intro_page.html @@ -28,15 +28,21 @@

    Welcome to ICEkit!

    -

    The website on {{ site.domain|default:request.get_host }} is empty.

    -

    You can start by adding your first page in the admin interface.

    + {% if has_pages %} +

    Create a page with slug 'home' to become the homepage.

    + {% else %} +

    The website on {{ site.domain|default:request.get_host }} is empty.

    +

    You can start by adding your first page in the admin interface. Give it the slug 'home' to become the homepage.

    + {% endif %}
    -
    -

    - You're seeing this message because you have DEBUG = True in your - Django settings file and you haven't published any pages yet. Get to work! -

    -
    + {% comment %} +
    +

    + You're seeing this message because you have DEBUG = True in your + Django settings file and you haven't published any pages yet. Get to work! +

    +
    + {% endcomment %} diff --git a/icekit/templates/icekit/index.html b/icekit/templates/icekit/index.html deleted file mode 100644 index 557db03d..00000000 --- a/icekit/templates/icekit/index.html +++ /dev/null @@ -1 +0,0 @@ -Hello World diff --git a/icekit/views.py b/icekit/views.py index fb17ce65..95e483d7 100644 --- a/icekit/views.py +++ b/icekit/views.py @@ -9,8 +9,16 @@ from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse - +from fluent_pages.models import Page +from fluent_pages.views import CmsPageDispatcher def index(request): - context = {} - return TemplateResponse(request, 'icekit/index.html', context) + try: + # If there is a page with slug 'home', render it, otherwise render a getting started page + return CmsPageDispatcher.as_view()(request, path='home/') + except Http404: + # render the welcome page + context = { + 'has_pages': Page.objects.count(), + } + return TemplateResponse(request, 'fluent_pages/intro_page.html', context) From 953ac40bc1f981deb94db3e50a5572c2063dc637 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 10 Nov 2016 18:23:24 +1100 Subject: [PATCH 048/184] Add hero and listing mixins to default page type. Also changelog. --- docs/changelog.md | 20 +++++++++++-- icekit/admin_mixins.py | 26 ++++++++++++++++ icekit/page_types/layout_page/admin.py | 18 +++++++++-- .../migrations/0004_auto_20161110_1737.py | 30 +++++++++++++++++++ icekit/page_types/layout_page/models.py | 3 +- icekit/project/settings/glamkit.py | 2 +- 6 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 icekit/page_types/layout_page/migrations/0004_auto_20161110_1737.py diff --git a/docs/changelog.md b/docs/changelog.md index 3f7e6201..f0f169b9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,7 +24,12 @@ * Ignore `.env` and `docker-cloud.*.yml` files, which frequently contain secrets that should not be committed. -Breaking changes: + * New `ContactPerson` model + and plugin added, allowing adding of staff + contacts to most types of content. + + * Verbose Name for "o embed with caption" is now "Embedded media" + +### Breaking changes: * The initial migration for `icekit.plugins.slideshow` had the wrong database table name, which meant that the `0004_auto_20160821_2140` migration would @@ -39,7 +44,18 @@ Breaking changes: * `icekit.articles` is no more. Functionality is moved to `icekit.content_collections`. - * The `FEATURED_APPS` setting has moved to `ICEKIT['DASHBOARD_FEATURED_APPS']`. + * The `FEATURED_APPS` setting has moved to `ICEKIT['DASHBOARD_FEATURED_APPS']`, + and is now a list, not a tuple, so as to support item assignment. + + * Added HeroMixin and ListableMixin to LayoutPage and Article. This will break + ported/subclass models that define similarly-named fields. Either remove the + definition or migrate data somehow. + + * The required version of Press Releases removes the `PressContact` model, in + favour of `ContactPerson`. If you have `PressContacts`, you will need to + migrate to the new model. + + ## 0.15 (27 September 2016) diff --git a/icekit/admin_mixins.py b/icekit/admin_mixins.py index 1b0c7369..e33dd93e 100644 --- a/icekit/admin_mixins.py +++ b/icekit/admin_mixins.py @@ -1,3 +1,4 @@ +from django.contrib import admin from fluent_contents.admin import PlaceholderEditorAdmin from fluent_contents.models import PlaceholderData @@ -31,3 +32,28 @@ def get_placeholder_data(self, request, obj): else: data = obj.layout.get_placeholder_data() return data + +class HeroMixinAdmin(admin.ModelAdmin): + raw_id_fields = ('hero_image',) + + # Alas, we cannot use 'fieldsets' as it causes a recursionerror on + # (polymorphic?) admins that use base_fieldsets. + FIELDSETS = ( + ('Hero section', { + 'fields': ( + 'hero_image', + ) + }), + ) + + +class ListableMixinAdmin(admin.ModelAdmin): + FIELDSETS = ( + ('Advanced listing options', { + 'classes': ('collapse',), + 'fields': ( + 'list_image', + 'boosted_search_terms', + ) + }), + ) diff --git a/icekit/page_types/layout_page/admin.py b/icekit/page_types/layout_page/admin.py index b04793c3..4fca965e 100644 --- a/icekit/page_types/layout_page/admin.py +++ b/icekit/page_types/layout_page/admin.py @@ -1,6 +1,7 @@ from fluent_pages.integration.fluent_contents.admin import FluentContentsPageAdmin -from icekit.admin_mixins import FluentLayoutsMixin +from icekit.admin_mixins import FluentLayoutsMixin, HeroMixinAdmin, \ + ListableMixinAdmin from icekit.publishing.admin import PublishingAdmin @@ -9,5 +10,16 @@ class UnpublishableLayoutPageAdmin(FluentLayoutsMixin, FluentContentsPageAdmin): class LayoutPageAdmin( - FluentLayoutsMixin, FluentContentsPageAdmin, PublishingAdmin): - raw_id_fields = ('parent',) + FluentLayoutsMixin, + FluentContentsPageAdmin, + PublishingAdmin, + HeroMixinAdmin, + ListableMixinAdmin, +): + + raw_id_fields = HeroMixinAdmin.raw_id_fields + ('parent',) + + base_fieldsets = FluentContentsPageAdmin.base_fieldsets[0:1] + \ + HeroMixinAdmin.FIELDSETS + \ + ListableMixinAdmin.FIELDSETS + \ + FluentContentsPageAdmin.base_fieldsets[1:] \ No newline at end of file diff --git a/icekit/page_types/layout_page/migrations/0004_auto_20161110_1737.py b/icekit/page_types/layout_page/migrations/0004_auto_20161110_1737.py new file mode 100644 index 00000000..11e9a6a7 --- /dev/null +++ b/icekit/page_types/layout_page/migrations/0004_auto_20161110_1737.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_image', '0009_auto_20161026_2044'), + ('layout_page', '0003_auto_20160810_1856'), + ] + + operations = [ + migrations.AddField( + model_name='layoutpage', + name='boosted_search_terms', + field=models.TextField(help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.', blank=True), + ), + migrations.AddField( + model_name='layoutpage', + name='hero_image', + field=models.ForeignKey(null=True, related_name='+', blank=True, to='icekit_plugins_image.Image', help_text=b'The hero image for this content.'), + ), + migrations.AddField( + model_name='layoutpage', + name='list_image', + field=models.ImageField(help_text=b"image to use in listings. Default image is used if this isn't given", blank=True, upload_to=b'icekit/listable/list_image/'), + ), + ] diff --git a/icekit/page_types/layout_page/models.py b/icekit/page_types/layout_page/models.py index 3eea9fa7..f9ae4244 100644 --- a/icekit/page_types/layout_page/models.py +++ b/icekit/page_types/layout_page/models.py @@ -1,7 +1,8 @@ +from icekit.mixins import HeroMixin, ListableMixin from . import abstract_models -class LayoutPage(abstract_models.AbstractLayoutPage): +class LayoutPage(abstract_models.AbstractLayoutPage, HeroMixin, ListableMixin): class Meta: verbose_name = "Layout page" diff --git a/icekit/project/settings/glamkit.py b/icekit/project/settings/glamkit.py index f5a9dfdd..16109f22 100644 --- a/icekit/project/settings/glamkit.py +++ b/icekit/project/settings/glamkit.py @@ -54,7 +54,7 @@ 'main': {'plugins': DEFAULT_PLUGINS }, 'pressrelease_contacts': { 'plugins': ( - 'ContactItemPlugin', + 'ContactPersonPlugin', 'TextPlugin', ), }, From 61cba06601df85b7c2680586a5458ef556c0d939 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 11 Nov 2016 09:01:43 +1100 Subject: [PATCH 049/184] Changing migration dependency to one that can be found upstream --- icekit/plugins/contact_person/migrations/0001_initial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icekit/plugins/contact_person/migrations/0001_initial.py b/icekit/plugins/contact_person/migrations/0001_initial.py index 41d1d83e..2e6b470b 100644 --- a/icekit/plugins/contact_person/migrations/0001_initial.py +++ b/icekit/plugins/contact_person/migrations/0001_initial.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('fluent_contents', '0003_auto_20161021_1441'), + ('fluent_contents', '0001_initial'), ] operations = [ From a273515b19847e6a74146701b0c2310207a35648 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 14 Nov 2016 09:56:06 +1100 Subject: [PATCH 050/184] F/E documentation --- docs/howto/start.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/howto/start.md b/docs/howto/start.md index b2393986..aae63c0c 100644 --- a/docs/howto/start.md +++ b/docs/howto/start.md @@ -46,6 +46,10 @@ All settings are optional, but you can provide: ## Where to put files +Put your layout template files in `templates/layouts/`. + +Put your static files (js/css/static images) in `static/`. + Anything you put in the `static` or `templates` directories will override the default ICEkit static files and templates. @@ -56,6 +60,27 @@ The `project_settings.py` file is a Django settings module. You can override any default Django/ICEkit settings or configure apps installed via `requirements .txt`. + +## How do I add project-specific URLs? + +Create a `urls.py` file in a project folder of your choice, such as `myproject`. +It can contain something like + + + from django.conf.urls import include, patterns, url + + urlpatterns = patterns( + 'myproject.views', + ... your URLs here ... + # finally, fall back to ICEkit/GLAMkit URLs. + url('', include('icekit.project.urls')), # use `glamkit_urls` if this is a GLAMkit project + ] + +Lastly, in `project_settings.py`, override the default URL path: + + ROOT_URLCONF = 'myproject.urls' + + ## Where's my virtualenv? How do I modify a source package? If you're running in Docker, the requirements are installed in the Docker image From 710f86ea89679a4dc35b99a242cf373caba0bb5d Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 14 Nov 2016 10:48:51 +1100 Subject: [PATCH 051/184] filter_horizontal for Layouts --- icekit/admin.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/icekit/admin.py b/icekit/admin.py index 176025f8..421efa53 100644 --- a/icekit/admin.py +++ b/icekit/admin.py @@ -4,6 +4,7 @@ # Define `list_display`, `list_filter` and `search_fields` for each model. # These go a long way to making the admin more usable. +from django import forms from django.conf import settings from django.conf.urls import url, patterns from django.contrib import admin @@ -104,7 +105,7 @@ def get_child_models(self): class LayoutAdmin(admin.ModelAdmin): - model = models.Layout + filter_horizontal = ('content_types',) def _get_ctypes(self): """ @@ -166,18 +167,12 @@ def get_urls(self): ) return my_urls + urls - def get_form(self, *args, **kwargs): - ctypes = self._get_ctypes() + def formfield_for_manytomany(self, db_field, request=None, **kwargs): + if db_field.name == "content_types": + kwargs["queryset"] = ContentType.objects.filter(pk__in=self._get_ctypes()) - class Form(super(LayoutAdmin, self).get_form(*args, **kwargs)): - def __init__(self, *args, **kwargs): - super(Form, self).__init__(*args, **kwargs) - self.fields['content_types'].queryset = self.fields[ - 'content_types'].queryset.filter( - pk__in=ctypes, - ) - - return Form + return super(LayoutAdmin, self)\ + .formfield_for_manytomany(db_field, request, **kwargs) class MediaCategoryAdmin(admin.ModelAdmin): From 15336d719eed2859ddf10154b3f2ce6517508cf3 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 14 Nov 2016 11:29:12 +1100 Subject: [PATCH 052/184] Unused import --- icekit/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/icekit/admin.py b/icekit/admin.py index 421efa53..14d36a13 100644 --- a/icekit/admin.py +++ b/icekit/admin.py @@ -4,7 +4,6 @@ # Define `list_display`, `list_filter` and `search_fields` for each model. # These go a long way to making the admin more usable. -from django import forms from django.conf import settings from django.conf.urls import url, patterns from django.contrib import admin From e8c0efd2e44bea53f019348d50557e491982c270 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 14 Nov 2016 12:23:54 +1100 Subject: [PATCH 053/184] Change to Hero definition, and fix 500 viewing articles. --- icekit/mixins.py | 7 ++++--- icekit/page_types/article/abstract_models.py | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/icekit/mixins.py b/icekit/mixins.py index 807fc999..385f8f35 100644 --- a/icekit/mixins.py +++ b/icekit/mixins.py @@ -153,6 +153,7 @@ def get_title(self): def get_list_image(self): """ :return: the ImageField to use for thumbnails in lists + NB Image Field (since the override is just a field and we don't need alt text), not Image record. """ list_image = first_of( self, @@ -160,7 +161,7 @@ def get_list_image(self): 'get_hero_image', 'image', ) - return list_image + return getattr(list_image, "image", list_image) class HeroMixin(models.Model): @@ -178,5 +179,5 @@ class Meta: abstract = True def get_hero_image(self): - """Return the ImageField""" - return self.hero_image.image + """ Return the Image record to use as the Hero """ + return self.hero_image diff --git a/icekit/page_types/article/abstract_models.py b/icekit/page_types/article/abstract_models.py index f32dd441..b8f90611 100644 --- a/icekit/page_types/article/abstract_models.py +++ b/icekit/page_types/article/abstract_models.py @@ -7,10 +7,12 @@ class AbstractArticleCategoryPage(AbstractListingPage): def get_items_to_list(self, request): + from .models import Article unpublished_pk = self.get_draft().pk return Article.objects.published().filter(parent_id=unpublished_pk) def get_items_to_mount(self, request): + from .models import Article unpublished_pk = self.get_draft().pk return Article.objects.visible().filter(parent_id=unpublished_pk) From 8636d2af4e37c4fe3746402c6b08668ef360331e Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 14 Nov 2016 12:58:19 +1100 Subject: [PATCH 054/184] Repair 500 viewing articles --- .../content_collections/page_type_plugins.py | 2 -- icekit/page_types/article/abstract_models.py | 16 ---------------- icekit/page_types/article/models.py | 18 +++++++++++++----- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/icekit/content_collections/page_type_plugins.py b/icekit/content_collections/page_type_plugins.py index 15b3b6bf..56367bcc 100644 --- a/icekit/content_collections/page_type_plugins.py +++ b/icekit/content_collections/page_type_plugins.py @@ -1,8 +1,6 @@ from django.conf.urls import patterns, url from django.core.exceptions import ObjectDoesNotExist from django.http import Http404 -from django.template.response import TemplateResponse -from fluent_pages.extensions import page_type_pool from icekit.page_types.layout_page.admin import LayoutPageAdmin from icekit.plugins import ICEkitFluentContentsPagePlugin diff --git a/icekit/page_types/article/abstract_models.py b/icekit/page_types/article/abstract_models.py index b8f90611..3a480a24 100644 --- a/icekit/page_types/article/abstract_models.py +++ b/icekit/page_types/article/abstract_models.py @@ -5,22 +5,6 @@ from icekit.publishing.models import PublishableFluentContents -class AbstractArticleCategoryPage(AbstractListingPage): - def get_items_to_list(self, request): - from .models import Article - unpublished_pk = self.get_draft().pk - return Article.objects.published().filter(parent_id=unpublished_pk) - - def get_items_to_mount(self, request): - from .models import Article - unpublished_pk = self.get_draft().pk - return Article.objects.visible().filter(parent_id=unpublished_pk) - - class Meta: - verbose_name = "Article category page" - abstract = True - - class AbstractArticle( PublishableFluentContents, AbstractCollectedContent, diff --git a/icekit/page_types/article/models.py b/icekit/page_types/article/models.py index bfef8cad..9bb19dbe 100644 --- a/icekit/page_types/article/models.py +++ b/icekit/page_types/article/models.py @@ -1,10 +1,18 @@ -from .abstract_models import \ - AbstractArticleCategoryPage, AbstractArticle +from icekit.content_collections.abstract_models import AbstractListingPage +from .abstract_models import AbstractArticle -class ArticleCategoryPage(AbstractArticleCategoryPage): +class Article(AbstractArticle): pass -class Article(AbstractArticle): - pass +class ArticleCategoryPage(AbstractListingPage): + def get_items_to_list(self, request): + unpublished_pk = self.get_draft().pk + return Article.objects.published().filter(parent_id=unpublished_pk) + + def get_items_to_mount(self, request): + unpublished_pk = self.get_draft().pk + return Article.objects.visible().filter(parent_id=unpublished_pk) + + From 2be06c429365bc1bdfabe84b673ed014c5bb0017 Mon Sep 17 00:00:00 2001 From: Mark Finger Date: Tue, 15 Nov 2016 09:54:09 +1100 Subject: [PATCH 055/184] Syntax highlighting --- docs/howto/start.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/howto/start.md b/docs/howto/start.md index aae63c0c..a415a816 100644 --- a/docs/howto/start.md +++ b/docs/howto/start.md @@ -66,19 +66,22 @@ default Django/ICEkit settings or configure apps installed via `requirements Create a `urls.py` file in a project folder of your choice, such as `myproject`. It can contain something like +```python +from django.conf.urls import include, patterns, url - from django.conf.urls import include, patterns, url - - urlpatterns = patterns( - 'myproject.views', - ... your URLs here ... - # finally, fall back to ICEkit/GLAMkit URLs. - url('', include('icekit.project.urls')), # use `glamkit_urls` if this is a GLAMkit project - ] +urlpatterns = patterns( + 'myproject.views', + ... your URLs here ... + # finally, fall back to ICEkit/GLAMkit URLs. + url('', include('icekit.project.urls')), # use `glamkit_urls` if this is a GLAMkit project +] +``` Lastly, in `project_settings.py`, override the default URL path: - ROOT_URLCONF = 'myproject.urls' +```python +ROOT_URLCONF = 'myproject.urls' +``` ## Where's my virtualenv? How do I modify a source package? From cf40f6a16599c72f7495bd5340d04e4e45dfe1c4 Mon Sep 17 00:00:00 2001 From: Fabian McDonald Date: Tue, 15 Nov 2016 11:41:18 +1100 Subject: [PATCH 056/184] Add Icekit admin override styles We have to ensure the Icekit Dashboard app appears before Icekit in installed apps settings --- icekit/dashboard/templates/admin/base_site.html | 3 ++- icekit/project/settings/calculated.py | 2 +- icekit/static/icekit/admin/css/override.less | 14 ++++++++++++++ icekit/templates/admin/base_site.html | 10 ++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 icekit/static/icekit/admin/css/override.less create mode 100644 icekit/templates/admin/base_site.html diff --git a/icekit/dashboard/templates/admin/base_site.html b/icekit/dashboard/templates/admin/base_site.html index 730263f0..dd5ce990 100644 --- a/icekit/dashboard/templates/admin/base_site.html +++ b/icekit/dashboard/templates/admin/base_site.html @@ -1,4 +1,4 @@ -{% extends "admin/base.html" %} +{% extends "icekit:admin/base_site.html" %} {% comment %} * Set verbose site name as title. @@ -15,6 +15,7 @@ {% block extrastyle %} {% compress css %} + {% endcompress %} {% endblock %} diff --git a/icekit/project/settings/calculated.py b/icekit/project/settings/calculated.py index e0d24a21..03f7b958 100644 --- a/icekit/project/settings/calculated.py +++ b/icekit/project/settings/calculated.py @@ -45,8 +45,8 @@ INSTALLED_APPS, [ # First our apps. + 'icekit.dashboard', # Must be before `icekit` 'icekit', - 'icekit.dashboard', 'icekit.integration.reversion', 'polymorphic_auth', diff --git a/icekit/static/icekit/admin/css/override.less b/icekit/static/icekit/admin/css/override.less new file mode 100644 index 00000000..c23161e1 --- /dev/null +++ b/icekit/static/icekit/admin/css/override.less @@ -0,0 +1,14 @@ +/* override wysiwyg styles */ + +// Overide unordered list styling alloy editor +.aligned .form-row .ae-editable { + + ul { + margin-left: 1.5em; + + li { + list-style-type: disc; + } + } + +} diff --git a/icekit/templates/admin/base_site.html b/icekit/templates/admin/base_site.html new file mode 100644 index 00000000..aa921cd4 --- /dev/null +++ b/icekit/templates/admin/base_site.html @@ -0,0 +1,10 @@ +{% extends "admin:admin/base_site.html" %} + +{% load static compress %} + +{% block extrastyle %} + {{ block.super }} + {% compress css %} + + {% endcompress %} +{% endblock %} From 03c96d5805744cf0b643f415e58a45050099a7ec Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Nov 2016 11:46:47 +1100 Subject: [PATCH 057/184] Add icekit hero mixins to all layout pages. --- .../migrations/0003_auto_20161115_1118.py | 30 +++++++++++++++++++ .../page_types/layout_page/abstract_models.py | 6 ++-- icekit/page_types/layout_page/models.py | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 icekit/page_types/author/migrations/0003_auto_20161115_1118.py diff --git a/icekit/page_types/author/migrations/0003_auto_20161115_1118.py b/icekit/page_types/author/migrations/0003_auto_20161115_1118.py new file mode 100644 index 00000000..adaa40cd --- /dev/null +++ b/icekit/page_types/author/migrations/0003_auto_20161115_1118.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_image', '0009_auto_20161026_2044'), + ('icekit_authors', '0002_auto_20161011_1522'), + ] + + operations = [ + migrations.AddField( + model_name='authorlisting', + name='boosted_search_terms', + field=models.TextField(blank=True, help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.'), + ), + migrations.AddField( + model_name='authorlisting', + name='hero_image', + field=models.ForeignKey(related_name='+', blank=True, null=True, help_text=b'The hero image for this content.', to='icekit_plugins_image.Image'), + ), + migrations.AddField( + model_name='authorlisting', + name='list_image', + field=models.ImageField(blank=True, help_text=b"image to use in listings. Default image is used if this isn't given", upload_to=b'icekit/listable/list_image/'), + ), + ] diff --git a/icekit/page_types/layout_page/abstract_models.py b/icekit/page_types/layout_page/abstract_models.py index 82cced23..76c41e72 100644 --- a/icekit/page_types/layout_page/abstract_models.py +++ b/icekit/page_types/layout_page/abstract_models.py @@ -1,7 +1,7 @@ from fluent_pages.integration.fluent_contents import FluentContentsPage from icekit.publishing.models import PublishableFluentContentsPage -from icekit.mixins import LayoutFieldMixin +from icekit.mixins import LayoutFieldMixin, HeroMixin, ListableMixin class AbstractUnpublishableLayoutPage(FluentContentsPage, LayoutFieldMixin): @@ -9,6 +9,6 @@ class Meta: abstract = True -class AbstractLayoutPage(PublishableFluentContentsPage, LayoutFieldMixin): +class AbstractLayoutPage(PublishableFluentContentsPage, LayoutFieldMixin, HeroMixin, ListableMixin): class Meta: - abstract = True + abstract = True \ No newline at end of file diff --git a/icekit/page_types/layout_page/models.py b/icekit/page_types/layout_page/models.py index f9ae4244..edefba32 100644 --- a/icekit/page_types/layout_page/models.py +++ b/icekit/page_types/layout_page/models.py @@ -2,7 +2,7 @@ from . import abstract_models -class LayoutPage(abstract_models.AbstractLayoutPage, HeroMixin, ListableMixin): +class LayoutPage(abstract_models.AbstractLayoutPage): class Meta: verbose_name = "Layout page" From 47510d218c18ea92401760d494026e35c2dd8c2f Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Nov 2016 12:10:58 +1100 Subject: [PATCH 058/184] Add missed articlecategorypage migration --- .../article/migrations/0005_add_hero.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 icekit/page_types/article/migrations/0005_add_hero.py diff --git a/icekit/page_types/article/migrations/0005_add_hero.py b/icekit/page_types/article/migrations/0005_add_hero.py new file mode 100644 index 00000000..e848c222 --- /dev/null +++ b/icekit/page_types/article/migrations/0005_add_hero.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_image', '0009_auto_20161026_2044'), + ('icekit_article', '0004_article_hero_image'), + ] + + operations = [ + migrations.AddField( + model_name='articlecategorypage', + name='boosted_search_terms', + field=models.TextField(blank=True, help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.'), + ), + migrations.AddField( + model_name='articlecategorypage', + name='hero_image', + field=models.ForeignKey(related_name='+', blank=True, null=True, help_text=b'The hero image for this content.', to='icekit_plugins_image.Image'), + ), + migrations.AddField( + model_name='articlecategorypage', + name='list_image', + field=models.ImageField(blank=True, help_text=b"image to use in listings. Default image is used if this isn't given", upload_to=b'icekit/listable/list_image/'), + ), + ] From 8fa9243411806bc0c43c77b3d9430f9aecba3628 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Nov 2016 12:19:40 +1100 Subject: [PATCH 059/184] migrations for test models --- .../migrations/0006_auto_20161115_1219.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 icekit/tests/migrations/0006_auto_20161115_1219.py diff --git a/icekit/tests/migrations/0006_auto_20161115_1219.py b/icekit/tests/migrations/0006_auto_20161115_1219.py new file mode 100644 index 00000000..d5c21dc8 --- /dev/null +++ b/icekit/tests/migrations/0006_auto_20161115_1219.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_plugins_image', '0009_auto_20161026_2044'), + ('tests', '0005_auto_20161027_1428'), + ] + + operations = [ + migrations.AddField( + model_name='articlelisting', + name='boosted_search_terms', + field=models.TextField(help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.', blank=True), + ), + migrations.AddField( + model_name='articlelisting', + name='hero_image', + field=models.ForeignKey(null=True, related_name='+', to='icekit_plugins_image.Image', help_text=b'The hero image for this content.', blank=True), + ), + migrations.AddField( + model_name='articlelisting', + name='list_image', + field=models.ImageField(upload_to=b'icekit/listable/list_image/', help_text=b"image to use in listings. Default image is used if this isn't given", blank=True), + ), + migrations.AddField( + model_name='layoutpagewithrelatedpages', + name='boosted_search_terms', + field=models.TextField(help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.', blank=True), + ), + migrations.AddField( + model_name='layoutpagewithrelatedpages', + name='hero_image', + field=models.ForeignKey(null=True, related_name='+', to='icekit_plugins_image.Image', help_text=b'The hero image for this content.', blank=True), + ), + migrations.AddField( + model_name='layoutpagewithrelatedpages', + name='list_image', + field=models.ImageField(upload_to=b'icekit/listable/list_image/', help_text=b"image to use in listings. Default image is used if this isn't given", blank=True), + ), + ] From 6158ea2e5e07c0bb4e24a1e1c4a2d6ab14c2cb2d Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Nov 2016 12:57:03 +1100 Subject: [PATCH 060/184] Don't require THUMBNAIL_ALIASES to be set. --- icekit/plugins/image/admin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/icekit/plugins/image/admin.py b/icekit/plugins/image/admin.py index e0c33ea8..3b78b672 100644 --- a/icekit/plugins/image/admin.py +++ b/icekit/plugins/image/admin.py @@ -5,6 +5,10 @@ from . import models +try: + ADMIN_THUMB_ALIAS = settings.THUMBNAIL_ALIASES['']['admin'] +except (AttributeError, KeyError): + ADMIN_THUMB_ALIAS = {'size': (150, 150)} class ImageAdmin(ThumbnailAdminMixin, admin.ModelAdmin): list_display = ['thumbnail', 'title', 'alt_text',] @@ -17,6 +21,6 @@ class ImageAdmin(ThumbnailAdminMixin, admin.ModelAdmin): # ThumbnailAdminMixin attributes thumbnail_field = 'image' - thumbnail_options = settings.THUMBNAIL_ALIASES['']['admin'] + thumbnail_options = ADMIN_THUMB_ALIAS admin.site.register(models.Image, ImageAdmin) From c2ae9fa04949e1af6634f4c3d9b14ccc199597b7 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 15 Nov 2016 13:54:34 +1100 Subject: [PATCH 061/184] Add content placeholder JS from django-fluent-contents 1.1.8, re #138 This is the original unchanged script from the django-fluent-contents repo as of 1.1.8. --- .../fluent_contents/admin/cp_plugins.js | 929 ++++++++++++++++++ 1 file changed, 929 insertions(+) create mode 100644 icekit/static/fluent_contents/admin/cp_plugins.js diff --git a/icekit/static/fluent_contents/admin/cp_plugins.js b/icekit/static/fluent_contents/admin/cp_plugins.js new file mode 100644 index 00000000..ec2d1fc6 --- /dev/null +++ b/icekit/static/fluent_contents/admin/cp_plugins.js @@ -0,0 +1,929 @@ +/** + * This file deals with the internal DOM manipulations within a content placeholder. + * namely the plugin ("content item types") which are added, reordered, and removed there. + */ +var cp_plugins = {}; + +(function($){ + + var MOVE_SPEED = 200; + var FLIP_SPEED = 200; + + // Global state + var has_load_error = false; + var restore_timer = null; + + // Allow debugging + var stub = function() {}; + var console = window.console || {'log': stub, 'error': stub}; + + // Settings + var plugin_handlers = {}; + var on_init_callbacks = []; + var before_layout_callbacks = []; + var is_first_layout = true; + + + /** + * Bind an event handler before the plugins are initialized. + */ + cp_plugins.on_init = function(callback) + { + on_init_callbacks.push(callback); + } + + + /** + * Bind an event before the final layout is organized. + * The callback can return true to stop the initialisation of the plugin. + * + * At some point, `move_items_to_placeholders()` needs to be called. + * Either manually, or through some `load_layout()` function. + */ + cp_plugins.on_init_layout = function(callback) + { + before_layout_callbacks.push(callback); + } + + + cp_plugins.init = function() + { + $("#content-main > form").submit( cp_plugins.onFormSubmit ); + + if($.fn.on) { + // jQuery 1.7+ + $("#content-main") + .on('click', ".cp-plugin-add-button", cp_plugins.onAddButtonClick ) + .on('click', ".cp-copy-language-controls .cp-copy-button", cp_plugins.onCopyLanguageButtonClick ) + .on('click', ".cp-item-controls .cp-item-up", cp_plugins.onItemUpClick ) + .on('click', ".cp-item-controls .cp-item-down", cp_plugins.onItemDownClick ) + .on('click', ".cp-item-controls .cp-item-move", cp_plugins.onItemMoveClick ) + .on('click', ".cp-item-controls .cp-item-delete a", cp_plugins.onDeleteClick ); + } + else { + $(".cp-plugin-add-button").live( 'click', cp_plugins.onAddButtonClick ); + $(".cp-copy-language-controls .cp-copy-button").live( 'click', cp_plugins.onCopyLanguageButtonClick ); + $(".cp-item-controls .cp-item-up").live( 'click', cp_plugins.onItemUpClick ); + $(".cp-item-controls .cp-item-down").live( 'click', cp_plugins.onItemDownClick ); + $(".cp-item-controls .cp-item-move").live( 'click', cp_plugins.onItemMoveClick ); + $(".cp-item-controls .cp-item-delete a").live( 'click', cp_plugins.onDeleteClick ); + } + + // Allow plugins to initialize + cp_plugins._init_view_handlers(); + } + + + cp_plugins.post_init = function() + { + // Allow external code to change the layout tabs + // hence delaying the initialisation. + for( var i = 0; i < before_layout_callbacks.length; i++ ) + { + if( before_layout_callbacks[i]() ) + { + console.log('cp_plugins.post_init() - skipped, waiting for layout.'); + return; + } + } + + // Do normal initialisation + cp_plugins.move_items_to_placeholders(); + cp_plugins.init_placeholders(); + } + + + /** + * Initialize additional event binding for the placeholders. + */ + cp_plugins.init_placeholders = function() { + var $content_roots = $(".cp-content"); + $content_roots.each(_init_sortable); + } + + cp_plugins.init_placeholder = function($placeholder) { + var $content_roots = $placeholder.find(".cp-content"); + $content_roots.each(_init_sortable); + } + + cp_plugins.unload_placeholders = function($all_placeholders) { + $all_placeholders.find(".cp-content").each(function(i, element){ + var sortable = $(element).data('sortable'); + if(sortable) { + sortable.destroy(); + } + }) + } + + function _init_sortable(index, element) { + var sortable = Sortable.create(element, { + animation: FLIP_SPEED, + draggable: ".inline-related", + handle: ".cp-formset-item-title", + filter: ".p-item-controls", + scroll: true, + scrollSensitivity: 100, + scrollSpeed: 20, + //group: "", + onStart: function(event) { + // TinyMCE breaks when taken out of the DOM, so + var $fs_item = $(event.item); + cp_plugins._fixate_item_height($fs_item); + cp_plugins.disable_pageitem($fs_item, true); + }, + onEnd: function (event) { + var $fs_item = $(event.item); + cp_plugins.enable_pageitem($fs_item, true); + cp_plugins._restore_item_height($fs_item); + }, + onUpdate: function (event){ + var $fs_item = $(event.item); + var pane = cp_data.get_placeholder_pane_for_item($fs_item); + cp_plugins.update_sort_order(pane); + } + }); + + $(this).data('sortable', sortable); + } + + /** + * This is the starting signal after all tabs or placeholders are created. + * Move all formset items to their appropriate tabs. + * The tab is selected based on template key, and role. + */ + cp_plugins.move_items_to_placeholders = function() + { + // Count number of seen tabs per role. + var roles_seen = {}; + var placeholders = cp_data.get_placeholders(); + if(placeholders == null) { + console.error("Placeholders are not defined. Is the proper PlaceholderFieldAdmin/PlaceholderEditorAdmin class used?") + return; + } + for(var i = 0; i < placeholders.length; i++) + roles_seen[placeholders[i].role] = 0; + + // Move all items to the tabs. + // TODO: direct access to dom_placeholder data, should be cleaned up. + // The DOM placeholders holds the desired layout of all items. + // Depending on the current layout, it placeholder pane can be found, or needs to be migrated. + for(var placeholder_slot in cp_data.dom_placeholders) + { + if(! cp_data.dom_placeholders.hasOwnProperty(placeholder_slot)) + continue; + + var dom_placeholder = cp_data.dom_placeholders[placeholder_slot]; + var last_role_occurance = ++roles_seen[dom_placeholder.role]; + if( dom_placeholder.items.length == 0) + continue; + + // Fill the tab + var pane = cp_plugins.get_new_placeholder_pane(dom_placeholder, last_role_occurance); + cp_plugins.move_items_to_pane(dom_placeholder, pane); + } + + // Initialisation completed! + if( is_first_layout ) + { + console.log("Initialized editor, placeholders=", cp_data.get_placeholders(), " contentitems=", cp_data.contentitem_metadata); + is_first_layout = false; + } + } + + + /** + * Find the new placeholder where the contents can be displayed. + */ + cp_plugins.get_new_placeholder_pane = function(dom_placeholder, last_known_nr) + { + var pane; + var isExpiredTab = window.cp_tabs ? cp_tabs.is_expired_tab : function(node) { return false; }; + + // Option 1. Find identical placeholder by slot name. + var placeholder = cp_data.get_placeholder_by_slot(dom_placeholder.slot); + if( placeholder ) + { + // Option 1. Find by slot name, + pane = cp_data.get_placeholder_pane(placeholder); + if( pane && ! isExpiredTab(pane.root) ) + return pane; + } + + // Option 2. Find a good substitude candidate, the last placeholder which was used for the same role. + var altplaceholder = cp_data.get_placeholder_for_role(dom_placeholder.role, last_known_nr); + if( altplaceholder ) + { + pane = cp_data.get_placeholder_pane(altplaceholder); + if( pane && ! isExpiredTab(pane.root) ) + { + console.log("Using placeholder '" + altplaceholder.slot + "' as fallback for item from placeholder '" + dom_placeholder.slot + "'."); + return pane; + } + } + + // Option 3: If there is only one placeholder, that can be used. + // This is typically the case for the PlaceholderField() object at the page. + var single_placeholder = cp_data.get_single_placeholder(); + if( single_placeholder ) + { + pane = cp_data.get_placeholder_pane(single_placeholder); + if( pane && ! isExpiredTab(pane.root) ) + { + console.log("Using single placeholder '" + single_placeholder.slot + "' as fallback for item from placeholder '" + dom_placeholder.slot + "'."); + return pane; + } + } + + // Option 4. Open a "lost+found" tab. + // NOTE: not really a clean solution, needs a better (public) API for this (cp_tabs is optional). + if( window.cp_tabs ) + { + pane = cp_tabs.get_fallback_pane(); + if( pane ) + { + console.log("Using orphaned tab as fallback for item from placeholder '" + dom_placeholder.slot + "'."); + return pane; + } + } + + throw new Error("No placeholder pane for placeholder: " + dom_placeholder.slot + " (role: " + dom_placeholder.role + ")"); + } + + + /** + * Move the items of one placeholder to the given tab. + */ + cp_plugins.move_items_to_pane = function(dom_placeholder, pane) + { + if( !pane || pane.content.length == 0) + { + if( window.console ) + window.console.error("Invalid tab, missing tab-content: ", pane); + return; + } + + console.log("move_items_to_pane:", dom_placeholder, pane); + + // Reorder in accordance to the sorting order. + cp_plugins._sort_items( dom_placeholder.items ); + + // Move all items to that tab. + // Restore item values upon restoring fields. + for(var i = 0; i < dom_placeholder.items.length; i++) + { + var $fs_item = dom_placeholder.items[i]; + dom_placeholder.items[i] = cp_plugins._move_item_to( $fs_item, function _move_to_pane($fs_item) + { + pane.content.append($fs_item); + + // Update the placeholder-id. + // Note this will not update the dom_placeholders, + // hence the item will move back when the original layout is restored. + if( pane.placeholder ) + cp_plugins._set_pageitem_data($fs_item, pane.placeholder, i); + }); + } + + if( dom_placeholder.items.length ) + pane.empty_message.hide(); + } + + + /** + * Move an item to a new place. + */ + cp_plugins._move_item_to = function( $fs_item, add_action, uses_promise ) + { + var itemId = $fs_item.attr("id"); + var values; + + // Prepare for moving/removing + var beforeMove = function() { + cp_plugins.disable_pageitem($fs_item); // needed for WYSIWYG editors! + values = cp_plugins._get_input_values($fs_item); + }; + + var afterMove = function() { + // Fetch the node reference as it was added to the DOM. + $fs_item = $("#" + itemId); + + // Re-enable the item + cp_plugins._set_input_values($fs_item, values); + cp_plugins.enable_pageitem($fs_item); + return $fs_item; + }; + + if(! uses_promise) { + beforeMove(); + add_action( $fs_item ); + $fs_item = afterMove(); + } + else { + add_action($fs_item, beforeMove, afterMove); + } + + // Return to allow updating the administration + return $fs_item; + } + + + cp_plugins._get_input_values = function($root) + { + // Don't restore the special fields, + // The add_action could move the formset item, and this update it. + var ignoreFields = ['placeholder', 'placeholder_slot', 'sort_order', 'DELETE']; + var isIgnoredField = function(name) { return $.inArray(name.substring(name.lastIndexOf('-')+1), ignoreFields) != -1; }; + + var $inputs = $root.find(":input"); + var values = {}; + for(var i = 0; i < $inputs.length; i++) + { + var $input = $inputs.eq(i) + , id = $input.attr("id") || $input.attr("name") // multiple input checkbox has one name, but different IDs + , input_type = $input[0].type; + if((input_type == 'radio' || input_type == 'checkbox') && !$input[0].checked) + continue; + + if( !isIgnoredField(name) ) + values[id] = $input.val(); + } + + return values; + } + + + cp_plugins._set_input_values = function($root, values) + { + // Don't restore the special fields, + // The add_action could move the formset item, and this update it. + var ignoreFields = ['placeholder', 'placeholder_slot', 'sort_order', 'DELETE']; + var isIgnoredField = function(name) { return $.inArray(name.substring(name.lastIndexOf('-')+1), ignoreFields) != -1; }; + + var $inputs = $root.find(":input"); + for(var i = 0; i < $inputs.length; i++) + { + var $input = $inputs.eq(i) + , id = $input.attr("id") || $input.attr("name"); + + if( values.hasOwnProperty(id) && !isIgnoredField(id) ) + { + var value = values[id]; + cp_plugins._set_input_value($input, value); + } + } + } + + + cp_plugins._set_input_value = function($input, value) + { + var input_type = $input[0].type; + if( input_type == 'radio' || input_type == 'checkbox' ) + { + $input[0].checked = ($input[0].value == value); + } + else + { + if(value == null) + $input.removeAttr('value'); + else + $input.val(value); + } + } + + + // -------- Add plugin feature ------ + + /** + * Add plugin click + */ + cp_plugins.onAddButtonClick = function(event) + { + var $add_button = $(event.target); + var placeholder_key = $add_button.attr("data-placeholder-slot"); // TODO: use ID? + var model_name = $add_button.siblings("select").val(); + cp_plugins.add_formset_item( placeholder_key, model_name ); + } + + + /** + * Add an item to a tab. + */ + cp_plugins.add_formset_item = function( placeholder_slot, model_name, options ) + { + options = options || {}; + + // The Django admin/media/js/inlines.js API is not public, or easy to use. + // Recoded the inline model dynamics. + + // Get objects + var inline_meta = cp_data.get_contentitem_metadata_by_type(model_name); + var group_prefix = inline_meta.auto_id.replace(/%s/, inline_meta.prefix); + var placeholder = cp_data.get_placeholder_by_slot(placeholder_slot); + var dom_placeholder = cp_data.get_or_create_dom_placeholder(placeholder); + + // Get DOM items + var pane = cp_data.get_placeholder_pane(placeholder); + var total = $("#" + group_prefix + "-TOTAL_FORMS")[0]; + + // Clone the item. + var new_index = total.value; + var item_id = inline_meta.prefix + "-" + new_index; + var newhtml = options.get_html + ? options.get_html(inline_meta, new_index) // hook + : inline_meta.item_template.get_outerHtml().replace(/__prefix__/g, new_index); + var $newitem = $(newhtml).removeClass("empty-form").attr("id", item_id); + + // Add it + pane.content.append($newitem); + pane.empty_message.hide(); + + var $fs_item = $("#" + item_id); + if( $fs_item.length == 0 ) + throw new Error("New FormSetItem not found: #" + item_id); + + // Update administration + dom_placeholder.items.push($fs_item); + total.value++; + + // Configure it + cp_plugins._set_pageitem_data($fs_item, placeholder, new_index); + cp_plugins.enable_pageitem($fs_item); + cp_plugins.update_sort_order(pane); // Not required, but keep the form state consistent all the time. + if(options.on_post_add) options.on_post_add($fs_item); + } + + + cp_plugins._set_pageitem_data = function($fs_item, placeholder, new_sort_index) + { + var field_prefix = cp_plugins._get_field_prefix($fs_item); + $("#" + field_prefix + "-placeholder").val(placeholder.id); + $("#" + field_prefix + "-placeholder_slot").val(placeholder.slot); + $("#" + field_prefix + "-sort_order").val(new_sort_index); + } + + + cp_plugins._get_field_prefix = function($fs_item) + { + // Currently redetermining group_prefix, avoid letting fs_item to go out of sync with different call paths. + var current_item = cp_data.get_inline_formset_item_info($fs_item); + var group_prefix = current_item.auto_id.replace(/%s/, current_item.prefix); + return group_prefix + "-" + current_item.index; + } + + + // -------- Moving and sorting plugins ------ + + + cp_plugins.onItemUpClick = function(event) + { + event.preventDefault(); + cp_plugins.swap_formset_item(event.target, true); + } + + + cp_plugins.onItemMoveClick = function(event) + { + event.preventDefault(); + event.stopPropagation(); + cp_plugins._show_move_popup(event.target); + } + + + cp_plugins.onItemDownClick = function(event) + { + event.preventDefault(); + cp_plugins.swap_formset_item(event.target, false); + } + + + cp_plugins.swap_formset_item = function(child_node, isUp) + { + var current_item = cp_data.get_inline_formset_item_info(child_node); + var $fs_item = current_item.fs_item; + + // Get next/previous item + var relative = $fs_item[isUp ? 'prev' : 'next']("div"); + if(!relative.length) return; + + // Calculate animation + var fs_height = $fs_item.height(); + var relative_height = relative.height(); + var fs_move_dist = isUp? -relative_height : relative_height; + var relative_move_dist = isUp? fs_height : -fs_height; + + $fs_item.css({"z-index": 1000}); + + var _moveUpDown = function(fs_item, beforeMove, afterMove) { + var anim1 = fs_item.animate({top: fs_move_dist+"px"}, MOVE_SPEED); + var anim2 = relative.animate({top: relative_move_dist+"px"}, MOVE_SPEED); + + return $.when(anim1, anim2).done(function() { + // Only at the moment supreme, disable editor, swap DOM elements and restore editor. + cp_plugins._fixate_item_height($fs_item); + beforeMove(); + fs_item[isUp ? 'insertBefore' : 'insertAfter'](relative); + afterMove(); + cp_plugins._restore_item_height($fs_item); + + // Reset DOM positions + fs_item.css({'top': '0px', 'z-index': 'auto'}); + relative.css('top', '0px'); + }); + }; + + $fs_item = cp_plugins._move_item_to( $fs_item, _moveUpDown, true ); + } + + + cp_plugins.move_item_to_placeholder = function(child_node, slot) + { + var dominfo = cp_data.get_formset_dom_info(child_node); + var current_item = cp_data.get_inline_formset_item_info(child_node); // childnode is likely already a current_item object. + var $fs_item = current_item.fs_item; + + var old_pane = cp_data.get_placeholder_pane_for_item($fs_item); + var old_slot = old_pane.is_orphaned ? '__orphaned__' : dominfo.placeholder_slot; + var old_placeholder = cp_data.get_placeholder_by_slot(old_slot); + var new_placeholder = cp_data.get_placeholder_by_slot(slot); + var dom_placeholder = cp_data.get_or_create_dom_placeholder(new_placeholder); + var new_pane = cp_data.get_placeholder_pane(new_placeholder); + + // Move formset item + $fs_item = cp_plugins._move_item_to( $fs_item, function(fs_item) { new_pane.content.append(fs_item); } ); + var last_index = cp_plugins.update_sort_order(new_pane); + cp_plugins._set_pageitem_data($fs_item, new_placeholder, last_index); + + // Move to proper dom placeholder list. + // dom_placeholder is currently not accurate, behaves more like "desired placeholder". + if( old_placeholder ) cp_data.remove_dom_item(old_placeholder.slot, current_item); + dom_placeholder.items.push($fs_item); + + // Update placeholders + hide popup + new_pane.empty_message.hide(); + cp_plugins._check_empty_pane(old_pane); + cp_plugins._hide_move_popup(null); + } + + + cp_plugins._show_move_popup = function(child_node) + { + var current_item = cp_data.get_inline_formset_item_info(child_node); + var dominfo = cp_data.get_formset_dom_info(current_item); + var placeholders = cp_data.get_placeholders(); + + // Build popup HTML + var html = '

    Move to

      '; + for( var i = 0; i < placeholders.length; i++ ) + { + var placeholder = placeholders[i]; + if( (placeholder.id && placeholder.id == dominfo.placeholder_id) + || (placeholder.slot && placeholder.slot == dominfo.placeholder_slot) ) + continue; + + html += '
    • ' + placeholder.title + '
    • '; + } + html += '
    '; + $("body").append('
    ' + html + '
    '); + + // Set position + var $window = $(window); + var $child_node = $(child_node); + var pos = $child_node.offset(); + var $popup = $("#cp-move-popup"); + var oldJquery = !$.fn.on; // assume that 1.7 has the bug fixed. + $popup.offset({ + left: parseInt(pos.left) - $popup.width() + 18 + (oldJquery ? $window.scrollLeft() : 0), + top: parseInt(pos.top) + $child_node.height() + 2 + (oldJquery ? $window.scrollTop() : 0) + }); + + // Configure clicks + $popup.find('a').click(function(event){ + event.preventDefault(); + var slot = event.target.href; + slot = slot.substring(slot.indexOf('#') + 1); + cp_plugins.move_item_to_placeholder(current_item, slot); + }); + + $(document).click(cp_plugins._hide_move_popup); + + // Show! + $popup.fadeIn(150); + } + + cp_plugins._hide_move_popup = function(event) + { + if( event && $(event.target).closest('#cp-move-popup').length ) return; + $("#cp-move-popup").remove(); + $(document).unbind('click', cp_plugins._hide_move_popup); + } + + + cp_plugins._fixate_item_height = function($fs_item) + { + // Avoid height flashes by fixating height + clearTimeout( restore_timer ); + var $tabmain = $("#cp-tabmain"); // FIXME: this breaks encapsulation of the tabbar control. Yet it is pretty easy this way. + $tabmain.css("height", $tabmain.height() + "px"); + $fs_item.css("height", $fs_item.height() + "px"); + } + + + cp_plugins._restore_item_height = function($fs_item) + { + // Give more then enough time for the YUI editor to restore. + // The height won't be changed within 2 seconds at all. + var $tabmain = $("#cp-tabmain"); // FIXME: this breaks encapsulation of the tabbar control. Yet it is pretty easy this way. + restore_timer = setTimeout(function() { + $fs_item.css("height", ''); + $tabmain.css("height", ''); + }, 500); + } + + + cp_plugins.onFormSubmit = function(event) + { + // The form state should be consistent all the time, + // but to be sure, update the sort ordering. + var panes = cp_data.get_placeholder_panes(); + for(var i = 0; i < panes.length; i++) + { + cp_plugins.update_sort_order(panes[i]); + } + + // Validate + for(var i = 0; i < panes.length; i++) + { + if( ! cp_plugins.validate_placeholder_forms(panes[i]) ) + { + alert("Internal CMS error: error in placeholder data found. Not saving results"); + event.preventDefault(); + return false; + } + } + } + + + cp_plugins.update_sort_order = function(tab) + { + // Can just assign the order in which it exists in the DOM. + var sort_order = tab.content.find("input[id$=-sort_order]"); + for(var i = 0; i < sort_order.length; i++) + { + sort_order[i].value = i; + } + return i - 1; + } + + + cp_plugins.validate_placeholder_forms = function(tab) + { + var desired_id = tab.placeholder.id; + var desired_slot = tab.placeholder.slot; + var $inputs = tab.content.find('input[type=hidden]'); + var $ids = $inputs.filter('[id$=-placeholder]'); + var $slots = $inputs.filter('[id$=-placeholder_slot]'); + if( $ids.length != $slots.length ) + return false; + + for( var i = 0; i < $ids.length; i++ ) + { + var id = $ids[i].value, slot = $slots[i].value; + if( id != desired_id || (slot != desired_slot && !(slot == '' && id)) ) + return false; + } + return true; + } + + + cp_plugins._sort_items = function($items) + { + // The sort_order field is likely top-level, but the fieldset html can place it anywhere. + for( var i = 0; i < $items.length; i++ ) + { + var $fs_item = $items[i]; + $fs_item._sort_order = parseInt($fs_item.find("input[id$=-sort_order]:first").val()); + } + + $items.sort(function(a, b) { return a._sort_order - b._sort_order; }); + } + + + + // -------- Copy languages ------ + + cp_plugins.onCopyLanguageButtonClick = function(event) + { + var $button = $(event.target); + var language_code = $button.siblings('select').val(); + var placeholder_slot = $button.attr('data-placeholder-slot'); + var url = $('.inline-placeholder-group').attr('data-get-placeholder-data-url'); + $.ajax({ + url: url, + dataType: 'json', + data: 'language=' + language_code, + success: function(data, textStatus, xhr) + { + // Ask to update the tabs! + if(data.success) + cp_plugins.load_formset_data(data, placeholder_slot); + else + alert("Internal CMS error: failed to fetch site data!"); + }, + error: function(xhr, textStatus, ex) + { + alert("Internal CMS error: failed to fetch site data!"); // can't yet rely on $.ajaxError + } + }); + } + + + cp_plugins.load_formset_data = function(data, match_placeholder_slot) + { + for (var i = 0; i < data.formset_forms.length; i++) { + // Each item is stored as basic formdata + // and generated HTML. + var itemdata = data.formset_forms[i]; + if(match_placeholder_slot && itemdata.placeholder_slot != match_placeholder_slot) + continue; + + // Replace the server-side generated prefix, + // as this clearly won't be the same as what we'll generate client-side. + var re_old_prefix = new RegExp(itemdata.prefix + '-', 'g'); + + cp_plugins.add_formset_item(itemdata.placeholder_slot, itemdata.model, { + 'get_html': function(inline_meta, new_index) { + // Use the server-side provided HTML, which has fields filled in + // with all template-styling handled. It's a literal copy of the edit page. + var new_prefix = inline_meta.prefix + "-" + new_index + "-"; + var new_formfields = itemdata.html.replace(re_old_prefix, new_prefix); + + // Take the original template, replace the contents of the 'cp-formset-item-fields' block. + var orig_html = inline_meta.item_template.get_outerHtml().replace(/__prefix__/g, new_index); + var $orig_html = $(orig_html); + $orig_html.find('.cp-formset-item-fields').empty().html(new_formfields); + return $orig_html; + }, + 'on_post_add': function($fs_item) { + // Trigger a change() event for radio fields. + // This fixes the django-any-urlfield display. + $fs_item.find('input[type=radio]:checked').change(); + } + }); + } + } + + + + // -------- Delete plugin ------ + + + /** + * Delete item click + */ + cp_plugins.onDeleteClick = function(event) + { + event.preventDefault(); + cp_plugins.remove_formset_item(event.target); + } + + + cp_plugins.remove_formset_item = function(child_node) + { + // Get dom info + var current_item = cp_data.get_inline_formset_item_info(child_node); + var dominfo = cp_data.get_formset_dom_info(current_item); + var pane = cp_data.get_placeholder_pane_for_item(current_item.fs_item); + + // Get administration + // dominfo slot is always filled in, id may be unknown yet. + var placeholder = null; + var total_count = parseInt(dominfo.total_forms.value); + if( dominfo.placeholder_slot ) // could be orphaned tab + placeholder = cp_data.get_placeholder_by_slot( dominfo.placeholder_slot ); + + // Final check + if( dominfo.id_field.length == 0 ) + throw new Error("ID field not found for deleting objects!"); + + // Disable item, wysiwyg, etc.. + current_item.fs_item.css("height", current_item.fs_item.height() + "px"); // Fixate height, less redrawing. + cp_plugins.disable_pageitem(current_item.fs_item); + + // In case there is a delete checkbox, save it. + if( dominfo.delete_checkbox.length ) + { + var id_field = dominfo.id_field.remove().insertAfter(dominfo.total_forms); + dominfo.delete_checkbox.attr('checked', true).remove().insertAfter(dominfo.total_forms); + } + else + { + // Newly added item, renumber in reverse order + for( var i = current_item.index + 1; i < total_count; i++ ) + { + var $fs_item = $("#" + current_item.prefix + "-" + i); + cp_admin.renumber_formset_item($fs_item, current_item.prefix, i - 1); + } + + dominfo.total_forms.value--; + } + + // And remove item + current_item.fs_item.remove(); + + // Remove from node list, if all removed + if( placeholder ) + { + // TODO: currently ignoring return value. dom_placeholder is currently not accurate, behaves more like "desired placeholder". + // TODO: deal with orphaned items, might exist somewhere in the dom_placeholder administration. + cp_data.remove_dom_item(placeholder.slot, current_item); + } + + // Show empty tab message + cp_plugins._check_empty_pane(pane); + if( window.cp_tabs ) + cp_tabs.update_empty_message(); + } + + + cp_plugins._check_empty_pane = function(pane) + { + if( pane.content.children('.inline-related').length == 0 ) + { + pane.empty_message.show(); + + // Orphaned tab? + if( pane.placeholder == null && window.cp_tabs ) + { + cp_tabs.hide_fallback_pane(); + } + } + } + + + // -------- Page item scripts ------ + + /** + * Register a class which can update the appearance of a plugin + * when it is loaded in the DOM tree. + */ + cp_plugins.register_view_handler = function(model_typename, view_handler) + { + var typename = model_typename; + if( plugin_handlers[ typename ] ) + throw new Error("Plugin already registered: " + typename); + //if( cp_data.get_formset_itemtype( typename ) == null ) + // throw new Error("Plugin Model type unknown: " + typename); + + plugin_handlers[ typename ] = view_handler; + } + + cp_plugins._init_view_handlers = function() + { + // Allow a global initialization (e.g. have a script that handles things for multiple plugins) + for( var i = 0; i < on_init_callbacks.length; i++ ) + { + on_init_callbacks[i](); + } + + // Offer plugin view handlers a change to initialize after the placeholder editor is loaded, but before the items are moved. + for( var model_name in plugin_handlers ) + { + if( plugin_handlers.hasOwnProperty(model_name) && plugin_handlers[model_name].initialize ) + { + var item_meta = cp_data.get_contentitem_metadata_by_type(model_name); + var $formset_group = $("#" + item_meta.prefix + "-group"); + plugin_handlers[model_name].initialize($formset_group); + } + } + } + + + cp_plugins.get_view_handler = function($fs_item) + { + var itemdata = cp_data.get_inline_formset_item_info($fs_item); + return plugin_handlers[ itemdata.type ]; + } + + + cp_plugins.enable_pageitem = function($fs_item) + { + // Default actions: + cp_widgets.enable_wysiwyg($fs_item); + + // Custom view handler + var view_handler = cp_plugins.get_view_handler($fs_item); + if( view_handler ) view_handler.enable($fs_item); + } + + + cp_plugins.disable_pageitem = function($fs_item) + { + // Default actions: + cp_widgets.disable_wysiwyg($fs_item); + + // Custom code + var view_handler = cp_plugins.get_view_handler($fs_item); + if( view_handler ) view_handler.disable($fs_item); + } + +})(window.jQuery || django.jQuery); From 09f83904b1f1aa1fcc600b17b31bd8ea73f1b857 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 15 Nov 2016 14:22:34 +1100 Subject: [PATCH 062/184] Fix typo. --- icekit/utils/sequences.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icekit/utils/sequences.py b/icekit/utils/sequences.py index fcdc8045..7988d173 100644 --- a/icekit/utils/sequences.py +++ b/icekit/utils/sequences.py @@ -15,7 +15,7 @@ def dedupe_and_sort(sequence, first=None, last=None): Items from `sequence` that don't appear in `first` will come after any that do, and retain their existing order. - Returns a sequence of the same as given. + Returns a sequence of the same type as given. """ first = first or [] last = last or [] From 8a6319e13f6c15b967520d8b010ea0ef1b64b527 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 15 Nov 2016 14:23:03 +1100 Subject: [PATCH 063/184] Add missing default `CELERYBEAT_SCHEDULE` setting. --- icekit/project/settings/_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 451ac877..f84fa5b4 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -356,6 +356,8 @@ CELERY_RESULT_BACKEND = 'djcelery.backends.database:DatabaseBackend' +CELERYBEAT_SCHEDULE = {} + INSTALLED_APPS += ( 'djcelery', 'kombu.transport.django', From 066deef74370832b1bf2d8de12ee851617be8ab3 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 15 Nov 2016 14:23:16 +1100 Subject: [PATCH 064/184] Code style. Remove unnecessary blank line. --- icekit/bin/manage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/icekit/bin/manage.py b/icekit/bin/manage.py index 4ecf212c..1757dce8 100755 --- a/icekit/bin/manage.py +++ b/icekit/bin/manage.py @@ -3,7 +3,6 @@ import os import sys - if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "icekit.project.settings") From 153de81b7a5e8015d1f8bc08529cc6f67fb0713d Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Tue, 15 Nov 2016 14:32:40 +1100 Subject: [PATCH 065/184] Bust Docker build cache. Pin version of `icekit-events`. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e76b4cf2..e56cbf5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -e .[api,brightcove,dev,django18,forms,glamkit,project,search,test] --e git+https://github.com/ic-labs/icekit-events.git@develop#egg=icekit-events +-e git+https://github.com/ic-labs/icekit-events.git@85747d64eb153b3db0ab10e0c53d2858ede73e23#egg=icekit-events -e git+https://github.com/ixc/django-dynamic-fixture.git@ixc/master#egg=django-dynamic-fixture From b4883c4aee78c8fe18e4446f09250e4bc3691b19 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 15 Nov 2016 14:53:19 +1100 Subject: [PATCH 066/184] Clone of HTML template on Add content now includes events, re #138 Keep dynamic behaviour defined for content items forms or fields when adding new content items to the placeholder formset. This is accomplished by: - using jQuery's `clone(true)` clone operation to copy the template including any bound events, rather than getting the raw HTML - using the Django admin's `django.jQuery` instance on which events were defined on page load, so the events are available to the clone operation. --- .../fluent_contents/admin/cp_plugins.js | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/icekit/static/fluent_contents/admin/cp_plugins.js b/icekit/static/fluent_contents/admin/cp_plugins.js index ec2d1fc6..a69b883b 100644 --- a/icekit/static/fluent_contents/admin/cp_plugins.js +++ b/icekit/static/fluent_contents/admin/cp_plugins.js @@ -428,10 +428,26 @@ var cp_plugins = {}; // Clone the item. var new_index = total.value; var item_id = inline_meta.prefix + "-" + new_index; - var newhtml = options.get_html - ? options.get_html(inline_meta, new_index) // hook - : inline_meta.item_template.get_outerHtml().replace(/__prefix__/g, new_index); - var $newitem = $(newhtml).removeClass("empty-form").attr("id", item_id); + if (options.get_html) { + var newhtml = options.get_html(inline_meta, new_index); // hook + var $newitem = $(newhtml); + } else { + // Clone template with `true` arg to include event listeners and use + // Django admin's `django.jQuery` on which events were defined. + var $newitem = django.jQuery(inline_meta.item_template).clone(true); + } + $newitem.removeClass("empty-form").attr("id", item_id); + + // Replace "__prefix__" variable placeholder in all attrs with new index + // Based on http://stackoverflow.com/a/14645827/4970 + $newitem.find("*").each(function() { + var $elem = $(this); + $.each(this.attributes, function() { + if (this.specified) { + $elem.attr(this.name, this.value.replace(/__prefix__/, new_index)); + } + }); + }); // Add it pane.content.append($newitem); From 20097629f3656301596c958c987534e7cc4f110a Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 15 Nov 2016 14:55:04 +1100 Subject: [PATCH 067/184] Remove hack to re-apply dynamic behaviour to related lookup fields, re #138 This hack should no longer be necessary with the general fix applied to the cp_plugins.js script. --- .../icekit/admin/fluent_layouts_change_form.html | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/icekit/templates/icekit/admin/fluent_layouts_change_form.html b/icekit/templates/icekit/admin/fluent_layouts_change_form.html index 0a7d34e5..c25b9cad 100644 --- a/icekit/templates/icekit/admin/fluent_layouts_change_form.html +++ b/icekit/templates/icekit/admin/fluent_layouts_change_form.html @@ -7,18 +7,3 @@ fluent_layouts.layout_url = '{% url "admin:icekit_layout_changelist" %}'; {% endblock %} - -{# the below is adapted from from admin/fluent_pages/page/base_change_form.html #} -{# to make raw ids on new content blocks work #} -{% block admin_change_form_document_ready %} - -{% endblock %} From 756c1d0ed45f4feddc9d931d2363b9757da7330a Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Nov 2016 16:10:55 +1100 Subject: [PATCH 068/184] Changelog notes on AbstractLayoutPage change. --- docs/changelog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index f0f169b9..c1e13732 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -31,6 +31,9 @@ ### Breaking changes: + * `AbstractLayoutPage` now includes ListableMixin and HeroMixin. All models + which inherit from this will need a new migration. + * The initial migration for `icekit.plugins.slideshow` had the wrong database table name, which meant that the `0004_auto_20160821_2140` migration would not have correctly renamed the database table when upgrading to `>=0.10` From 8d24f238e71dd1a1591eb312fa83da7eab985784 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 15 Nov 2016 22:34:49 +1100 Subject: [PATCH 069/184] Make layout_page more easily portable --- icekit/page_types/layout_page/__init__.py | 2 +- icekit/page_types/layout_page/apps.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/icekit/page_types/layout_page/__init__.py b/icekit/page_types/layout_page/__init__.py index d15b46bc..dd48ee45 100644 --- a/icekit/page_types/layout_page/__init__.py +++ b/icekit/page_types/layout_page/__init__.py @@ -1 +1 @@ -default_app_config = 'icekit.page_types.layout_page.apps.LayoutConfig' +default_app_config = '%s.apps.AppConfig' % __name__ diff --git a/icekit/page_types/layout_page/apps.py b/icekit/page_types/layout_page/apps.py index 41bb8d7d..7f3e0812 100644 --- a/icekit/page_types/layout_page/apps.py +++ b/icekit/page_types/layout_page/apps.py @@ -1,6 +1,7 @@ from django.apps import AppConfig -class LayoutConfig(AppConfig): - name = 'icekit.page_types.layout_page' +class AppConfig(AppConfig): + name = '.'.join(__name__.split('.')[:-1]) + label = "layout_page" verbose_name = 'Page' From b546e9ecd636b39b2faf00bb48de8e3fa0d39978 Mon Sep 17 00:00:00 2001 From: James Murty Date: Wed, 16 Nov 2016 10:24:25 +1100 Subject: [PATCH 070/184] Require django-fluent-contents 1.1.8+ for JS compatibility, re #138 Require version 1.1.8 or later of django-fluent-contents to reduce the risk of our Javascript overrides to cp_plugins.js breaking because of upstream changes. This version restriction should be removed if/when the related upstream pull request is merged. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0b967002..473989ee 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ def find_packages(*paths): 'django-bootstrap3', 'django-compressor', 'django-el-pagination', - 'django-fluent-contents', + 'django-fluent-contents>=1.1.8', # For JS compatibility, see #138 'django-fluent-pages', 'django-model-settings', 'django-mptt', From 3d2c24a55d2fad23ff7b19131b6eeeef99a7d1d0 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Wed, 16 Nov 2016 11:36:57 +1100 Subject: [PATCH 071/184] Explaining purpose of requirements-icekit.txt --- project_template/requirements-icekit.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/project_template/requirements-icekit.txt b/project_template/requirements-icekit.txt index 4dd5bbdf..5fbfe447 100644 --- a/project_template/requirements-icekit.txt +++ b/project_template/requirements-icekit.txt @@ -1,3 +1,5 @@ +# requirements-icekit.txt is used by go.sh to install whatever the icekit +# Dockerfile installs into the icekit docker image -e ..[api,brightcove,dev,django18,forms,glamkit,project,search,test] -e git+https://github.com/ic-labs/icekit-events.git@develop#egg=icekit-events -e git+https://github.com/ixc/django-dynamic-fixture.git@ixc/master#egg=django-dynamic-fixture From 75b9d0dc1e54450212dd8676f254ab7f90c295b9 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Wed, 16 Nov 2016 12:30:56 +1100 Subject: [PATCH 072/184] Updating dependency versions --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e56cbf5d..9726067f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -e .[api,brightcove,dev,django18,forms,glamkit,project,search,test] --e git+https://github.com/ic-labs/icekit-events.git@85747d64eb153b3db0ab10e0c53d2858ede73e23#egg=icekit-events +-e git+https://github.com/ic-labs/icekit-events.git@ca527#egg=icekit-events +-e git+https://github.com/ic-labs/glamkit-collections.git@a61be#egg=glamkit-collections -e git+https://github.com/ixc/django-dynamic-fixture.git@ixc/master#egg=django-dynamic-fixture From fd8b2c7989b5c3ab5502890dd1e1c5475c83d1d6 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Wed, 16 Nov 2016 18:01:20 +1100 Subject: [PATCH 073/184] Add style option to TextField. Added a gitignore for phantom migrations created by downstream projects. --- docs/changelog.md | 2 ++ icekit/appsettings.py | 2 +- icekit/plugins/text/appsettings.py | 4 ++++ icekit/plugins/text/migrations/.gitignore | 2 ++ .../text/migrations/0002_textitem_style.py | 19 +++++++++++++++++++ icekit/plugins/text/models.py | 4 ++++ .../icekit/plugins/text/default.html | 2 +- 7 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 icekit/plugins/text/migrations/.gitignore create mode 100644 icekit/plugins/text/migrations/0002_textitem_style.py diff --git a/docs/changelog.md b/docs/changelog.md index c1e13732..6e6f86c9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -29,6 +29,8 @@ * Verbose Name for "o embed with caption" is now "Embedded media" + * Text plugin now has a style setting + ### Breaking changes: * `AbstractLayoutPage` now includes ListableMixin and HeroMixin. All models diff --git a/icekit/appsettings.py b/icekit/appsettings.py index d51a99fb..938bb188 100644 --- a/icekit/appsettings.py +++ b/icekit/appsettings.py @@ -8,4 +8,4 @@ # File class referenced by `icekit.plugins.file.abstract_models.AbstractFileItem`. FILE_CLASS = ICEKIT.get('FILE_CLASS', 'icekit_plugins_file.File') -DASHBOARD_FEATURED_APPS = ICEKIT.get('DASHBOARD_FEATURED_APPS', ()) +DASHBOARD_FEATURED_APPS = ICEKIT.get('DASHBOARD_FEATURED_APPS', ()) \ No newline at end of file diff --git a/icekit/plugins/text/appsettings.py b/icekit/plugins/text/appsettings.py index 721bb23c..9cb6aafb 100644 --- a/icekit/plugins/text/appsettings.py +++ b/icekit/plugins/text/appsettings.py @@ -5,3 +5,7 @@ FLUENT_TEXT_CLEAN_HTML = getattr(settings, "FLUENT_TEXT_CLEAN_HTML", False) FLUENT_TEXT_SANITIZE_HTML = getattr(settings, "FLUENT_TEXT_SANITIZE_HTML", False) + +ICEKIT = getattr(settings, 'ICEKIT', {}) + +TEXT_STYLE_CHOICES = ICEKIT.get('TEXT_STYLE_CHOICES', (('', 'Normal'),)) diff --git a/icekit/plugins/text/migrations/.gitignore b/icekit/plugins/text/migrations/.gitignore new file mode 100644 index 00000000..6460be1e --- /dev/null +++ b/icekit/plugins/text/migrations/.gitignore @@ -0,0 +1,2 @@ +# migrations created if CHOICES changes in a downstream project +0003_auto*.py \ No newline at end of file diff --git a/icekit/plugins/text/migrations/0002_textitem_style.py b/icekit/plugins/text/migrations/0002_textitem_style.py new file mode 100644 index 00000000..a7679f29 --- /dev/null +++ b/icekit/plugins/text/migrations/0002_textitem_style.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('text', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='textitem', + name='style', + field=models.CharField(blank=True, max_length=255, choices=[(b'', b'Normal')]), + ), + ] diff --git a/icekit/plugins/text/models.py b/icekit/plugins/text/models.py index 7bb917d5..c4bd6c40 100644 --- a/icekit/plugins/text/models.py +++ b/icekit/plugins/text/models.py @@ -6,6 +6,7 @@ from fluent_contents.models import ContentItem from icekit.plugins.text import appsettings from django_wysiwyg.utils import clean_html, sanitize_html +from django.db import models @python_2_unicode_compatible @@ -14,6 +15,9 @@ class TextItem(ContentItem): A snippet of HTML text to display on a page. """ text = PluginHtmlField(_('text'), blank=True) + # annoyingly, Django will create useless migrations every time choices + # changes. These shouldn't be committed to ICEKit + style = models.CharField(max_length=255, choices=appsettings.TEXT_STYLE_CHOICES, blank=True) class Meta: verbose_name = _('Text') diff --git a/icekit/plugins/text/templates/icekit/plugins/text/default.html b/icekit/plugins/text/templates/icekit/plugins/text/default.html index 3ac8bf6a..697d9a90 100644 --- a/icekit/plugins/text/templates/icekit/plugins/text/default.html +++ b/icekit/plugins/text/templates/icekit/plugins/text/default.html @@ -1 +1 @@ -{{ instance.text|safe }} +{% if text.style %}
    {{ instance.text|safe }}
    {% else %}{{ instance.text|safe }}{% endif %} From 6da3ed33c06b563b862dad7daae24eb0c874bb0b Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Wed, 16 Nov 2016 23:27:14 +1100 Subject: [PATCH 074/184] Add Zapier webhook notification. --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index e1e7f9f9..f6265716 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,3 +29,7 @@ env: global: # Use `pull-N`, Git tag, or Git branch as Docker image tag. - TAG=$([[ "$TRAVIS_PULL_REQUEST" != "false" ]] && echo "pull-$TRAVIS_PULL_REQUEST" || echo ${TRAVIS_TAG:-$TRAVIS_BRANCH} | sed 's/[^.0-9A-Za-z]/-/g') + +notifications: + webhooks: + secure: "fuQLP69xYcIipFi1982mC9Q1o2IzBzclkDDpvqtevm20QJAGKYzNud7/ujH3r7tWZ6VcQ2fjXAQBfL68LLr0rgPi7Zy11/OiTGTGNxAF7HxZnX070fhh4tDNVYXQqRVWswGP/DyLBGMY3NDIDAY2s2U7SZeKisBFXZyv1JYcDxTWFCGpF5ia1+WprFNvDg9YQCAKqCFW37i1C+qsVUYfvKTpFiDcm9YrkvM3VH2i2ceupukkiBxh2Q8BI2TCc4Oj5dy+urOA40Zwy74HwGlpgi8IQF9BfD5GMzHTh26o10SxFwxNaQtbfXr+aH/df6+Qo0cXN82hjezcLwkQSf/G2A0hBA1TUuUwqGBEjTZ5evmGJKQEXDgEr4ZJmxZ4+2GvoCTHfC4fN6/VcdFD2JWVrfpkqJY1jpah50/K/PcorM/PI2IwIZ07AcG8IcMWo/WYOowUHnq6D7zjiXUU4S3WQ9pkl/MrE+EaLoEfFHf+y83WB8iOIRNSwWTAi73jXTFNFeK5HHtceMYwkYwI4tvPIDbzSV4sKBDL4qoJWqbHLwObfGbm3/kvYfv2h7lH6ZRGXUVO1FO8bVQxL0vQfh5R41RsVjy/hYlwgWrlai7QVWKBX3AbMvyKUEm0Ybnvm1LDQONLR+Oc/P5CZxZ8duba07tiIm5Y9X0X13HOyISg/lk=" From 55ec8cce307c1a4eba2c3bcf08ad41b15552b37a Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 17 Nov 2016 10:49:58 +1100 Subject: [PATCH 075/184] Add AbstractLinkItem content class, for linking to various types of content. This is for manually making lists of e.g. related items. --- icekit/abstract_models.py | 60 +++++++++++++++++++ icekit/appsettings.py | 4 +- icekit/page_types/article/content_plugins.py | 7 +++ icekit/page_types/article/models.py | 4 ++ icekit/project/settings/_base.py | 15 ++++- .../icekit/plugins/link/default.html | 9 +++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 icekit/page_types/article/content_plugins.py create mode 100644 icekit/templates/icekit/plugins/link/default.html diff --git a/icekit/abstract_models.py b/icekit/abstract_models.py index da515bd2..3046ee3e 100644 --- a/icekit/abstract_models.py +++ b/icekit/abstract_models.py @@ -9,6 +9,9 @@ from django.utils import encoding, timezone from django.utils.translation import ugettext_lazy as _ from fluent_contents.analyzer import get_template_placeholder_data +from fluent_contents.extensions import ContentPlugin +from fluent_contents.models import ContentItem +from icekit import appsettings from . import fields, plugins @@ -141,3 +144,60 @@ class BoostedTermsMixin(models.Model): class Meta: abstract = True + + +class AbstractLinkItem(ContentItem): + """ + A content type that is a relation to another model. + + Subclasses should define: + + item = models.ForeignKey(to) + + Assuming the 'to' model implements `ListableMixin` then the Item renders as + a list item. + """ + style = models.CharField(_("Link style"), max_length=255, choices=appsettings.RELATION_STYLE_CHOICES, blank=True) + type_override = models.CharField(max_length=255, blank=True) + title_override = models.CharField(max_length=255, blank=True) + image_override = models.ImageField( + blank=True, + upload_to="icekit/listable/list_image/", + ) + + class Meta: + abstract = True + + def __unicode__(self): + return _("Relation to '%s'") % unicode(self.item) + + def get_type(self): + return self.type_override or self.item.get_type() + + def get_title(self): + return self.title_override or self.item.get_title() + + def get_list_image(self): + return self.image_override or self.item.get_list_image() + + +class LinkPlugin(ContentPlugin): + category = _('Links') + raw_id_fields = ('item') + render_template = 'icekit/plugins/link/default.html' + fieldsets = ( + (None, { + 'fields': ( + 'item', + 'style', + ) + }), + ('Overrides', { + 'fields': ( + 'type_override', + 'title_override', + 'image_override', + ), + 'classes': ('collapse', ) + }), + ) \ No newline at end of file diff --git a/icekit/appsettings.py b/icekit/appsettings.py index 938bb188..497c8d7c 100644 --- a/icekit/appsettings.py +++ b/icekit/appsettings.py @@ -8,4 +8,6 @@ # File class referenced by `icekit.plugins.file.abstract_models.AbstractFileItem`. FILE_CLASS = ICEKIT.get('FILE_CLASS', 'icekit_plugins_file.File') -DASHBOARD_FEATURED_APPS = ICEKIT.get('DASHBOARD_FEATURED_APPS', ()) \ No newline at end of file +DASHBOARD_FEATURED_APPS = ICEKIT.get('DASHBOARD_FEATURED_APPS', ()) + +RELATION_STYLE_CHOICES = ICEKIT.get('RELATION_STYLE_CHOICES', (("", "Normal"), )) \ No newline at end of file diff --git a/icekit/page_types/article/content_plugins.py b/icekit/page_types/article/content_plugins.py new file mode 100644 index 00000000..7c96d4fb --- /dev/null +++ b/icekit/page_types/article/content_plugins.py @@ -0,0 +1,7 @@ +from fluent_contents.extensions import plugin_pool +from icekit.models import LinkPlugin +from .import models + +@plugin_pool.register +class ArticleLinkPlugin(LinkPlugin): + model = models.ArticleLink diff --git a/icekit/page_types/article/models.py b/icekit/page_types/article/models.py index 9bb19dbe..76882d23 100644 --- a/icekit/page_types/article/models.py +++ b/icekit/page_types/article/models.py @@ -1,4 +1,5 @@ from icekit.content_collections.abstract_models import AbstractListingPage +from icekit.models import content_link_item_factory from .abstract_models import AbstractArticle @@ -16,3 +17,6 @@ def get_items_to_mount(self, request): return Article.objects.visible().filter(parent_id=unpublished_pk) +class ArticleLink(content_link_item_factory("icekit_article.Article", verbose_name="Choose article")): + class Meta: + verbose_name = "Article link" \ No newline at end of file diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index f84fa5b4..d253414e 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -437,7 +437,10 @@ }, 'image_gallery_thumb': { 'size': (200, 0), - } + }, + 'list_image': { + 'size': (150, 0), + }, } } @@ -494,17 +497,25 @@ 'ChildPagesPlugin', ] +_LINK_PLUGINS = [ + 'ArticleLinkPlugin', +] + DEFAULT_PLUGINS = \ _BASIC_PLUGINS + \ _TEXT_PLUGINS + \ _ASSETS_PLUGINS + \ _EMBED_PLUGINS + \ - _NAVIGATION_PLUGINS + _NAVIGATION_PLUGINS + \ + _LINK_PLUGINS FLUENT_CONTENTS_PLACEHOLDER_CONFIG = { 'main': { 'plugins': DEFAULT_PLUGINS, }, + 'relations': { + 'plugins': _LINK_PLUGINS, + } # 'sidebar': { # 'plugins': ('...', ), # }, diff --git a/icekit/templates/icekit/plugins/link/default.html b/icekit/templates/icekit/plugins/link/default.html new file mode 100644 index 00000000..226ec2f2 --- /dev/null +++ b/icekit/templates/icekit/plugins/link/default.html @@ -0,0 +1,9 @@ +{% load thumbnail %} + \ No newline at end of file From 7437fb7ee2c3a53d9a0d3f8ac48d66d451c8d00c Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 17 Nov 2016 10:50:22 +1100 Subject: [PATCH 076/184] Minor tidy-up --- icekit/plugins/image/content_plugins.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/icekit/plugins/image/content_plugins.py b/icekit/plugins/image/content_plugins.py index 0f62bcb1..d114e1b0 100644 --- a/icekit/plugins/image/content_plugins.py +++ b/icekit/plugins/image/content_plugins.py @@ -29,7 +29,6 @@ class ImagePlugin(ContentPlugin): ) }) ) - # readonly_fields = ('rendered_caption', ) def get_render_template(self, request, instance, **kwargs): opts = type(instance.parent)._meta @@ -40,10 +39,10 @@ def get_render_template(self, request, instance, **kwargs): 'icekit/plugins/image/default.html' ] ) - # In Django >= 1.8 `select_template` returns and instance of + # In Django >= 1.8 `select_template` returns an instance of # `django.template.backends.django.Template` if the template exists. To obtain the # `django.template.base.Template` object we need to get the `template` attribute on it. - # Previous versions of Django return an `django.template.base.Template` object. + # Previous versions of Django return a `django.template.base.Template` object. if hasattr(template, 'template'): template = template.template return template.name From cf4032b2b7ddf0e3c0a82ddb61f7266413f4b0cb Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 17 Nov 2016 10:50:30 +1100 Subject: [PATCH 077/184] Fix text template --- icekit/plugins/text/templates/icekit/plugins/text/default.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icekit/plugins/text/templates/icekit/plugins/text/default.html b/icekit/plugins/text/templates/icekit/plugins/text/default.html index 697d9a90..7b271830 100644 --- a/icekit/plugins/text/templates/icekit/plugins/text/default.html +++ b/icekit/plugins/text/templates/icekit/plugins/text/default.html @@ -1 +1 @@ -{% if text.style %}
    {{ instance.text|safe }}
    {% else %}{{ instance.text|safe }}{% endif %} +{% if instance.text.style %}
    {{ instance.text|safe }}
    {% else %}{{ instance.text|safe }}{% endif %} \ No newline at end of file From 2d475bcc7bc6c74d844c61e5b8720026880007ab Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Thu, 17 Nov 2016 11:25:40 +1100 Subject: [PATCH 078/184] Items and migrations for ArticleLink and PageLink --- .../migrations/0006_auto_20161117_1117.py | 36 +++++++++++++++++++ icekit/page_types/article/models.py | 9 ++--- .../layout_page/migrations/0005_pagelink.py | 33 +++++++++++++++++ icekit/page_types/layout_page/models.py | 11 ++++-- 4 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 icekit/page_types/article/migrations/0006_auto_20161117_1117.py create mode 100644 icekit/page_types/layout_page/migrations/0005_pagelink.py diff --git a/icekit/page_types/article/migrations/0006_auto_20161117_1117.py b/icekit/page_types/article/migrations/0006_auto_20161117_1117.py new file mode 100644 index 00000000..5e297ea8 --- /dev/null +++ b/icekit/page_types/article/migrations/0006_auto_20161117_1117.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fluent_contents', '0003_auto_20161021_1441'), + ('icekit_article', '0005_add_hero'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleLink', + fields=[ + ('contentitem_ptr', models.OneToOneField(auto_created=True, parent_link=True, primary_key=True, to='fluent_contents.ContentItem', serialize=False)), + ('style', models.CharField(max_length=255, choices=[(b'', b'Small'), (b'large', b'Large')], blank=True, verbose_name='Link style')), + ('type_override', models.CharField(max_length=255, blank=True)), + ('title_override', models.CharField(max_length=255, blank=True)), + ('image_override', models.ImageField(upload_to=b'icekit/listable/list_image/', blank=True)), + ('oneliner_override', models.TextField(max_length=512, blank=True)), + ('item', models.ForeignKey(to='icekit_article.Article')), + ], + options={ + 'verbose_name': 'Article link', + 'db_table': 'contentitem_icekit_article_articlelink', + }, + bases=('fluent_contents.contentitem',), + ), + migrations.AlterModelOptions( + name='articlecategorypage', + options={}, + ), + ] diff --git a/icekit/page_types/article/models.py b/icekit/page_types/article/models.py index 76882d23..6c1eba39 100644 --- a/icekit/page_types/article/models.py +++ b/icekit/page_types/article/models.py @@ -1,7 +1,7 @@ from icekit.content_collections.abstract_models import AbstractListingPage -from icekit.models import content_link_item_factory +from plugins.models import AbstractACMILinkItem from .abstract_models import AbstractArticle - +from django.db import models class Article(AbstractArticle): pass @@ -17,6 +17,7 @@ def get_items_to_mount(self, request): return Article.objects.visible().filter(parent_id=unpublished_pk) -class ArticleLink(content_link_item_factory("icekit_article.Article", verbose_name="Choose article")): +class ArticleLink(AbstractACMILinkItem): + item = models.ForeignKey("Article") class Meta: - verbose_name = "Article link" \ No newline at end of file + verbose_name = "Article link" diff --git a/icekit/page_types/layout_page/migrations/0005_pagelink.py b/icekit/page_types/layout_page/migrations/0005_pagelink.py new file mode 100644 index 00000000..2f8a911a --- /dev/null +++ b/icekit/page_types/layout_page/migrations/0005_pagelink.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fluent_contents', '0003_auto_20161021_1441'), + ('fluent_pages', '0001_initial'), + ('layout_page', '0004_auto_20161110_1737'), + ] + + operations = [ + migrations.CreateModel( + name='PageLink', + fields=[ + ('contentitem_ptr', models.OneToOneField(serialize=False, parent_link=True, auto_created=True, primary_key=True, to='fluent_contents.ContentItem')), + ('style', models.CharField(blank=True, choices=[(b'', b'Small'), (b'large', b'Large')], verbose_name='Link style', max_length=255)), + ('type_override', models.CharField(blank=True, max_length=255)), + ('title_override', models.CharField(blank=True, max_length=255)), + ('image_override', models.ImageField(blank=True, upload_to=b'icekit/listable/list_image/')), + ('oneliner_override', models.TextField(blank=True, max_length=512)), + ('item', models.ForeignKey(to='fluent_pages.Page')), + ], + options={ + 'verbose_name': 'Page link', + 'db_table': 'contentitem_layout_page_pagelink', + }, + bases=('fluent_contents.contentitem',), + ), + ] diff --git a/icekit/page_types/layout_page/models.py b/icekit/page_types/layout_page/models.py index edefba32..e8d6592b 100644 --- a/icekit/page_types/layout_page/models.py +++ b/icekit/page_types/layout_page/models.py @@ -1,8 +1,15 @@ -from icekit.mixins import HeroMixin, ListableMixin +from plugins.models import AbstractACMILinkItem from . import abstract_models - +from django.db import models class LayoutPage(abstract_models.AbstractLayoutPage): class Meta: verbose_name = "Layout page" + + +class PageLink(AbstractACMILinkItem): + item = models.ForeignKey("fluent_pages.Page") + + class Meta: + verbose_name = "Page link" From 54572766d2021b591037f642e5739c52a918a542 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 10:32:25 +1100 Subject: [PATCH 079/184] More convenient command for getting data dump --- docs/intro/docker.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/intro/docker.md b/docs/intro/docker.md index ea5e5b62..290441cc 100644 --- a/docs/intro/docker.md +++ b/docs/intro/docker.md @@ -81,10 +81,8 @@ commands. # then when you've finished and Ctrl-C exited runserver $ supervisorctl.sh start all - # Dump a database, encrypt it, and upload to the transfer.sh service. - $ pg_dump -O -x -f ~/dump.sql - $ cat ~/dump.sql|gpg -ac -o-|curl -X PUT --upload-file "-" https://transfer.sh/dump.x - $ rm ~/dump.sql # don't leave database dumps lying around + # Dump a database, encrypt it, and upload to the transfer.sh service, then delete the local copy + $ pg_dump -O -x -f ~/dump.sql && cat ~/dump.sql|gpg -ac -o-|curl -X PUT --upload-file "-" https://transfer.sh/dump.x && rm ~/dump.sql # then on the destination machine, to download and decrypt: $ curl [transfer.sh url] | gpg -o- > dump.sql From 44b79150568189fe5495023044256935368d2248 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 10:35:06 +1100 Subject: [PATCH 080/184] Moving LinkItem to its own app and defining links to Page, Author and Article. CollectedContent now implements ListableMixin. --- icekit/abstract_models.py | 59 +---------- icekit/appsettings.py | 4 +- icekit/content_collections/abstract_models.py | 4 +- icekit/mixins.py | 3 + icekit/page_types/article/abstract_models.py | 1 - icekit/page_types/article/content_plugins.py | 7 -- .../migrations/0006_auto_20161117_1117.py | 36 ------- .../migrations/0006_auto_20161117_1800.py | 18 ++++ icekit/page_types/article/models.py | 10 +- .../migrations/0004_auto_20161117_1201.py | 24 +++++ .../migrations/0005_auto_20161117_1824.py | 19 ++++ .../migrations/0006_auto_20161117_1825.py | 19 ++++ icekit/page_types/author/models.py | 12 ++- .../page_types/layout_page/abstract_models.py | 6 +- .../layout_page/migrations/0005_pagelink.py | 33 ------- icekit/page_types/layout_page/models.py | 11 +-- icekit/plugins/links/__init__.py | 1 + icekit/plugins/links/abstract_models.py | 98 +++++++++++++++++++ icekit/plugins/links/apps.py | 7 ++ icekit/plugins/links/appsettings.py | 5 + icekit/plugins/links/content_plugins.py | 19 ++++ .../plugins/links/migrations/0001_initial.py | 65 ++++++++++++ .../migrations/0002_auto_20161117_1221.py | 30 ++++++ .../migrations/0003_auto_20161117_1810.py | 29 ++++++ icekit/plugins/links/migrations/__init__.py | 0 icekit/plugins/links/models.py | 28 ++++++ .../templates}/plugins/link/default.html | 0 icekit/plugins/text/appsettings.py | 3 - icekit/project/settings/_base.py | 32 +++--- icekit/project/settings/glamkit.py | 14 ++- icekit/publishing/managers.py | 1 + icekit/publishing/models.py | 15 +++ icekit/utils/attributes.py | 4 +- 33 files changed, 433 insertions(+), 184 deletions(-) delete mode 100644 icekit/page_types/article/content_plugins.py delete mode 100644 icekit/page_types/article/migrations/0006_auto_20161117_1117.py create mode 100644 icekit/page_types/article/migrations/0006_auto_20161117_1800.py create mode 100644 icekit/page_types/author/migrations/0004_auto_20161117_1201.py create mode 100644 icekit/page_types/author/migrations/0005_auto_20161117_1824.py create mode 100644 icekit/page_types/author/migrations/0006_auto_20161117_1825.py delete mode 100644 icekit/page_types/layout_page/migrations/0005_pagelink.py create mode 100644 icekit/plugins/links/__init__.py create mode 100644 icekit/plugins/links/abstract_models.py create mode 100644 icekit/plugins/links/apps.py create mode 100644 icekit/plugins/links/appsettings.py create mode 100644 icekit/plugins/links/content_plugins.py create mode 100644 icekit/plugins/links/migrations/0001_initial.py create mode 100644 icekit/plugins/links/migrations/0002_auto_20161117_1221.py create mode 100644 icekit/plugins/links/migrations/0003_auto_20161117_1810.py create mode 100644 icekit/plugins/links/migrations/__init__.py create mode 100644 icekit/plugins/links/models.py rename icekit/{templates/icekit => plugins/links/templates}/plugins/link/default.html (100%) diff --git a/icekit/abstract_models.py b/icekit/abstract_models.py index 3046ee3e..6dc04b41 100644 --- a/icekit/abstract_models.py +++ b/icekit/abstract_models.py @@ -143,61 +143,4 @@ class BoostedTermsMixin(models.Model): ) class Meta: - abstract = True - - -class AbstractLinkItem(ContentItem): - """ - A content type that is a relation to another model. - - Subclasses should define: - - item = models.ForeignKey(to) - - Assuming the 'to' model implements `ListableMixin` then the Item renders as - a list item. - """ - style = models.CharField(_("Link style"), max_length=255, choices=appsettings.RELATION_STYLE_CHOICES, blank=True) - type_override = models.CharField(max_length=255, blank=True) - title_override = models.CharField(max_length=255, blank=True) - image_override = models.ImageField( - blank=True, - upload_to="icekit/listable/list_image/", - ) - - class Meta: - abstract = True - - def __unicode__(self): - return _("Relation to '%s'") % unicode(self.item) - - def get_type(self): - return self.type_override or self.item.get_type() - - def get_title(self): - return self.title_override or self.item.get_title() - - def get_list_image(self): - return self.image_override or self.item.get_list_image() - - -class LinkPlugin(ContentPlugin): - category = _('Links') - raw_id_fields = ('item') - render_template = 'icekit/plugins/link/default.html' - fieldsets = ( - (None, { - 'fields': ( - 'item', - 'style', - ) - }), - ('Overrides', { - 'fields': ( - 'type_override', - 'title_override', - 'image_override', - ), - 'classes': ('collapse', ) - }), - ) \ No newline at end of file + abstract = True \ No newline at end of file diff --git a/icekit/appsettings.py b/icekit/appsettings.py index 497c8d7c..938bb188 100644 --- a/icekit/appsettings.py +++ b/icekit/appsettings.py @@ -8,6 +8,4 @@ # File class referenced by `icekit.plugins.file.abstract_models.AbstractFileItem`. FILE_CLASS = ICEKIT.get('FILE_CLASS', 'icekit_plugins_file.File') -DASHBOARD_FEATURED_APPS = ICEKIT.get('DASHBOARD_FEATURED_APPS', ()) - -RELATION_STYLE_CHOICES = ICEKIT.get('RELATION_STYLE_CHOICES', (("", "Normal"), )) \ No newline at end of file +DASHBOARD_FEATURED_APPS = ICEKIT.get('DASHBOARD_FEATURED_APPS', ()) \ No newline at end of file diff --git a/icekit/content_collections/abstract_models.py b/icekit/content_collections/abstract_models.py index 58ff69b7..c2b496c0 100644 --- a/icekit/content_collections/abstract_models.py +++ b/icekit/content_collections/abstract_models.py @@ -1,3 +1,5 @@ +from icekit.mixins import ListableMixin + try: from urlparse import urljoin except ImportError: @@ -95,7 +97,7 @@ def get_items_to_mount(self, request): "Please implement `get_items_to_mount(request)` on %r" % type(self) ) -class AbstractCollectedContent(models.Model): +class AbstractCollectedContent(ListableMixin): """ Content collections can be mounted into a publishable listing page, which has the URL returned by `get_parent_url()`. diff --git a/icekit/mixins.py b/icekit/mixins.py index 385f8f35..69589a2b 100644 --- a/icekit/mixins.py +++ b/icekit/mixins.py @@ -124,6 +124,7 @@ class ListableMixin(models.Model): * Title * Image * URL (assume get_absolute_url) + Optional oneliner (implement `get_oneliner()`) ...and since they show in lists, they show in search results, so this model also includes search-related fields. @@ -163,6 +164,8 @@ def get_list_image(self): ) return getattr(list_image, "image", list_image) + def get_oneliner(self): + return getattr(self, 'oneliner', "") class HeroMixin(models.Model): """ diff --git a/icekit/page_types/article/abstract_models.py b/icekit/page_types/article/abstract_models.py index 3a480a24..841ee5b1 100644 --- a/icekit/page_types/article/abstract_models.py +++ b/icekit/page_types/article/abstract_models.py @@ -8,7 +8,6 @@ class AbstractArticle( PublishableFluentContents, AbstractCollectedContent, - ListableMixin, HeroMixin, TitleSlugMixin ): diff --git a/icekit/page_types/article/content_plugins.py b/icekit/page_types/article/content_plugins.py deleted file mode 100644 index 7c96d4fb..00000000 --- a/icekit/page_types/article/content_plugins.py +++ /dev/null @@ -1,7 +0,0 @@ -from fluent_contents.extensions import plugin_pool -from icekit.models import LinkPlugin -from .import models - -@plugin_pool.register -class ArticleLinkPlugin(LinkPlugin): - model = models.ArticleLink diff --git a/icekit/page_types/article/migrations/0006_auto_20161117_1117.py b/icekit/page_types/article/migrations/0006_auto_20161117_1117.py deleted file mode 100644 index 5e297ea8..00000000 --- a/icekit/page_types/article/migrations/0006_auto_20161117_1117.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('fluent_contents', '0003_auto_20161021_1441'), - ('icekit_article', '0005_add_hero'), - ] - - operations = [ - migrations.CreateModel( - name='ArticleLink', - fields=[ - ('contentitem_ptr', models.OneToOneField(auto_created=True, parent_link=True, primary_key=True, to='fluent_contents.ContentItem', serialize=False)), - ('style', models.CharField(max_length=255, choices=[(b'', b'Small'), (b'large', b'Large')], blank=True, verbose_name='Link style')), - ('type_override', models.CharField(max_length=255, blank=True)), - ('title_override', models.CharField(max_length=255, blank=True)), - ('image_override', models.ImageField(upload_to=b'icekit/listable/list_image/', blank=True)), - ('oneliner_override', models.TextField(max_length=512, blank=True)), - ('item', models.ForeignKey(to='icekit_article.Article')), - ], - options={ - 'verbose_name': 'Article link', - 'db_table': 'contentitem_icekit_article_articlelink', - }, - bases=('fluent_contents.contentitem',), - ), - migrations.AlterModelOptions( - name='articlecategorypage', - options={}, - ), - ] diff --git a/icekit/page_types/article/migrations/0006_auto_20161117_1800.py b/icekit/page_types/article/migrations/0006_auto_20161117_1800.py new file mode 100644 index 00000000..6cb79ebb --- /dev/null +++ b/icekit/page_types/article/migrations/0006_auto_20161117_1800.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_article', '0005_add_hero'), + ] + + operations = [ + migrations.AlterModelOptions( + name='articlecategorypage', + options={}, + ), + ] diff --git a/icekit/page_types/article/models.py b/icekit/page_types/article/models.py index 6c1eba39..d89cfd74 100644 --- a/icekit/page_types/article/models.py +++ b/icekit/page_types/article/models.py @@ -1,7 +1,5 @@ from icekit.content_collections.abstract_models import AbstractListingPage -from plugins.models import AbstractACMILinkItem from .abstract_models import AbstractArticle -from django.db import models class Article(AbstractArticle): pass @@ -14,10 +12,4 @@ def get_items_to_list(self, request): def get_items_to_mount(self, request): unpublished_pk = self.get_draft().pk - return Article.objects.visible().filter(parent_id=unpublished_pk) - - -class ArticleLink(AbstractACMILinkItem): - item = models.ForeignKey("Article") - class Meta: - verbose_name = "Article link" + return Article.objects.visible().filter(parent_id=unpublished_pk) \ No newline at end of file diff --git a/icekit/page_types/author/migrations/0004_auto_20161117_1201.py b/icekit/page_types/author/migrations/0004_auto_20161117_1201.py new file mode 100644 index 00000000..6a65b494 --- /dev/null +++ b/icekit/page_types/author/migrations/0004_auto_20161117_1201.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_authors', '0003_auto_20161115_1118'), + ] + + operations = [ + migrations.AddField( + model_name='author', + name='boosted_search_terms', + field=models.TextField(blank=True, help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.'), + ), + migrations.AddField( + model_name='author', + name='list_image', + field=models.ImageField(blank=True, upload_to=b'icekit/listable/list_image/', help_text=b"image to use in listings. Default image is used if this isn't given"), + ), + ] diff --git a/icekit/page_types/author/migrations/0005_auto_20161117_1824.py b/icekit/page_types/author/migrations/0005_auto_20161117_1824.py new file mode 100644 index 00000000..7397c9f8 --- /dev/null +++ b/icekit/page_types/author/migrations/0005_auto_20161117_1824.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_authors', '0004_auto_20161117_1201'), + ] + + operations = [ + migrations.RenameField( + model_name='author', + old_name='introduction', + new_name='oneliner', + ), + ] diff --git a/icekit/page_types/author/migrations/0006_auto_20161117_1825.py b/icekit/page_types/author/migrations/0006_auto_20161117_1825.py new file mode 100644 index 00000000..6f2d4827 --- /dev/null +++ b/icekit/page_types/author/migrations/0006_auto_20161117_1825.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('icekit_authors', '0005_auto_20161117_1824'), + ] + + operations = [ + migrations.AlterField( + model_name='author', + name='oneliner', + field=models.CharField(max_length=255, help_text='An introduction about the author used on list pages.', blank=True), + ), + ] diff --git a/icekit/page_types/author/models.py b/icekit/page_types/author/models.py index 36994379..4288f64f 100644 --- a/icekit/page_types/author/models.py +++ b/icekit/page_types/author/models.py @@ -67,8 +67,9 @@ class Author(AbstractCollectedContent, PublishingModel): validators=[RelativeURLValidator(), ] ) - introduction = PluginHtmlField( - _('introduction'), + oneliner = models.CharField( + max_length=255, + blank=True, help_text=_('An introduction about the author used on list pages.') ) @@ -77,8 +78,9 @@ class Author(AbstractCollectedContent, PublishingModel): ) def __str__(self): - return self.title() + return self.title + @property def title(self): return " ".join((self.given_names, self.family_name)) @@ -115,5 +117,9 @@ def get_absolute_url(self): def get_layout_template_name(self): return "icekit_authors/detail.html" + def get_list_image(self): + if self.portrait: + return self.portrait.image + class Meta: ordering = ('family_name', 'given_names', ) diff --git a/icekit/page_types/layout_page/abstract_models.py b/icekit/page_types/layout_page/abstract_models.py index 76c41e72..ece6baf9 100644 --- a/icekit/page_types/layout_page/abstract_models.py +++ b/icekit/page_types/layout_page/abstract_models.py @@ -11,4 +11,8 @@ class Meta: class AbstractLayoutPage(PublishableFluentContentsPage, LayoutFieldMixin, HeroMixin, ListableMixin): class Meta: - abstract = True \ No newline at end of file + abstract = True + + def get_type(self): + # we don't normally want pages to say they're a 'page' + return "" diff --git a/icekit/page_types/layout_page/migrations/0005_pagelink.py b/icekit/page_types/layout_page/migrations/0005_pagelink.py deleted file mode 100644 index 2f8a911a..00000000 --- a/icekit/page_types/layout_page/migrations/0005_pagelink.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('fluent_contents', '0003_auto_20161021_1441'), - ('fluent_pages', '0001_initial'), - ('layout_page', '0004_auto_20161110_1737'), - ] - - operations = [ - migrations.CreateModel( - name='PageLink', - fields=[ - ('contentitem_ptr', models.OneToOneField(serialize=False, parent_link=True, auto_created=True, primary_key=True, to='fluent_contents.ContentItem')), - ('style', models.CharField(blank=True, choices=[(b'', b'Small'), (b'large', b'Large')], verbose_name='Link style', max_length=255)), - ('type_override', models.CharField(blank=True, max_length=255)), - ('title_override', models.CharField(blank=True, max_length=255)), - ('image_override', models.ImageField(blank=True, upload_to=b'icekit/listable/list_image/')), - ('oneliner_override', models.TextField(blank=True, max_length=512)), - ('item', models.ForeignKey(to='fluent_pages.Page')), - ], - options={ - 'verbose_name': 'Page link', - 'db_table': 'contentitem_layout_page_pagelink', - }, - bases=('fluent_contents.contentitem',), - ), - ] diff --git a/icekit/page_types/layout_page/models.py b/icekit/page_types/layout_page/models.py index e8d6592b..e4780dae 100644 --- a/icekit/page_types/layout_page/models.py +++ b/icekit/page_types/layout_page/models.py @@ -1,15 +1,6 @@ -from plugins.models import AbstractACMILinkItem from . import abstract_models -from django.db import models class LayoutPage(abstract_models.AbstractLayoutPage): class Meta: - verbose_name = "Layout page" - - -class PageLink(AbstractACMILinkItem): - item = models.ForeignKey("fluent_pages.Page") - - class Meta: - verbose_name = "Page link" + verbose_name = "Layout page" \ No newline at end of file diff --git a/icekit/plugins/links/__init__.py b/icekit/plugins/links/__init__.py new file mode 100644 index 00000000..dd48ee45 --- /dev/null +++ b/icekit/plugins/links/__init__.py @@ -0,0 +1 @@ +default_app_config = '%s.apps.AppConfig' % __name__ diff --git a/icekit/plugins/links/abstract_models.py b/icekit/plugins/links/abstract_models.py new file mode 100644 index 00000000..0c94e7aa --- /dev/null +++ b/icekit/plugins/links/abstract_models.py @@ -0,0 +1,98 @@ +from fluent_contents.extensions import ContentPlugin +from fluent_contents.models import ContentItem +from django.db import models +import appsettings +from icekit.fields import ICEkitURLField +from icekit.utils.admin.urls import admin_link +from icekit.utils.attributes import resolve + + +class AbstractLinkItem(ContentItem): + """ + A content type that is a relation to another model. + + Subclasses should define: + + item = models.ForeignKey(to) + + Assuming the 'to' model implements `ListableMixin` then the Item renders as + a list item. + """ + style = models.CharField("Link style", max_length=255, choices=appsettings.RELATION_STYLE_CHOICES, blank=True) + type_override = models.CharField(max_length=255, blank=True) + title_override = models.CharField(max_length=255, blank=True) + oneliner_override = models.CharField(max_length=255, blank=True) + url_override = ICEkitURLField(max_length=255, blank=True) + image_override = models.ImageField( + blank=True, + upload_to="icekit/listable/list_image/", + ) + + class Meta: + abstract = True + + def get_item(self): + "If the item is publishable, get the visible version" + if not hasattr(self, '_item'): + try: + self._item = self.item.get_published_or_draft() + except AttributeError: + # not publishable + self._item = self.item + return self._item + + def __unicode__(self): + return "Relation to '%s'" % unicode(self.item) + + def _resolve(self, attr): + return resolve(self.get_item(), attr) + + def get_type(self): + return self.type_override or self._resolve('get_type') + + def get_title(self): + return self.title_override or self._resolve('get_title') + + def get_list_image(self): + return self.image_override or self._resolve('get_list_image') + + def get_absolute_url(self): + return self.url_override or self._resolve('get_absolute_url') + + def get_oneliner(self): + return self.oneliner_override or self._resolve('get_oneliner') + + def admin_link(self): + return admin_link(self.item) + +class LinkPlugin(ContentPlugin): + category = 'Links' + raw_id_fields = ('item', ) + render_template = 'plugins/link/default.html' + fieldsets = ( + (None, { + 'fields': ( + ('item', 'admin_link',), + 'style', + ) + }), + ('Overrides', { + 'fields': ( + 'type_override', + 'title_override', + 'oneliner_override', + 'image_override', + 'url_override', + ), + 'classes': ('collapse', ) + }), + ) + readonly_fields = ('admin_link',) + + def render(self, request, instance, **kwargs): + """ + Only render the plugin if the item can be shown to the user + """ + if instance.get_item(): + return super(LinkPlugin, self).render(request, instance, **kwargs) + return "" \ No newline at end of file diff --git a/icekit/plugins/links/apps.py b/icekit/plugins/links/apps.py new file mode 100644 index 00000000..c4d2be72 --- /dev/null +++ b/icekit/plugins/links/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AppConfig(AppConfig): + name = '.'.join(__name__.split('.')[:-1]) + label = "ik_links" + verbose_name = 'Links' diff --git a/icekit/plugins/links/appsettings.py b/icekit/plugins/links/appsettings.py new file mode 100644 index 00000000..d2581f9a --- /dev/null +++ b/icekit/plugins/links/appsettings.py @@ -0,0 +1,5 @@ +from django.conf import settings + +ICEKIT = getattr(settings, 'ICEKIT', {}) + +RELATION_STYLE_CHOICES = ICEKIT.get('RELATION_STYLE_CHOICES', (("", "Normal"), )) \ No newline at end of file diff --git a/icekit/plugins/links/content_plugins.py b/icekit/plugins/links/content_plugins.py new file mode 100644 index 00000000..dfa908c7 --- /dev/null +++ b/icekit/plugins/links/content_plugins.py @@ -0,0 +1,19 @@ +from icekit.plugins.links.abstract_models import LinkPlugin +from . import models +from fluent_contents.extensions import plugin_pool + +@plugin_pool.register +class PageLinkPlugin(LinkPlugin): + model = models.PageLink + + +@plugin_pool.register +class ArticleLinkPlugin(LinkPlugin): + model = models.ArticleLink + + +@plugin_pool.register +class AuthorLinkPlugin(LinkPlugin): + model = models.AuthorLink + + diff --git a/icekit/plugins/links/migrations/0001_initial.py b/icekit/plugins/links/migrations/0001_initial.py new file mode 100644 index 00000000..79fbcce5 --- /dev/null +++ b/icekit/plugins/links/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fluent_contents', '0003_auto_20161021_1441'), + ('fluent_pages', '0001_initial'), + ('icekit_authors', '0003_auto_20161115_1118'), + ('icekit_article', '0005_add_hero'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleLink', + fields=[ + ('contentitem_ptr', models.OneToOneField(auto_created=True, serialize=False, to='fluent_contents.ContentItem', parent_link=True, primary_key=True)), + ('style', models.CharField(blank=True, max_length=255, verbose_name=b'Link style', choices=[(b'', b'Small'), (b'large', b'Large')])), + ('type_override', models.CharField(blank=True, max_length=255)), + ('title_override', models.CharField(blank=True, max_length=255)), + ('image_override', models.ImageField(blank=True, upload_to=b'icekit/listable/list_image/')), + ('item', models.ForeignKey(to='icekit_article.Article')), + ], + options={ + 'db_table': 'contentitem_ik_links_articlelink', + 'verbose_name': 'Article link', + }, + bases=('fluent_contents.contentitem',), + ), + migrations.CreateModel( + name='AuthorLink', + fields=[ + ('contentitem_ptr', models.OneToOneField(auto_created=True, serialize=False, to='fluent_contents.ContentItem', parent_link=True, primary_key=True)), + ('style', models.CharField(blank=True, max_length=255, verbose_name=b'Link style', choices=[(b'', b'Small'), (b'large', b'Large')])), + ('type_override', models.CharField(blank=True, max_length=255)), + ('title_override', models.CharField(blank=True, max_length=255)), + ('image_override', models.ImageField(blank=True, upload_to=b'icekit/listable/list_image/')), + ('item', models.ForeignKey(to='icekit_authors.Author')), + ], + options={ + 'db_table': 'contentitem_ik_links_authorlink', + 'verbose_name': 'Author link', + }, + bases=('fluent_contents.contentitem',), + ), + migrations.CreateModel( + name='PageLink', + fields=[ + ('contentitem_ptr', models.OneToOneField(auto_created=True, serialize=False, to='fluent_contents.ContentItem', parent_link=True, primary_key=True)), + ('style', models.CharField(blank=True, max_length=255, verbose_name=b'Link style', choices=[(b'', b'Small'), (b'large', b'Large')])), + ('type_override', models.CharField(blank=True, max_length=255)), + ('title_override', models.CharField(blank=True, max_length=255)), + ('image_override', models.ImageField(blank=True, upload_to=b'icekit/listable/list_image/')), + ('item', models.ForeignKey(to='fluent_pages.Page')), + ], + options={ + 'db_table': 'contentitem_ik_links_pagelink', + 'verbose_name': 'Page link', + }, + bases=('fluent_contents.contentitem',), + ), + ] diff --git a/icekit/plugins/links/migrations/0002_auto_20161117_1221.py b/icekit/plugins/links/migrations/0002_auto_20161117_1221.py new file mode 100644 index 00000000..3fcbcdce --- /dev/null +++ b/icekit/plugins/links/migrations/0002_auto_20161117_1221.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import icekit.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('ik_links', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='articlelink', + name='url_override', + field=icekit.fields.ICEkitURLField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='authorlink', + name='url_override', + field=icekit.fields.ICEkitURLField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='pagelink', + name='url_override', + field=icekit.fields.ICEkitURLField(blank=True, max_length=255), + ), + ] diff --git a/icekit/plugins/links/migrations/0003_auto_20161117_1810.py b/icekit/plugins/links/migrations/0003_auto_20161117_1810.py new file mode 100644 index 00000000..734fcfef --- /dev/null +++ b/icekit/plugins/links/migrations/0003_auto_20161117_1810.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ik_links', '0002_auto_20161117_1221'), + ] + + operations = [ + migrations.AddField( + model_name='articlelink', + name='oneliner_override', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='authorlink', + name='oneliner_override', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='pagelink', + name='oneliner_override', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/icekit/plugins/links/migrations/__init__.py b/icekit/plugins/links/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/icekit/plugins/links/models.py b/icekit/plugins/links/models.py new file mode 100644 index 00000000..280c97d8 --- /dev/null +++ b/icekit/plugins/links/models.py @@ -0,0 +1,28 @@ +from .abstract_models import AbstractLinkItem +from django.db import models + +"""Links to default ICEkit content types""" + +class PageLink(AbstractLinkItem): + item = models.ForeignKey("fluent_pages.Page") + + class Meta: + verbose_name = "Page link" + + +class ArticleLink(AbstractLinkItem): + item = models.ForeignKey("icekit_article.Article") + + class Meta: + verbose_name = "Article link" + + +class AuthorLink(AbstractLinkItem): + item = models.ForeignKey("icekit_authors.Author") + + class Meta: + verbose_name = "Author link" + + + + diff --git a/icekit/templates/icekit/plugins/link/default.html b/icekit/plugins/links/templates/plugins/link/default.html similarity index 100% rename from icekit/templates/icekit/plugins/link/default.html rename to icekit/plugins/links/templates/plugins/link/default.html diff --git a/icekit/plugins/text/appsettings.py b/icekit/plugins/text/appsettings.py index 9cb6aafb..13726db7 100644 --- a/icekit/plugins/text/appsettings.py +++ b/icekit/plugins/text/appsettings.py @@ -1,6 +1,3 @@ -""" -Settings for the text item. -""" from django.conf import settings FLUENT_TEXT_CLEAN_HTML = getattr(settings, "FLUENT_TEXT_CLEAN_HTML", False) diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index d253414e..35fd7ecf 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -460,18 +460,18 @@ DJANGO_WYSIWYG_FLAVOR = 'alloyeditor' DJANGO_WYSIWYG_MEDIA_URL = STATIC_URL + 'alloyeditor/dist/alloy-editor/' -_BASIC_PLUGINS = [ +BASIC_PLUGINS = [ 'RawHtmlPlugin', 'TextPlugin', 'HorizontalRulePlugin', ] -_TEXT_PLUGINS = [ +TEXT_PLUGINS = [ 'FAQPlugin', 'QuotePlugin', ] -_ASSETS_PLUGINS = [ +ASSETS_PLUGINS = [ 'SlideShowPlugin', 'ImagePlugin', 'ImageGalleryPlugin', @@ -480,7 +480,7 @@ 'ContactPersonPlugin', ] -_EMBED_PLUGINS = [ +EMBED_PLUGINS = [ 'IframePlugin', 'MapPlugin', 'MapWithTextPlugin', @@ -491,34 +491,33 @@ 'TwitterEmbedPlugin', ] -_NAVIGATION_PLUGINS = [ +NAVIGATION_PLUGINS = [ 'PageAnchorPlugin', 'PageAnchorListPlugin', 'ChildPagesPlugin', ] -_LINK_PLUGINS = [ +LINK_PLUGINS = [ 'ArticleLinkPlugin', + 'PageLinkPlugin', + 'AuthorLinkPlugin', ] DEFAULT_PLUGINS = \ - _BASIC_PLUGINS + \ - _TEXT_PLUGINS + \ - _ASSETS_PLUGINS + \ - _EMBED_PLUGINS + \ - _NAVIGATION_PLUGINS + \ - _LINK_PLUGINS + BASIC_PLUGINS + \ + TEXT_PLUGINS + \ + ASSETS_PLUGINS + \ + EMBED_PLUGINS + \ + NAVIGATION_PLUGINS + \ + LINK_PLUGINS FLUENT_CONTENTS_PLACEHOLDER_CONFIG = { 'main': { 'plugins': DEFAULT_PLUGINS, }, 'relations': { - 'plugins': _LINK_PLUGINS, + 'plugins': LINK_PLUGINS, } - # 'sidebar': { - # 'plugins': ('...', ), - # }, } FLUENT_DASHBOARD_DEFAULT_MODULE = 'ModelList' @@ -682,6 +681,7 @@ 'icekit.plugins.horizontal_rule', 'icekit.plugins.image', 'icekit.plugins.instagram_embed', + 'icekit.plugins.links', 'icekit.plugins.map', 'icekit.plugins.map_with_text', 'icekit.plugins.oembed_with_caption', diff --git a/icekit/project/settings/glamkit.py b/icekit/project/settings/glamkit.py index 16109f22..40214c51 100644 --- a/icekit/project/settings/glamkit.py +++ b/icekit/project/settings/glamkit.py @@ -8,6 +8,7 @@ 'icekit_events', 'icekit_events.event_types.simple', + 'icekit_events.plugins.links', 'icekit_events.page_types.eventlistingfordate', ) @@ -41,12 +42,20 @@ # GLAMKIT SPONSORS ############################################################ -_SPONSOR_PLUGINS = [ +SPONSOR_PLUGINS = [ 'BeginSponsorBlockPlugin', 'EndSponsorBlockPlugin', 'SponsorPromoPlugin', ] -DEFAULT_PLUGINS += _SPONSOR_PLUGINS + +LINK_PLUGINS += [ + 'WorkLinkPlugin', + 'CreatorLinkPlugin', + 'EventLinkPlugin', +] + +DEFAULT_PLUGINS += SPONSOR_PLUGINS + LINK_PLUGINS + # CONFIGURE PLACEHOLDERS ###################################################### @@ -58,4 +67,5 @@ 'TextPlugin', ), }, + 'relations': {'plugins': LINK_PLUGINS }, }) diff --git a/icekit/publishing/managers.py b/icekit/publishing/managers.py index c21be5fa..e1fb7e68 100644 --- a/icekit/publishing/managers.py +++ b/icekit/publishing/managers.py @@ -42,6 +42,7 @@ class DraftItemBoobyTrap(object): 'get_draft_payload', 'get_published', 'get_visible', + 'get_published_or_draft', # NOTE: `get_draft` is not included here to discourage getting a draft 'publishing_linked', 'publishing_linked_id', diff --git a/icekit/publishing/models.py b/icekit/publishing/models.py index de28caff..dbb698de 100644 --- a/icekit/publishing/models.py +++ b/icekit/publishing/models.py @@ -174,6 +174,21 @@ def get_visible(self): else: return self.get_published() + def get_published_or_draft(self): + """ + Return the published item, if it exists, otherwise, for privileged + users, return the draft version. + """ + if self.is_published: + return self + elif self.publishing_linked: + return self.publishing_linked + if is_draft_request_context(): + return self.get_draft() + # There is no public version, and there is no privilege to view the + # draft version + return None + def get_unique_together(self): return self._meta.unique_together diff --git a/icekit/utils/attributes.py b/icekit/utils/attributes.py index 93d8a62d..3c45a26b 100644 --- a/icekit/utils/attributes.py +++ b/icekit/utils/attributes.py @@ -3,8 +3,10 @@ def resolve(obj, attr): Resolves obj.attr to a value, calling it as a function if necessary. :param obj: :param attr: a string name of a property or function - :return: + :return: the result of the attribute, or None if object/attr not found """ + if obj is None: + return None attr = getattr(obj, attr, None) if callable(attr): return attr() From 1f4efefeb00946b9c63d2eb8c48126e289184dc0 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 10:49:18 +1100 Subject: [PATCH 081/184] Tweak migrations to try to get Travis tests passing. --- .../plugins/links/migrations/0001_initial.py | 2 +- .../migrations/0007_auto_20161118_1044.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 icekit/tests/migrations/0007_auto_20161118_1044.py diff --git a/icekit/plugins/links/migrations/0001_initial.py b/icekit/plugins/links/migrations/0001_initial.py index 79fbcce5..7a9fc15b 100644 --- a/icekit/plugins/links/migrations/0001_initial.py +++ b/icekit/plugins/links/migrations/0001_initial.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('fluent_contents', '0003_auto_20161021_1441'), + ('fluent_contents', '0001_initial'), ('fluent_pages', '0001_initial'), ('icekit_authors', '0003_auto_20161115_1118'), ('icekit_article', '0005_add_hero'), diff --git a/icekit/tests/migrations/0007_auto_20161118_1044.py b/icekit/tests/migrations/0007_auto_20161118_1044.py new file mode 100644 index 00000000..a2a87840 --- /dev/null +++ b/icekit/tests/migrations/0007_auto_20161118_1044.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0006_auto_20161115_1219'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='boosted_search_terms', + field=models.TextField(blank=True, help_text='Words (space-separated) added here are boosted in relevance for search results increasing the chance of this appearing higher in the search results.'), + ), + migrations.AddField( + model_name='article', + name='list_image', + field=models.ImageField(upload_to=b'icekit/listable/list_image/', blank=True, help_text=b"image to use in listings. Default image is used if this isn't given"), + ), + ] From 74fecb87943512ce0d30c60827f4449ad39df47e Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 15:05:37 +1100 Subject: [PATCH 082/184] More robust migration, and not installing Work/CreatorLink by default --- icekit/plugins/links/migrations/0001_initial.py | 2 +- icekit/project/settings/glamkit.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/icekit/plugins/links/migrations/0001_initial.py b/icekit/plugins/links/migrations/0001_initial.py index 7a9fc15b..e343afe6 100644 --- a/icekit/plugins/links/migrations/0001_initial.py +++ b/icekit/plugins/links/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): ('fluent_contents', '0001_initial'), ('fluent_pages', '0001_initial'), ('icekit_authors', '0003_auto_20161115_1118'), - ('icekit_article', '0005_add_hero'), + ('icekit_article', '0001_initial'), ] operations = [ diff --git a/icekit/project/settings/glamkit.py b/icekit/project/settings/glamkit.py index 40214c51..dabf2802 100644 --- a/icekit/project/settings/glamkit.py +++ b/icekit/project/settings/glamkit.py @@ -49,8 +49,6 @@ ] LINK_PLUGINS += [ - 'WorkLinkPlugin', - 'CreatorLinkPlugin', 'EventLinkPlugin', ] From 6f2b5a9e8153f333151b5fe72c696b7ced5c1189 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 16:08:18 +1100 Subject: [PATCH 083/184] Make add_missing_placeholders operate on any model with the attribute, and move the function up to LayoutFieldsMixin. --- .../commands/add_missing_placeholders.py | 2 +- icekit/mixins.py | 24 +++++++++---------- icekit/project/settings/_base.py | 2 +- icekit/project/settings/glamkit.py | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/icekit/management/commands/add_missing_placeholders.py b/icekit/management/commands/add_missing_placeholders.py index 0cac83d1..69c146de 100644 --- a/icekit/management/commands/add_missing_placeholders.py +++ b/icekit/management/commands/add_missing_placeholders.py @@ -11,7 +11,7 @@ class Command(NoArgsCommand): def handle_noargs(self, *args, **options): verbosity = int(options.get('verbosity')) for model in apps.get_models(): - if issubclass(model, FluentFieldsMixin): + if hasattr(model, 'add_missing_placeholders'): if verbosity: self.stdout.write('Adding placeholders for %s "%s"...' % ( model.objects.count(), diff --git a/icekit/mixins.py b/icekit/mixins.py index 69589a2b..f6b2196f 100644 --- a/icekit/mixins.py +++ b/icekit/mixins.py @@ -40,18 +40,6 @@ def get_layout_template_name(self): return self.layout.template_name return self.fallback_template - -class FluentFieldsMixin(LayoutFieldMixin): - """ - Add ``layout``, ``contentitem_set`` and ``placeholder_set`` fields so we - can add modular content with ``django-fluent-contents``. - """ - contentitem_set = ContentItemRelation() - placeholder_set = PlaceholderRelation() - - class Meta: - abstract = True - # HACK: This is needed to work-around a `django-fluent-contents` issue # where it cannot handle placeholders being added to a template after an # object already has placeholder data in the database. @@ -76,6 +64,18 @@ def add_missing_placeholders(self): result = result or created return result + +class FluentFieldsMixin(LayoutFieldMixin): + """ + Add ``layout``, ``contentitem_set`` and ``placeholder_set`` fields so we + can add modular content with ``django-fluent-contents``. + """ + contentitem_set = ContentItemRelation() + placeholder_set = PlaceholderRelation() + + class Meta: + abstract = True + def placeholders(self): # return a dict of placeholders, organised by slot, for access in # templates use `page.placeholders..get_content_items` to diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index 35fd7ecf..ac6d8cae 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -515,7 +515,7 @@ 'main': { 'plugins': DEFAULT_PLUGINS, }, - 'relations': { + 'related': { 'plugins': LINK_PLUGINS, } } diff --git a/icekit/project/settings/glamkit.py b/icekit/project/settings/glamkit.py index dabf2802..79ac080d 100644 --- a/icekit/project/settings/glamkit.py +++ b/icekit/project/settings/glamkit.py @@ -65,5 +65,5 @@ 'TextPlugin', ), }, - 'relations': {'plugins': LINK_PLUGINS }, + 'related': {'plugins': LINK_PLUGINS }, }) From abdf981eaa2e3ca461c0c740da988350e3b57f5b Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 16:33:33 +1100 Subject: [PATCH 084/184] Switching back to unpegged reqs in icekit develop as not updating it manually gets us more times than consequences of it not auto-updating. --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9726067f..2dd5c77b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -e .[api,brightcove,dev,django18,forms,glamkit,project,search,test] --e git+https://github.com/ic-labs/icekit-events.git@ca527#egg=icekit-events --e git+https://github.com/ic-labs/glamkit-collections.git@a61be#egg=glamkit-collections +-e git+https://github.com/ic-labs/icekit-events.git@develop#egg=icekit-events +-e git+https://github.com/ic-labs/glamkit-collections.git@develop#egg=glamkit-collections -e git+https://github.com/ixc/django-dynamic-fixture.git@ixc/master#egg=django-dynamic-fixture From 53ee7fd106f9649b19c07cf45a0b3c4a1371ff0e Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Fri, 18 Nov 2016 19:16:19 +1100 Subject: [PATCH 085/184] Test to see if new image gets made just from a comment --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 2dd5c77b..dfd17ac2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +# Add or remove a character to trick Docker into updating the image with the latest requirements. -e .[api,brightcove,dev,django18,forms,glamkit,project,search,test] -e git+https://github.com/ic-labs/icekit-events.git@develop#egg=icekit-events -e git+https://github.com/ic-labs/glamkit-collections.git@develop#egg=glamkit-collections From b5d89fd491e95e16da78734aa152910fda71bbff Mon Sep 17 00:00:00 2001 From: James Murty Date: Mon, 21 Nov 2016 15:47:41 +1100 Subject: [PATCH 086/184] Improve admin form for users without publish permission, re #155 General improvements so the admin UI shows links and capabilities already available to staff users: - show the "Preview Draft" link at top of form to any user who can preview items, not just those who can perform the publish action - show the "View Published" link at the top of form when an item is published whether or not a preview URL is also available - show "This version is newer than the published version" status text at bottom of form even when user cannot publish an item. Change summary: - add `has_preview_permission()` method to `PublishingAdmin` to determine who can see the shortcut "Preview Draft" link in the admin - pass more information about publishable models through to change form template when user does not have `can_publish` permission, so these users can at least see the preview link and up-to-date status text. --- icekit/publishing/admin.py | 47 ++++++++++++++----- .../publishing/publishing_change_form.html | 6 +-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/icekit/publishing/admin.py b/icekit/publishing/admin.py index d26b1dfb..31135396 100644 --- a/icekit/publishing/admin.py +++ b/icekit/publishing/admin.py @@ -226,11 +226,38 @@ def has_publish_permission(self, request, obj=None): :return: Boolean. """ user_obj = request.user + if not user_obj.is_active: + return False if user_obj.is_superuser: return True + return user_obj.has_perm('%s.can_publish' % self.opts.app_label) + + def has_preview_permission(self, request, obj=None): + """ + Return `True` if the user has permissions to preview a publishable + item. + + NOTE: this method does not actually change who can or cannot preview + any particular item, just whether to show the preview link. The real + dcision is made by a combination of: + + - `PublishingMiddleware` which chooses who can view draft content + - the view code for a particular item, which may or may not render + draft content for a specific user. + + :param request: Django request object. + :param obj: The object the user would preview, if permitted. + :return: Boolean. + """ + # User who can publish always has preview permission. + if self.has_publish_permission(request, obj=obj): + return True + user_obj = request.user if not user_obj.is_active: return False - return user_obj.has_perm('%s.can_publish' % self.opts.app_label) + if user_obj.is_staff: + return True + return False def publishing_column(self, obj): """ @@ -253,6 +280,8 @@ def publishing_column(self, obj): 'object_url': object_url, 'has_publish_permission': self.has_publish_permission(self.request, obj), + 'has_preview_permission': + self.has_preview_permission(self.request, obj), }) try: if isinstance(obj, PublishingModel): @@ -465,6 +494,12 @@ def render_change_form(self, request, context, add=False, change=False, """ obj = context.get('original', None) if obj: + context['object'] = obj + context['has_been_published'] = obj.has_been_published + context['is_dirty'] = obj.is_dirty + context['has_preview_permission'] = \ + self.has_preview_permission(request, obj) + if not self.has_publish_permission(request, obj): context['has_publish_permission'] = False else: @@ -491,12 +526,6 @@ def render_change_form(self, request, context, add=False, change=False, unpublish_btn = reverse( self.unpublish_reverse(type(obj)), args=(obj.pk, )) - # By default don't show the preview draft button unless there - # is a `get_absolute_url` definition on the object. - preview_draft_btn = None - if callable(getattr(obj, 'get_absolute_url', None)): - preview_draft_btn = True - # If the user has publishing permission, the object has draft # changes and a published version show a revert button to # change back to the published information. @@ -505,13 +534,9 @@ def render_change_form(self, request, context, add=False, change=False, revert_btn = reverse(self.revert_reverse, args=(obj.pk, )) context.update({ - 'object': obj, 'object_url': object_url, - 'is_dirty': obj.is_dirty, - 'has_been_published': obj.has_been_published, 'publish_btn': publish_btn, 'unpublish_btn': unpublish_btn, - 'preview_draft_btn': preview_draft_btn, 'revert_btn': revert_btn, }) diff --git a/icekit/publishing/templates/admin/publishing/publishing_change_form.html b/icekit/publishing/templates/admin/publishing/publishing_change_form.html index 72f53227..b5675a77 100644 --- a/icekit/publishing/templates/admin/publishing/publishing_change_form.html +++ b/icekit/publishing/templates/admin/publishing/publishing_change_form.html @@ -42,7 +42,6 @@ {{ block.super }} - {% if has_publish_permission or preview_draft_btn %}
    @@ -58,7 +57,6 @@
    - {% endif %} {% endblock %} @@ -83,10 +81,10 @@
  • {% with original.get_absolute_url as preview_url %} - {% if preview_draft_btn and preview_url %} + {% if preview_url and has_preview_permission %}
  • Preview draft
  • {% endif %} - {% if has_absolute_url and has_been_published %}
  • {% trans "View published" %}
  • {% endif %} {% endwith %} + {% if has_absolute_url and has_been_published %}
  • {% trans "View published" %}
  • {% endif %} {% endblock %} From dc3f8176110efa5f11b6a9251e2ebfd3bd389fcf Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Mon, 21 Nov 2016 23:58:20 +1100 Subject: [PATCH 087/184] Add tag which takes oembed URL and resolves it to OEmbed HTML. --- icekit/templatetags/icekit_tags.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/icekit/templatetags/icekit_tags.py b/icekit/templatetags/icekit_tags.py index a06efb72..facb917f 100644 --- a/icekit/templatetags/icekit_tags.py +++ b/icekit/templatetags/icekit_tags.py @@ -1,9 +1,14 @@ +import urlparse + import re from django import template +from django.conf import settings from django.http import QueryDict from django.template import Library from django.utils.encoding import force_text +from django.utils.safestring import mark_safe +from fluent_contents.plugins.oembeditem.backend import get_oembed_data register = Library() @@ -184,3 +189,18 @@ def render(self, context): GET.setlist(actual_attr, li) return fix_ampersands(GET.urlencode()) + + +@register.filter +def oembed(url, params=""): + kwargs = dict(urlparse.parse_qsl(params)) + + try: + return mark_safe(get_oembed_data( + url, + **kwargs + )['html']) + except KeyError: + if settings.DEBUG: + return "No OEmbed data returned" + return "" \ No newline at end of file From 1ebabcdff0a91ec10cdd601717d1a5dac02bae1c Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 10:55:18 +1100 Subject: [PATCH 088/184] Add new application to ICEKit for content workflow features, re #156 --- icekit/workflow/__init__.py | 0 icekit/workflow/apps.py | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 icekit/workflow/__init__.py create mode 100644 icekit/workflow/apps.py diff --git a/icekit/workflow/__init__.py b/icekit/workflow/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/icekit/workflow/apps.py b/icekit/workflow/apps.py new file mode 100644 index 00000000..a356d22e --- /dev/null +++ b/icekit/workflow/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AppConfig(AppConfig): + name = '.'.join(__name__.split('.')[:-1]) + label = 'icekit_workflow' + verbose_name = "Workflow" From 7e2e5ed2f4192c49c89eafc6df170c0e92b32a4a Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 10:58:42 +1100 Subject: [PATCH 089/184] Add listing column methods to show user who created/edited content, re #156 The new `WorkflowMixinAdmin` admin class includes methods to use in `list_display` definitions to show the user who first created an item or the user who last edited an item. Also included some corresponding documentation. NOTE: The model change tracking is based on Django admin's `LogEntry` mechanisms and is fairly simplistic: it will not track model changes performed outside the admin. --- docs/index.md | 2 ++ docs/topics/workflow.md | 22 ++++++++++++++++++++++ icekit/workflow/admin.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 docs/topics/workflow.md create mode 100644 icekit/workflow/admin.py diff --git a/docs/index.md b/docs/index.md index 5e1fe233..233a2dc9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ Add custom functionality to your site. * [Layouts] * [Placeholders] * [Publishing] +* [Workflow] * [Page trees and mptt] * [Articles] * [Writing portable apps] @@ -96,6 +97,7 @@ team at [the Interaction Consortium]: [labs@interaction.net.au](mailto:labs@inte [Articles]: topics/articles.md [Placeholders]: topics/placeholders.md [Publishing]: topics/publishing.md +[Workflow]: topics/workflow.md [Page trees and mptt]: topics/page-trees-and-mptt.md [Writing portable apps]: topics/portable-apps.md [Release notes]: changelog.md diff --git a/docs/topics/workflow.md b/docs/topics/workflow.md new file mode 100644 index 00000000..465078f0 --- /dev/null +++ b/docs/topics/workflow.md @@ -0,0 +1,22 @@ +# Workflow in ICEkit + +ICEKit includes a very simple workflow system to help manage content generation +and [publishing][]. + +## Workflow Admin + +The `icekit.workflow.admin.WorkflowMixinAdmin` provides convenient workflow-related +information and features for use in your Django admin classes. + +Admin list views columns you can add to `list_display`: + +* `created_by_column` renders the user who first created an item in the Django admin +* `last_edited_by_column` renders the user to last edited (added or changed) an + item in the admin. + +NOTE: The model change tracking is based on Django admin's `LogEntry` +mechanisms and is fairly simplistic: it will not track model changes performed +outside the admin. + + +[publishing]: publishing.md diff --git a/icekit/workflow/admin.py b/icekit/workflow/admin.py new file mode 100644 index 00000000..fefbe320 --- /dev/null +++ b/icekit/workflow/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin +from django.contrib.contenttypes.models import ContentType + + +class WorkflowMixinAdmin(admin.ModelAdmin): + + def created_by_column(self, obj): + """ Return user who first created an item in Django admin """ + first_addition_logentry = admin.models.LogEntry.objects.filter( + object_id=obj.pk, + content_type_id=ContentType.objects.get_for_model(obj).pk, + action_flag=admin.models.ADDITION, + ).get() + return first_addition_logentry.user + created_by_column.short_description = 'Created by' + + def last_edited_by_column(self, obj): + """ + Return user who last edited an item in Django admin, where "edited" + means either created (addition) or modified (change). + """ + latest_logentry = admin.models.LogEntry.objects.filter( + object_id=obj.pk, + content_type_id=ContentType.objects.get_for_model(obj).pk, + action_flag__in=[admin.models.ADDITION, admin.models.CHANGE], + ).first() + return latest_logentry.user + last_edited_by_column.short_description = 'Last edited by' From 307b792e1d472ec0df44bad13066ac020f91a186 Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 22 Nov 2016 11:48:53 +1100 Subject: [PATCH 090/184] Re #149 disabling cache backend on develop as interim workaround --- icekit/project/settings/_develop.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/icekit/project/settings/_develop.py b/icekit/project/settings/_develop.py index 0c60b513..eaf0cd31 100644 --- a/icekit/project/settings/_develop.py +++ b/icekit/project/settings/_develop.py @@ -6,10 +6,11 @@ ALLOWED_HOSTS = ('*', ) -CACHES['default'].update({ - 'BACKEND': 'redis_lock.django_cache.RedisCache', - 'LOCATION': 'redis://%s/1' % REDIS_ADDRESS, -}) +# Disabling develop cache backend as short-term workaround until #149 is resolved +# CACHES['default'].update({ +# 'BACKEND': 'redis_lock.django_cache.RedisCache', +# 'LOCATION': 'redis://%s/1' % REDIS_ADDRESS, +# }) CSRF_COOKIE_SECURE = False # Don't require HTTPS for CSRF cookie SESSION_COOKIE_SECURE = False # Don't require HTTPS for session cookie From 8fdf978f47a669d91eac79bfdbfa3280f52cd9a6 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 14:33:00 +1100 Subject: [PATCH 091/184] Add model to represent steps in a workflow, re #156 - add `WorkflowStep` model to capture the object a workflow applies to (via a Generic Foreign Key), the status of the step, and an optional assigned user - add a `WorkflowStepMixin` abstract model to simplify interacting with workflow-enabled models - initial DB migration for `WorkflowStep` model - add `workflow` app to default installed apps for ICEKit. --- icekit/project/settings/_base.py | 1 + icekit/workflow/migrations/0001_initial.py | 26 +++++++++++ icekit/workflow/migrations/__init__.py | 0 icekit/workflow/models.py | 53 ++++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 icekit/workflow/migrations/0001_initial.py create mode 100644 icekit/workflow/migrations/__init__.py create mode 100644 icekit/workflow/models.py diff --git a/icekit/project/settings/_base.py b/icekit/project/settings/_base.py index ac6d8cae..f46adec0 100644 --- a/icekit/project/settings/_base.py +++ b/icekit/project/settings/_base.py @@ -664,6 +664,7 @@ 'icekit.integration.reversion', 'icekit.layouts', 'icekit.publishing', + 'icekit.workflow', 'icekit.response_pages', 'icekit.content_collections', 'notifications', diff --git a/icekit/workflow/migrations/0001_initial.py b/icekit/workflow/migrations/0001_initial.py new file mode 100644 index 00000000..2f2cbbbd --- /dev/null +++ b/icekit/workflow/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WorkflowStep', + fields=[ + ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), + ('object_id', models.CharField(max_length=254)), + ('status', models.CharField(choices=[(b'', b''), (b'pending_review', b'Pending Review'), (b'approved', b'Approved')], max_length=254, default=b'')), + ('assigned_to', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, help_text=b'User responsible for item at this stage in the workflow')), + ('content_type', models.ForeignKey(to='contenttypes.ContentType')), + ], + ), + ] diff --git a/icekit/workflow/migrations/__init__.py b/icekit/workflow/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/icekit/workflow/models.py b/icekit/workflow/models.py new file mode 100644 index 00000000..e36c0b4b --- /dev/null +++ b/icekit/workflow/models.py @@ -0,0 +1,53 @@ +from django.db import models +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey, \ + GenericRelation +from django.contrib.contenttypes.models import ContentType + +from future.utils import python_2_unicode_compatible + + +WORKFLOW_STATUS_CHOICES = ( + # First item is the default + ('', ''), + ('pending_review', 'Pending Review'), + ('approved', 'Approved'), +) + + +@python_2_unicode_compatible +class WorkflowStep(models.Model): + # Generic Foreign Key fields to arbitrary object + content_type = models.ForeignKey(ContentType) + object_id = models.CharField(max_length=254) + content_object = GenericForeignKey( + 'content_type', + 'object_id', + # Permit references to proxy models + for_concrete_model=False, + ) + + status = models.CharField( + max_length=254, + choices=WORKFLOW_STATUS_CHOICES, + default=WORKFLOW_STATUS_CHOICES[0][0], + ) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + help_text='User responsible for item at this stage in the workflow', + ) + + def __str__(self): + return ' '.join([ + self.content_object, self.status, self.assigned_to + ]) + + +@python_2_unicode_compatible +class WorkflowStepMixin(models.Model): + workflow_steps = GenericRelation(WorkflowStep) + + class Meta: + abstract = True From b3ea8553b67435e1b27c36d2a2da9e260848a73d Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 15:35:01 +1100 Subject: [PATCH 092/184] Add documentation for the `WorkflowStep` model, re #156 Also tweaked the default text representation of a workflow item. --- docs/topics/workflow.md | 20 ++++++++++++++++++++ icekit/workflow/models.py | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/topics/workflow.md b/docs/topics/workflow.md index 465078f0..1e5a71e3 100644 --- a/docs/topics/workflow.md +++ b/docs/topics/workflow.md @@ -3,6 +3,26 @@ ICEKit includes a very simple workflow system to help manage content generation and [publishing][]. + +## Workflow Step + +A workflow step captures the current status of items within a workflow. Status +information includes: + + * a brief description of the status, such as "Pending Review" or "Approved" + * an optional user assignment, if a particular individual is responsible for + progressing the item through to the next step in the workflow. + +An item will generally have only a single workflow step assigned as it moves +through a workflow process, such as from "Pending Review" to "Approved". +However it is also possible to relate many workflow steps to an item to handle +branching workflows if necessary. + +The `icekit.workflow.models.WorkflowStep` model allows you to assign workflow +information to any model in the system via a generic foreign key (GFK) +relationship, and to store related workflow status information. + + ## Workflow Admin The `icekit.workflow.admin.WorkflowMixinAdmin` provides convenient workflow-related diff --git a/icekit/workflow/models.py b/icekit/workflow/models.py index e36c0b4b..06672df1 100644 --- a/icekit/workflow/models.py +++ b/icekit/workflow/models.py @@ -14,6 +14,8 @@ ('approved', 'Approved'), ) +WORKFLOW_STATUS_CHOICES_DICT = dict(WORKFLOW_STATUS_CHOICES) + @python_2_unicode_compatible class WorkflowStep(models.Model): @@ -41,7 +43,9 @@ class WorkflowStep(models.Model): def __str__(self): return ' '.join([ - self.content_object, self.status, self.assigned_to + WORKFLOW_STATUS_CHOICES_DICT[self.status], + 'by', + str(self.assigned_to), ]) From 22f98accec1cc333ed5c49662173aac74bb99eb2 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 15:38:36 +1100 Subject: [PATCH 093/184] Add support for managing workflow relationships in admin, re #156 - add `WorkflowStepTabularInline` to allow workflow step relationships to be added to items in the Django admin - customise form for inline to ensure only active staff Users are available for `assigned_to` relationships in the inline - add `workflow_steps_column` to render brief text descriptions of all workflow steps for an item in the admin listing page - related documentation. --- docs/topics/workflow.md | 9 ++++++++ icekit/workflow/admin.py | 48 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/topics/workflow.md b/docs/topics/workflow.md index 1e5a71e3..1deea4d2 100644 --- a/docs/topics/workflow.md +++ b/docs/topics/workflow.md @@ -25,11 +25,15 @@ relationship, and to store related workflow status information. ## Workflow Admin +### WorkflowMixinAdmin + The `icekit.workflow.admin.WorkflowMixinAdmin` provides convenient workflow-related information and features for use in your Django admin classes. Admin list views columns you can add to `list_display`: +* `workflow_steps_column` renders text descriptions of the workflow steps assigned + to an item * `created_by_column` renders the user who first created an item in the Django admin * `last_edited_by_column` renders the user to last edited (added or changed) an item in the admin. @@ -38,5 +42,10 @@ NOTE: The model change tracking is based on Django admin's `LogEntry` mechanisms and is fairly simplistic: it will not track model changes performed outside the admin. +### WorkflowStepTabularInline + +The `icekit.workflow.admin.WorkflowStepTabularInline` provides an inline for +assigning and managing workflow step relationships with items in the Django admin. + [publishing]: publishing.md diff --git a/icekit/workflow/admin.py b/icekit/workflow/admin.py index fefbe320..603e4e2b 100644 --- a/icekit/workflow/admin.py +++ b/icekit/workflow/admin.py @@ -1,14 +1,58 @@ from django.contrib import admin +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.admin import GenericTabularInline +from django import forms + +from . import models + + +class WorkflowStepForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super(WorkflowStepForm, self).__init__(*args, **kwargs) + # Limit user choices for `assigned_to` field to active staff users + User = get_user_model() + staff_user_pks = [ + u.pk for u in User.objects.filter(is_active=True, is_staff=True)] + self.fields['assigned_to'].choices = \ + [choice for choice in self.fields['assigned_to'].choices + if not choice[0] or choice[0] in staff_user_pks] + + +class WorkflowStepTabularInline(GenericTabularInline): + model = models.WorkflowStep + form = WorkflowStepForm + + # Permit only a sinlge workflow step relationships now for simplicity + extra = 0 + min_num = 1 + max_num = 1 + can_delete = False class WorkflowMixinAdmin(admin.ModelAdmin): + def _get_obj_ct(self, obj): + """ Look up and return object's content type and cache for reuse """ + if not hasattr(obj, '_wfct'): + obj._wfct = ContentType.objects.get_for_model(obj) + return obj._wfct + + def workflow_steps_column(self, obj): + """ Return text description of workflow steps assigned to object """ + workflow_steps = models.WorkflowStep.objects.filter( + content_type=self._get_obj_ct(obj), + object_id=obj.pk, + ) + return ', '.join([unicode(wfs) for wfs in workflow_steps]) + workflow_steps_column.short_description = 'Workflow Steps' + def created_by_column(self, obj): """ Return user who first created an item in Django admin """ first_addition_logentry = admin.models.LogEntry.objects.filter( object_id=obj.pk, - content_type_id=ContentType.objects.get_for_model(obj).pk, + content_type_id=self._get_obj_ct(obj).pk, action_flag=admin.models.ADDITION, ).get() return first_addition_logentry.user @@ -21,7 +65,7 @@ def last_edited_by_column(self, obj): """ latest_logentry = admin.models.LogEntry.objects.filter( object_id=obj.pk, - content_type_id=ContentType.objects.get_for_model(obj).pk, + content_type_id=self._get_obj_ct(obj).pk, action_flag__in=[admin.models.ADDITION, admin.models.CHANGE], ).first() return latest_logentry.user From 4e76a05e32aa92ed00f91af867fa20cfb1ef0c4d Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 22 Nov 2016 16:07:41 +1100 Subject: [PATCH 094/184] WIP factoring out search indexes --- icekit/mixins.py | 12 +++- icekit/page_types/author/search_indexes.py | 66 +++++++++---------- .../page_types/layout_page/search_indexes.py | 3 +- .../search_page/page_type_plugins.py | 8 +-- .../page_types/search_page/default.html | 2 +- icekit/search_indexes.py | 17 +++++ .../search/indexes/icekit/default.txt | 26 ++++++++ icekit/templatetags/search_tags.py | 34 ++++++++++ icekit/utils/search.py | 51 +++++++------- 9 files changed, 155 insertions(+), 64 deletions(-) create mode 100644 icekit/search_indexes.py create mode 100644 icekit/templates/search/indexes/icekit/default.txt diff --git a/icekit/mixins.py b/icekit/mixins.py index 807fc999..4b50570e 100644 --- a/icekit/mixins.py +++ b/icekit/mixins.py @@ -153,6 +153,10 @@ def get_title(self): def get_list_image(self): """ :return: the ImageField to use for thumbnails in lists + + NB note that the Image Field is returned, not the ICEkit Image model as + with get_hero_image (since the override is just a field and we don't + need alt text), not Image record. """ list_image = first_of( self, @@ -160,8 +164,14 @@ def get_list_image(self): 'get_hero_image', 'image', ) - return list_image + # return the `image` attribute (being the ImageField of the Image + # model) if there is one. + return getattr(list_image, "image", list_image) + + + def get_boosted_search_terms(self): + return self.boosted_search_terms class HeroMixin(models.Model): """ diff --git a/icekit/page_types/author/search_indexes.py b/icekit/page_types/author/search_indexes.py index c99732b0..3806f4cd 100644 --- a/icekit/page_types/author/search_indexes.py +++ b/icekit/page_types/author/search_indexes.py @@ -1,33 +1,33 @@ -from haystack import indexes - -from django.conf import settings -from django.utils import translation - -from . import models - - -class AuthorIndex(indexes.SearchIndex, indexes.Indexable): - """ - Search index for `Author`. - """ - text = indexes.CharField(document=True, use_template=True) - name = indexes.CharField(model_attr='title', boost=2.0) - url = indexes.CharField(model_attr='get_absolute_url') - has_url = indexes.BooleanField(model_attr='get_absolute_url') - # We add this for autocomplete. - content_auto = indexes.EdgeNgramField(model_attr='title') - - def index_queryset(self, using=None): - """ Index only published authors """ - # TODO Hack to activate the site language if none is yet active, to - # avoid complaints about null language_code when traversing the - # `parent` relationship -- should probably do this elsewhere? - if not translation.get_language(): - translation.activate(settings.LANGUAGE_CODE) - return self.get_model().objects.published() - - def get_model(self): - """ - Get the model for the search index. - """ - return models.Author +# from haystack import indexes +# +# from django.conf import settings +# from django.utils import translation +# +# from . import models +# +# +# class AuthorIndex(indexes.SearchIndex, indexes.Indexable): +# """ +# Search index for `Author`. +# """ +# text = indexes.CharField(document=True, use_template=True) +# name = indexes.CharField(model_attr='title', boost=2.0) +# url = indexes.CharField(model_attr='get_absolute_url') +# has_url = indexes.BooleanField(model_attr='get_absolute_url') +# # We add this for autocomplete. +# content_auto = indexes.EdgeNgramField(model_attr='title') +# +# def index_queryset(self, using=None): +# """ Index only published authors """ +# # TODO Hack to activate the site language if none is yet active, to +# # avoid complaints about null language_code when traversing the +# # `parent` relationship -- should probably do this elsewhere? +# if not translation.get_language(): +# translation.activate(settings.LANGUAGE_CODE) +# return self.get_model().objects.published() +# +# def get_model(self): +# """ +# Get the model for the search index. +# """ +# return models.Author diff --git a/icekit/page_types/layout_page/search_indexes.py b/icekit/page_types/layout_page/search_indexes.py index 51337922..7bf4cc92 100644 --- a/icekit/page_types/layout_page/search_indexes.py +++ b/icekit/page_types/layout_page/search_indexes.py @@ -1,11 +1,12 @@ from haystack import indexes +from icekit.search_indexes import AbstractLayoutIndex from icekit.utils.search import FluentContentsPageIndexMixin from . import models -class LayoutPageIndex(indexes.Indexable, FluentContentsPageIndexMixin): +class PageIndex(AbstractLayoutIndex): def get_model(self): return models.LayoutPage diff --git a/icekit/page_types/search_page/page_type_plugins.py b/icekit/page_types/search_page/page_type_plugins.py index 569dbf7e..f7e79b5a 100644 --- a/icekit/page_types/search_page/page_type_plugins.py +++ b/icekit/page_types/search_page/page_type_plugins.py @@ -4,7 +4,7 @@ from haystack.views import SearchView from icekit.plugins import ICEkitFluentContentsPagePlugin -from icekit.utils.search import FluentContentsPageModelSearchForm +from icekit.utils.search import ICEkitSearchForm from . import admin, models @@ -18,11 +18,11 @@ def __init__(self, *args, **kwargs): features. """ super(FluentSearchView, self).__init__(*args, **kwargs) - self.form_class = FluentContentsPageModelSearchForm + self.form_class = ICEkitSearchForm def extra_context(self): return { - 'instance': UrlNode.objects.get_for_path(self.request.path) + 'page': UrlNode.objects.get_for_path(self.request.path) } @@ -39,6 +39,6 @@ class SearchPagePlugin(ICEkitFluentContentsPagePlugin): FluentSearchView( template='icekit/page_types/search_page/default.html', ), - name='haystack_search' + name='search' ), ) diff --git a/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html b/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html index acd51968..c951d384 100644 --- a/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html +++ b/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html @@ -7,7 +7,7 @@

    Search

    - {% page_placeholder instance 'main' title='Main' role='m' fallback=True %} + {% page_placeholder page 'main' title='Main' role='m' fallback=True %}
    {% bootstrap_form form %} diff --git a/icekit/search_indexes.py b/icekit/search_indexes.py new file mode 100644 index 00000000..348dd4b7 --- /dev/null +++ b/icekit/search_indexes.py @@ -0,0 +1,17 @@ +from django.conf import settings +from django.utils import translation +from fluent_pages.models import Page +from haystack import indexes +from fluent_pages.models.db import UrlNode +from icekit.utils.search import AbstractLayoutIndex + + +class PageIndex(AbstractLayoutIndex, indexes.Indexable): + """Index all Fluent Pages""" + + def get_model(self): + return Page + + def index_queryset(self, using=None): + translation.activate(settings.LANGUAGE_CODE) + return self.get_model().objects.filter(status=UrlNode.PUBLISHED).select_related() diff --git a/icekit/templates/search/indexes/icekit/default.txt b/icekit/templates/search/indexes/icekit/default.txt new file mode 100644 index 00000000..14136d2e --- /dev/null +++ b/icekit/templates/search/indexes/icekit/default.txt @@ -0,0 +1,26 @@ +{% load fluent_contents_tags search_tags %} +{# search index for ListableMixin models #} + +{% filter striptags %} + {% filter unescape %} + {% block text %} + {{ object.get_type }} + {{ object.get_title }} + {{ object.get_subtitle }} + {{ object.get_oneliner }} + + {{ object.get_absolute_url }} + {{ object.slug }} {{ object.pk }} + + {% fake_request %}{# render_placeholder requires a request #} + + {% for placeholder in object.placeholder_set.all %} + {% render_placeholder placeholder %} + {% endfor %} + + {{ object.boosted_search_terms }}{# may not be necessary if this is included/boosted in the query #} + {% endblock %} + {% endfilter %} +{% endfilter %} + +{% block unfiltered_text %}{% endblock %} \ No newline at end of file diff --git a/icekit/templatetags/search_tags.py b/icekit/templatetags/search_tags.py index 788d19af..9461ca56 100644 --- a/icekit/templatetags/search_tags.py +++ b/icekit/templatetags/search_tags.py @@ -1,3 +1,7 @@ +import htmlentitydefs + +import re + from django.contrib.auth.models import AnonymousUser from django.template import Library, Node from django.test.client import RequestFactory @@ -23,3 +27,33 @@ def fake_request(parser, token): Create a fake request object in the context """ return FakeRequestNode() + + +@register.filter +def unescape(text): + """ + Removes HTML or XML character references and entities from a text string. + + :param text: The HTML (or XML) source text. + :return: The plain text, as a Unicode string, if necessary. + """ + def fixup(m): + text = m.group(0) + if text[:2] == "&#": + # character reference + try: + if text[:3] == "&#x": + return unichr(int(text[3:-1], 16)) + else: + return unichr(int(text[2:-1])) + except ValueError: + pass + else: + # named entity + try: + text = unichr(htmlentitydefs.name2codepoint[text[1:-1]]) + except KeyError: + pass + return text # leave as is + + return re.sub("&#?\w+;", fixup, text) \ No newline at end of file diff --git a/icekit/utils/search.py b/icekit/utils/search.py index de9abe56..1a26593b 100644 --- a/icekit/utils/search.py +++ b/icekit/utils/search.py @@ -2,57 +2,60 @@ from haystack.backends import SQ from haystack.inputs import AutoQuery from haystack.forms import ModelSearchForm - -from icekit.publishing.models import PublishableFluentContentsPage +from icekit.mixins import LayoutFieldMixin # Doesn't extend `indexes.Indexable` to avoid auto-detection for 'Search In' -class FluentContentsPageIndexMixin(indexes.SearchIndex): +class AbstractLayoutIndex(indexes.SearchIndex): """ - Base search index class for a publishable fluent contents page. + A search index for a publishable model that implements ListableMixin and + LayoutFieldMixin. + + Subclasses will need to mix in `indexes.Indexable` and implement + `get_model(self)`. They may need to override the `text` field to specify + a different template name. Derived classes must override the `get_model()` method to return the specific class (not an instance) that the search index will use. """ - text = indexes.CharField(document=True, use_template=True) - title = indexes.CharField(model_attr='title', boost=2.0) - slug = indexes.CharField(model_attr='slug') + # Content + text = indexes.CharField(document=True, use_template=True, template_name="search/indexes/icekit/default.txt") + type = indexes.CharField(model_attr='get_type') + title = indexes.CharField(model_attr='get_title', boost=2.0) + oneliner = indexes.CharField(model_attr='get_oneliner') + boosted_search_terms = indexes.CharField(model_attr="get_boosted_search_terms", boost=2.0, null=True) + + # Meta url = indexes.CharField(model_attr='get_absolute_url') - author = indexes.CharField() + image = indexes.CharField(model_attr='get_list_image') #TODO: URL modification_date = indexes.DateTimeField(model_attr='modification_date') - language_code = indexes.CharField(model_attr='language_code') - boosted_search_terms = indexes.CharField(boost=2.0, null=True) - # SEO Translations meta_keywords = indexes.CharField(model_attr='meta_keywords') meta_description = indexes.CharField(model_attr='meta_description') meta_title = indexes.CharField(model_attr='meta_title') - def get_model(self): - """ - Get the model for the search index. - """ - return PublishableFluentContentsPage + # We add this for autocomplete. + content_auto = indexes.EdgeNgramField(model_attr='get_title') def index_queryset(self, using=None): """ - Index current language translation of published pages. + Index current language translation of published objects. TODO: Find a way to index all translations of the given model, not just the current site language's translation. """ - return self.get_model().objects.published().language() - - def prepare_author(self, obj): - return obj.author.get_full_name() + return self.get_model.objects.published().language() - def prepare_boosted_search_terms(self, obj): - return getattr(obj, 'boosted_search_terms', '') + def get_model(self): + """ + Get the model for the search index. + """ + return LayoutFieldMixin -class FluentContentsPageModelSearchForm(ModelSearchForm): +class ICEkitSearchForm(ModelSearchForm): """ Custom search form to use the indexed fields defined above """ def get_searchqueryset(self, query): From 190b5c69e2595f5f466bb6f4822821b2abd14650 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 16:07:56 +1100 Subject: [PATCH 095/184] Require integer IDs in objects relatable to workflow steps, re #156 For the `object_id` Generic Foreign Key field we were using a `CharField` to allow for target models that don't have an integer primary key ID, which would be an unusual case but is possible. However this extra flexibility breaks querying over generic relationships in Postgres 9+ due to the type mismatch between the actual integer IDs and the character value of `object_id`. Such querying should work but doesn't, see the unresolved bug report https://code.djangoproject.com/ticket/16055 Supporting non-integer PKs on target objects would be nice, but not nice enough to warrant the extra work this would require right now (if it is possible at all). --- icekit/workflow/migrations/0001_initial.py | 2 +- icekit/workflow/models.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/icekit/workflow/migrations/0001_initial.py b/icekit/workflow/migrations/0001_initial.py index 2f2cbbbd..f7d460b3 100644 --- a/icekit/workflow/migrations/0001_initial.py +++ b/icekit/workflow/migrations/0001_initial.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): name='WorkflowStep', fields=[ ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('object_id', models.CharField(max_length=254)), + ('object_id', models.PositiveIntegerField()), ('status', models.CharField(choices=[(b'', b''), (b'pending_review', b'Pending Review'), (b'approved', b'Approved')], max_length=254, default=b'')), ('assigned_to', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, help_text=b'User responsible for item at this stage in the workflow')), ('content_type', models.ForeignKey(to='contenttypes.ContentType')), diff --git a/icekit/workflow/models.py b/icekit/workflow/models.py index 06672df1..6ad8095b 100644 --- a/icekit/workflow/models.py +++ b/icekit/workflow/models.py @@ -21,7 +21,7 @@ class WorkflowStep(models.Model): # Generic Foreign Key fields to arbitrary object content_type = models.ForeignKey(ContentType) - object_id = models.CharField(max_length=254) + object_id = models.PositiveIntegerField() content_object = GenericForeignKey( 'content_type', 'object_id', @@ -49,7 +49,6 @@ def __str__(self): ]) -@python_2_unicode_compatible class WorkflowStepMixin(models.Model): workflow_steps = GenericRelation(WorkflowStep) From 23d2a208686c27d0e3c9aba6688f265dae92ffe6 Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 22 Nov 2016 16:48:17 +1100 Subject: [PATCH 096/184] Set default settings for admin listing and filtering, re #156 Make it easy to add workflow features to the admin by referring to `WorkflowMixinAdmin.list_display` and `WorkflowMixinAdmin.list_filter` in admin classes. Also documented list filters, and the requirement that target models extend `WorkflowStepMixin` or provide an equivalent generic relationship field. --- docs/topics/workflow.md | 33 ++++++++++++++++++++++++++++----- icekit/workflow/admin.py | 4 ++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/topics/workflow.md b/docs/topics/workflow.md index 1deea4d2..ab197811 100644 --- a/docs/topics/workflow.md +++ b/docs/topics/workflow.md @@ -22,6 +22,19 @@ The `icekit.workflow.models.WorkflowStep` model allows you to assign workflow information to any model in the system via a generic foreign key (GFK) relationship, and to store related workflow status information. +### Reverse model relationships + +To make the relationships between workflow steps and a target object navigable +in reverse, the target object class should either: + + * extend from the helper abstract mixin model class + `icekit.workflow.models.WorkflowStepMixin`, or + * add an equivalent `GenericRelation` relationships attribute to the model. + +Once the reverse relationship is made navigable in this way you can look up the +workflow steps associated with an item more easily using the `workflow_steps` +relationship attribute. + ## Workflow Admin @@ -32,16 +45,26 @@ information and features for use in your Django admin classes. Admin list views columns you can add to `list_display`: -* `workflow_steps_column` renders text descriptions of the workflow steps assigned - to an item -* `created_by_column` renders the user who first created an item in the Django admin -* `last_edited_by_column` renders the user to last edited (added or changed) an - item in the admin. + * `workflow_steps_column` renders text descriptions of the workflow steps assigned + to an item + * `created_by_column` renders the user who first created an item in the Django admin + * `last_edited_by_column` renders the user to last edited (added or changed) an + item in the admin. NOTE: The model change tracking is based on Django admin's `LogEntry` mechanisms and is fairly simplistic: it will not track model changes performed outside the admin. +Admin filter attributes you can add to `list_filter`: + + * `workflow_steps__status` to show only items related to a workflow step with + the given status, such as "Approved" + * `workflow_steps__assigned_to` to show only items assigned to a user. + +NOTE: For these admin filters to work the relevant model must implement a +`GenericRelation` field named `workflow_steps`, which is easiest to do by +extending from `WorkflowStepMixin` as described above. + ### WorkflowStepTabularInline The `icekit.workflow.admin.WorkflowStepTabularInline` provides an inline for diff --git a/icekit/workflow/admin.py b/icekit/workflow/admin.py index 603e4e2b..72053159 100644 --- a/icekit/workflow/admin.py +++ b/icekit/workflow/admin.py @@ -32,6 +32,10 @@ class WorkflowStepTabularInline(GenericTabularInline): class WorkflowMixinAdmin(admin.ModelAdmin): + list_display = ( + "created_by_column", "last_edited_by_column", "workflow_steps_column") + list_filter = ( + "workflow_steps__status", "workflow_steps__assigned_to") def _get_obj_ct(self, obj): """ Look up and return object's content type and cache for reuse """ From d73cdaeb6c47f8fed54810aaa8850d8d539cc52b Mon Sep 17 00:00:00 2001 From: Greg Turner Date: Tue, 22 Nov 2016 20:59:34 +1100 Subject: [PATCH 097/184] Adapting search index to be ListableMixin compatible --- .../search_page/page_type_plugins.py | 13 +++++-- .../page_types/search_page/default.html | 24 +++++++----- icekit/utils/search.py | 38 +++++++++++++------ 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/icekit/page_types/search_page/page_type_plugins.py b/icekit/page_types/search_page/page_type_plugins.py index f7e79b5a..95f84683 100644 --- a/icekit/page_types/search_page/page_type_plugins.py +++ b/icekit/page_types/search_page/page_type_plugins.py @@ -20,11 +20,16 @@ def __init__(self, *args, **kwargs): super(FluentSearchView, self).__init__(*args, **kwargs) self.form_class = ICEkitSearchForm - def extra_context(self): - return { - 'page': UrlNode.objects.get_for_path(self.request.path) - } + def get_context(self): + """ + Inject 'page' into the context. Because haystack already returns a + 'page' context, rename that one to 'results_page' + """ + context = super(FluentSearchView, self).get_context() + context['results_page'] = context['page'] + context['page'] = UrlNode.objects.get_for_path(self.request.path) + return context # Register this plugin to the page plugin pool. @page_type_pool.register diff --git a/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html b/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html index c951d384..0e095d4f 100644 --- a/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html +++ b/icekit/page_types/search_page/templates/icekit/page_types/search_page/default.html @@ -18,32 +18,36 @@

    Search

    - {% for result in page.object_list %} + {% for result in results_page.object_list %}

    - {{ result.object.title }} + + {% if result.get_type %}{{ result.get_type }}:{% endif %} + {{ result.get_title }} + {% if result.get_list_image_url %}{% endif %} +

    {% empty %}

    No results found.

    {% endfor %} - {% if page.has_previous or page.has_next %} + {% if results_page.has_previous or results_page.has_next %}