diff --git a/dispatch/api/fields.py b/dispatch/api/fields.py index e77cb1753..561332680 100644 --- a/dispatch/api/fields.py +++ b/dispatch/api/fields.py @@ -4,15 +4,28 @@ from rest_framework.exceptions import ValidationError class JSONField(Field): - def to_internal_value(self, data): return data def to_representation(self, value): return value -class PrimaryKeyField(Field): +class NullBooleanField(Field): + """Forces a Django NullBooleanField to always return False for null values.""" + + def get_attribute(self, instance): + """Overrides the default get_attribute method to convert None values to False.""" + attr = super(NullBooleanField, self).get_attribute(instance) + return True if attr else False + + def to_internal_value(self, data): + return True if data else None + + def to_representation(self, value): + return True if value else False + +class PrimaryKeyField(Field): def __init__(self, serializer, *args, **kwargs): super(PrimaryKeyField, self).__init__(*args, **kwargs) diff --git a/dispatch/api/serializers.py b/dispatch/api/serializers.py index 5c1f63680..371a61b21 100644 --- a/dispatch/api/serializers.py +++ b/dispatch/api/serializers.py @@ -18,7 +18,7 @@ from dispatch.api.validators import ( FilenameValidator, ImageGalleryValidator, PasswordValidator, SlugValidator, AuthorValidator, TemplateValidator) -from dispatch.api.fields import JSONField, PrimaryKeyField, ForeignKeyField +from dispatch.api.fields import JSONField, NullBooleanField, PrimaryKeyField, ForeignKeyField class PersonSerializer(DispatchModelSerializer): """Serializes the Person model.""" @@ -608,6 +608,8 @@ class ArticleSerializer(DispatchModelSerializer, DispatchPublishableSerializer): id = serializers.ReadOnlyField(source='parent_id') slug = serializers.SlugField(validators=[SlugValidator()]) + is_published = NullBooleanField(read_only=True) + section = SectionSerializer(read_only=True) section_id = serializers.IntegerField(write_only=True) @@ -770,6 +772,8 @@ class PageSerializer(DispatchModelSerializer, DispatchPublishableSerializer): id = serializers.ReadOnlyField(source='parent_id') slug = serializers.SlugField(validators=[SlugValidator()]) + is_published = NullBooleanField(read_only=True) + featured_image = ImageAttachmentSerializer(required=False, allow_null=True) featured_video = VideoAttachmentSerializer(required=False, allow_null=True) diff --git a/dispatch/migrations/0022_publishable_constraints.py b/dispatch/migrations/0022_publishable_constraints.py new file mode 100644 index 000000000..f1219d7da --- /dev/null +++ b/dispatch/migrations/0022_publishable_constraints.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2018-10-30 18:59 +from __future__ import unicode_literals + +from django.db import migrations, models + +from dispatch.models import Article, Page + +def fix_latest_head(model, item): + latest = model.objects. \ + filter(parent=item.parent). \ + order_by('-id')[0] + + model.objects. \ + filter(parent=item.parent). \ + update(head=None) + + latest.head = True + latest.save(revision=False) + + duplicates = model.objects. \ + filter(slug=item.slug). \ + exclude(parent=item.parent). \ + delete() + +def fix_latest_published(model, item): + latest_items = model.objects. \ + filter(parent_id=item.parent_id, is_published=True). \ + order_by('-id') + + if not latest_items: + return + + model.objects. \ + filter(parent=item.parent). \ + update(is_published=None) + + latest = latest_items[0] + latest.is_published = True + latest.save(revision=False) + +def fix_items(model): + seen = set() + for item in model.objects.order_by('-id'): + if item.slug in seen: + continue + + fix_latest_head(model, item) + fix_latest_published(model, item) + + seen.add(item.slug) + +def remove_duplicates(apps, schema_editor): + fix_items(Article) + fix_items(Page) + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatch', '0021_podcast_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='article', + name='head', + field=models.NullBooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='article', + name='is_published', + field=models.NullBooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='page', + name='head', + field=models.NullBooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='page', + name='is_published', + field=models.NullBooleanField(db_index=True, default=False), + ), + migrations.RunPython(remove_duplicates), + migrations.AlterUniqueTogether( + name='article', + unique_together=set([('parent', 'slug', 'is_published'), ('slug', 'head'), ('parent', 'slug', 'head')]), + ), + migrations.AlterUniqueTogether( + name='page', + unique_together=set([('parent', 'slug', 'is_published'), ('slug', 'head'), ('parent', 'slug', 'head')]), + ), + ] diff --git a/dispatch/modules/content/models.py b/dispatch/modules/content/models.py index fe262512c..88c473383 100644 --- a/dispatch/modules/content/models.py +++ b/dispatch/modules/content/models.py @@ -12,8 +12,8 @@ from django.db.models import ( Model, DateTimeField, CharField, TextField, PositiveIntegerField, - ImageField, FileField, BooleanField, UUIDField, ForeignKey, - ManyToManyField, SlugField, SET_NULL, CASCADE, F) + ImageField, FileField, BooleanField, NullBooleanField, UUIDField, + ForeignKey, ManyToManyField, SlugField, SET_NULL, CASCADE, F) from django.conf import settings from django.core.validators import MaxValueValidator from django.utils import timezone @@ -70,10 +70,10 @@ class Publishable(Model): preview_id = UUIDField(default=uuid.uuid4) revision_id = PositiveIntegerField(default=0, db_index=True) - head = BooleanField(default=False, db_index=True) + head = NullBooleanField(default=None, db_index=True, null=True) - is_published = BooleanField(default=False, db_index=True) - is_active = BooleanField(default=True) + is_published = NullBooleanField(default=None, db_index=True) + is_active = NullBooleanField(default=True) published_version = PositiveIntegerField(null=True) latest_version = PositiveIntegerField(null=True) @@ -143,7 +143,7 @@ def is_parent(self): def publish(self): # Unpublish last published version - type(self).objects.filter(parent=self.parent, is_published=True).update(is_published=False, published_at=None) + type(self).objects.filter(parent=self.parent, is_published=True).update(is_published=None, published_at=None) self.is_published = True if self.published_at is None: self.published_at = timezone.now() @@ -156,8 +156,8 @@ def publish(self): return self def unpublish(self): - type(self).objects.filter(parent=self.parent, is_published=True).update(is_published=False, published_at=None) - self.is_published = False + type(self).objects.filter(parent=self.parent, is_published=True).update(is_published=None, published_at=None) + self.is_published = None # Unset published version for all articles type(self).objects.filter(parent=self.parent).update(published_version=None) @@ -184,7 +184,9 @@ def save(self, revision=True, *args, **kwargs): if not self.is_parent(): # If this is a revision, delete the old head of the list. - type(self).objects.filter(parent=self.parent, head=True).update(head=False) + type(self).objects \ + .filter(parent=self.parent, head=True) \ + .update(head=None) # Clear the instance id to force Django to save a new instance. # Both fields (pk, id) required for this to work -- something to do with model inheritance @@ -192,11 +194,7 @@ def save(self, revision=True, *args, **kwargs): self.id = None # New version is unpublished by default - self.is_published = False - - # Raise integrity error if instance with given slug already exists. - if type(self).objects.filter(slug=self.slug).exclude(parent=self.parent).exists(): - raise IntegrityError("%s with slug '%s' already exists." % (type(self).__name__, self.slug)) + self.is_published = None # Set created_at to current time, but only for first version if not self.created_at: @@ -206,14 +204,6 @@ def save(self, revision=True, *args, **kwargs): if revision: self.updated_at = timezone.now() - # Check that there is only one 'head' - if self.is_conflicting_head(): - raise IntegrityError("%s with head=True already exists." % (type(self).__name__,)) - - # Check that there is only one version with this revision_id - if self.is_conflicting_revision_id(): - raise IntegrityError("%s with revision_id=%s already exists." % (self.revision_id, type(self).__name__)) - super(Publishable, self).save(*args, **kwargs) # Update the parent foreign key @@ -223,7 +213,10 @@ def save(self, revision=True, *args, **kwargs): if revision: # Set latest version for all articles - type(self).objects.filter(parent=self.parent).update(latest_version=self.revision_id) + type(self).objects \ + .filter(parent=self.parent) \ + .update(latest_version=self.revision_id) + self.latest_version = self.revision_id return self @@ -232,14 +225,9 @@ def save(self, revision=True, *args, **kwargs): def delete(self, *args, **kwargs): if self.parent == self: return super(Publishable, self).delete(*args, **kwargs) + return self.parent.delete() - def is_conflicting_head(self): - return self.head is True and type(self).objects.filter(parent=self.parent, head=True).exclude(id=self.id).exists() - - def is_conflicting_revision_id(self): - return type(self).objects.filter(parent=self.parent, id=self.id).count() > 1 - def save_featured_image(self, data): """ Handles saving the featured image. @@ -321,7 +309,9 @@ def get_previous_revision(self): if self.parent == self: return self try: - revision = type(self).objects.filter(parent=self.parent).order_by('-pk')[1] + revision = type(self).objects \ + .filter(parent=self.parent) \ + .order_by('-pk')[1] return revision except: return self @@ -330,7 +320,6 @@ class Meta: abstract = True class Article(Publishable, AuthorMixin): - parent = ForeignKey('Article', related_name='article_parent', blank=True, null=True) headline = CharField(max_length=255) @@ -356,6 +345,13 @@ class Article(Publishable, AuthorMixin): reading_time = CharField(max_length=100, choices=READING_CHOICES, default='anytime') + class Meta: + unique_together = ( + ('slug', 'head'), + ('parent', 'slug', 'head'), + ('parent', 'slug', 'is_published'), + ) + AuthorModel = Author @property @@ -433,7 +429,7 @@ def get_published_articles(self): return Article.objects.filter(subsection=self, is_published=True).order_by('-published_at') def get_absolute_url(self): - """ Returns the subsection URL. """ + """Returns the subsection URL.""" return "%s%s/" % (settings.BASE_URL, self.slug) class Page(Publishable): @@ -441,6 +437,13 @@ class Page(Publishable): parent_page = ForeignKey('Page', related_name='parent_page_fk', null=True) title = CharField(max_length=255) + class Meta: + unique_together = ( + ('slug', 'head'), + ('parent', 'slug', 'head'), + ('parent', 'slug', 'is_published'), + ) + def get_author_string(self): return None