From ec8de0624dcb1b85afb0297a73ef4f29c1f483fe Mon Sep 17 00:00:00 2001 From: Andrew Kos Date: Thu, 22 Jan 2015 10:38:19 -0600 Subject: [PATCH] Squashed merge of promotions-scheduler --- bulbs/__init__.py | 2 +- bulbs/api/urls.py | 7 +- bulbs/api/views.py | 32 +- bulbs/promotion/api.py | 7 + bulbs/promotion/middleware.py | 14 + .../migrations/0002_content_list_to_pzone.py | 147 ++++++ .../migrations/0003_auto_20150121_1626.py | 26 + bulbs/promotion/models.py | 115 ++++- bulbs/promotion/operations.py | 108 +++-- bulbs/promotion/serializers.py | 54 ++- bulbs/promotion/templatetags/__init__.py | 0 bulbs/promotion/templatetags/promotion.py | 95 ++++ bulbs/promotion/urls.py | 18 + bulbs/promotion/views.py | 185 ++++++++ conftest.py | 7 + requirements.txt | 2 +- tests/promotion/test_api.py | 447 +++++++++++++++++- tests/promotion/test_contentlist.py | 54 --- tests/promotion/test_operations.py | 276 +++++++++-- tests/promotion/test_pzone.py | 78 +++ tests/promotion/test_template_tags.py | 53 +++ 21 files changed, 1503 insertions(+), 224 deletions(-) create mode 100644 bulbs/promotion/api.py create mode 100644 bulbs/promotion/middleware.py create mode 100644 bulbs/promotion/migrations/0002_content_list_to_pzone.py create mode 100644 bulbs/promotion/migrations/0003_auto_20150121_1626.py create mode 100644 bulbs/promotion/templatetags/__init__.py create mode 100644 bulbs/promotion/templatetags/promotion.py create mode 100644 bulbs/promotion/urls.py create mode 100644 bulbs/promotion/views.py delete mode 100644 tests/promotion/test_contentlist.py create mode 100644 tests/promotion/test_pzone.py create mode 100644 tests/promotion/test_template_tags.py diff --git a/bulbs/__init__.py b/bulbs/__init__.py index 39774cfa..f7e5c1d8 100644 --- a/bulbs/__init__.py +++ b/bulbs/__init__.py @@ -1 +1 @@ -__version__ = "0.3.16" +__version__ = "0.3.17" diff --git a/bulbs/api/urls.py b/bulbs/api/urls.py index e5279614..a4240590 100644 --- a/bulbs/api/urls.py +++ b/bulbs/api/urls.py @@ -11,6 +11,11 @@ url(r"^", include(api_v1_router.urls)) # noqa ) +if "bulbs.promotion" in settings.INSTALLED_APPS: + urlpatterns += ( + url(r"^", include("bulbs.promotion.urls")), + ) + if "bulbs.cms_notifications" in settings.INSTALLED_APPS: urlpatterns += ( url(r"^notifications/(?P\d+)?", notifications_view, name="notifications"), @@ -18,5 +23,5 @@ if "bulbs.contributions" in settings.INSTALLED_APPS: urlpatterns += ( - url(r"^contributions/", include('bulbs.contributions.urls')), + url(r"^contributions/", include("bulbs.contributions.urls")), ) diff --git a/bulbs/api/views.py b/bulbs/api/views.py index 2b7d1604..01727849 100644 --- a/bulbs/api/views.py +++ b/bulbs/api/views.py @@ -30,10 +30,8 @@ ) from bulbs.contributions.serializers import ContributionSerializer from bulbs.contributions.models import Contribution -from bulbs.promotion.models import ContentList, ContentListHistory -from bulbs.promotion.serializers import ContentListSerializer from .mixins import UncachedResponse -from .permissions import CanEditContent, CanPromoteContent, CanPublishContent +from .permissions import CanEditContent, CanPublishContent class ContentViewSet(UncachedResponse, viewsets.ModelViewSet): @@ -326,33 +324,6 @@ class UserViewSet(UncachedResponse, viewsets.ModelViewSet): paginate_by = 20 -class ContentListViewSet(UncachedResponse, viewsets.ModelViewSet): - """ - uncached viewset for `bulbs.promotions.ContentList` model - """ - - model = ContentList - serializer_class = ContentListSerializer - paginate_by = 20 - permission_classes = [IsAdminUser, CanPromoteContent] - - def post_save(self, obj, created=False): - """creates a record in the `bulbs.promotion.ContentListHistory` - - :param obj: the instance saved - :param created: boolean expressing if the object was newly created (`False` if updated) - """ - ContentListHistory.objects.create(content_list=obj, data=obj.data) - - @decorators.link() - def preview(self): - pass - - @decorators.link() - def futures(self): - pass - - class LogEntryViewSet(UncachedResponse, viewsets.ModelViewSet): """ uncached viewset for `bulbs.content.LogEntry` model @@ -469,7 +440,6 @@ def retrieve(self, request, *args, **kwargs): # note: me view is registered in urls.py api_v1_router = routers.DefaultRouter() api_v1_router.register(r"content", ContentViewSet, base_name="content") -api_v1_router.register(r"contentlist", ContentListViewSet, base_name="contentlist") api_v1_router.register(r"tag", TagViewSet, base_name="tag") api_v1_router.register(r"log", LogEntryViewSet, base_name="logentry") api_v1_router.register(r"author", AuthorViewSet, base_name="author") diff --git a/bulbs/promotion/api.py b/bulbs/promotion/api.py new file mode 100644 index 00000000..5aa47d98 --- /dev/null +++ b/bulbs/promotion/api.py @@ -0,0 +1,7 @@ +from rest_framework import routers + +from .views import PZoneViewSet + + +api_v1_router = routers.DefaultRouter() +api_v1_router.register(r"pzone", PZoneViewSet, base_name="pzone") diff --git a/bulbs/promotion/middleware.py b/bulbs/promotion/middleware.py new file mode 100644 index 00000000..505c70c7 --- /dev/null +++ b/bulbs/promotion/middleware.py @@ -0,0 +1,14 @@ +from django.utils.dateparse import parse_datetime + + +class PromotionMiddleware(object): + + def process_template_response(self, request, response): + when = None + if request.method == "GET" and "pzone_preview" in request.GET: + when = parse_datetime(request.GET["pzone_preview"]) + + if when: + response.context_data["pzone_preview"] = when + + return response diff --git a/bulbs/promotion/migrations/0002_content_list_to_pzone.py b/bulbs/promotion/migrations/0002_content_list_to_pzone.py new file mode 100644 index 00000000..5de75547 --- /dev/null +++ b/bulbs/promotion/migrations/0002_content_list_to_pzone.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import json_field.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0001_initial'), + ('content', '0001_initial'), + ('promotion', '0001_initial'), + ] + + operations = [ + + # cleanup old models + migrations.DeleteModel( + name='LockOperation', + ), + migrations.DeleteModel( + name='UnlockOperation', + ), + migrations.DeleteModel( + name='InsertOperation' + ), + migrations.DeleteModel( + name='ReplaceOperation' + ), + migrations.DeleteModel( + name='ContentListOperation' + ), + migrations.DeleteModel( + name='ContentListHistory', + ), + + # fix up content list which is now pzone + migrations.RenameModel( + old_name='ContentList', + new_name='PZone', + ), + migrations.RenameField( + model_name='pzone', + old_name='length', + new_name='zone_length', + ), + + # pzone operation modifications + migrations.CreateModel( + name='PZoneOperation', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('when', models.DateTimeField()), + ('applied', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['-when', 'id'], + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='pzoneoperation', + name='content', + field=models.ForeignKey(related_name='+', to='content.Content'), + preserve_default=True, + ), + migrations.AddField( + model_name='pzoneoperation', + name='polymorphic_ctype', + field=models.ForeignKey(related_name='polymorphic_promotion.pzoneoperation_set', editable=False, to='contenttypes.ContentType', null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='pzoneoperation', + name='pzone', + field=models.ForeignKey(related_name='operations', to='promotion.PZone'), + preserve_default=True, + ), + + # delete operation modifications + migrations.CreateModel( + name='DeleteOperation', + fields=[ + ('pzoneoperation_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='promotion.PZoneOperation')), + ], + options={ + 'abstract': False, + }, + bases=('promotion.pzoneoperation',), + ), + + # insert operation modifications + migrations.CreateModel( + name='InsertOperation', + fields=[ + ('pzoneoperation_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='promotion.PZoneOperation')), + ], + options={ + 'abstract': False, + }, + bases=('promotion.pzoneoperation',), + ), + migrations.AddField( + model_name='insertoperation', + name='index', + field=models.IntegerField(default=0), + preserve_default=True, + ), + + # replace operation modifications + migrations.CreateModel( + name='ReplaceOperation', + fields=[ + ('pzoneoperation_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='promotion.PZoneOperation')), + ], + options={ + 'abstract': False, + }, + bases=('promotion.pzoneoperation',), + ), + migrations.AddField( + model_name='replaceoperation', + name='index', + field=models.IntegerField(default=0), + preserve_default=True, + ), + + # pzone history modifications + migrations.CreateModel( + name='PZoneHistory', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('data', json_field.fields.JSONField(default=[], help_text='Enter a valid JSON object')), + ('date', models.DateTimeField(auto_now_add=True)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='pzonehistory', + name='pzone', + field=models.ForeignKey(related_name='history', to='promotion.PZone'), + preserve_default=True, + ), + ] diff --git a/bulbs/promotion/migrations/0003_auto_20150121_1626.py b/bulbs/promotion/migrations/0003_auto_20150121_1626.py new file mode 100644 index 00000000..8a5aad33 --- /dev/null +++ b/bulbs/promotion/migrations/0003_auto_20150121_1626.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('promotion', '0002_content_list_to_pzone'), + ] + + operations = [ + migrations.AlterModelOptions( + name='pzone', + options={'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='pzonehistory', + options={'ordering': ['-date']}, + ), + migrations.AlterModelOptions( + name='pzoneoperation', + options={'ordering': ['when', 'id']}, + ), + ] diff --git a/bulbs/promotion/models.py b/bulbs/promotion/models.py index 3bcad489..596cc0ac 100644 --- a/bulbs/promotion/models.py +++ b/bulbs/promotion/models.py @@ -1,3 +1,6 @@ +from celery.task import task + +from django.core.cache import cache from django.db import models from django.utils import timezone @@ -7,38 +10,93 @@ from .operations import * # noqa -class ContentListManager(models.Manager): - - def preview(self, name, when): - content_list = self.get(name=name) - data = content_list.data - for operation in content_list.operations.filter(when__lte=when, applied=False): - data = operation.apply(data) +@task +def update_pzone(**kwargs): + """Update pzone data in the DB""" + + pzone = PZone.objects.get(**kwargs) + + # get the data and loop through operate_on, applying them if necessary + when = timezone.now() + data = pzone.data + for operation in pzone.operations.filter(when__lte=when, applied=False): + data = operation.apply(data) + operation.applied = True + operation.save() + pzone.data = data + + # create a history entry + pzone.history.create(data=pzone.data) + + # save modified pzone, making transactions permanent + pzone.save() + - content_list.data = data - return content_list +class PZoneManager(models.Manager): - def applied(self, name): - content_list = self.get(name=name) - data = content_list.data - for operation in content_list.operations.filter(when__lte=timezone.now(), applied=False): - data = operation.apply(data) - operation.applied = True + def operate_on(self, when=None, apply=False, **kwargs): + """Do something with operate_on. If apply is True, all transactions will + be applied and saved via celery task.""" - content_list.data = data - content_list.save() - return content_list + # get pzone based on id + pzone = self.get(**kwargs) + + # cache the current time + now = timezone.now() + + # ensure we have some value for when + if when is None: + when = now + + if when < now: + histories = pzone.history.filter(date__lte=when) + if histories.exists(): + # we have some history, use its data + pzone.data = histories[0].data + + else: + # only apply operations if cache is expired or empty, or we're looking at the future + data = pzone.data + # Get the cached time of the next expiration + next_operation_time = cache.get('pzone-operation-expiry-' + pzone.name) + if next_operation_time is None or next_operation_time < when: + print(next_operation_time) + # start applying operations + pending_operations = pzone.operations.filter(when__lte=when, applied=False) + for operation in pending_operations: + data = operation.apply(data) -class ContentList(models.Model): + # reassign data + pzone.data = data + + if apply and pending_operations.exists(): + # there are operations to apply, do celery task + update_pzone.delay(**kwargs) + + # return pzone, modified if apply was True + return pzone + + def preview(self, when=timezone.now(), **kwargs): + """Preview transactions, but don't actually save changes to list.""" + + return self.operate_on(when=when, apply=False, **kwargs) + + def applied(self, **kwargs): + """Apply transactions via a background task, return preview to user.""" + + return self.operate_on(apply=True, **kwargs) + + +class PZone(models.Model): name = models.SlugField(unique=True) - length = models.IntegerField(default=10) + zone_length = models.IntegerField(default=10) data = JSONField(default=[]) - objects = ContentListManager() + objects = PZoneManager() def __len__(self): - return min(self.length, len(self.data)) + return min(self.zone_length, len(self.data)) def __iter__(self): content_ids = [item["id"] for item in self.data[:self.__len__()]] @@ -67,7 +125,7 @@ def __setitem__(self, index, value): elif isinstance(value, int): self.data[index]["id"] = value else: - raise ValueError("ContentList items must be Content or int") + raise ValueError("PZone items must be Content or int") def __delitem__(self, index): if index > self.__len__(): @@ -86,8 +144,15 @@ def __contains__(self, value): def __unicode__(self): return "{}[{}]".format(self.name, self.__len__()) + class Meta: + ordering = ["name"] -class ContentListHistory(models.Model): - content_list = models.ForeignKey(ContentList, related_name="history") + +class PZoneHistory(models.Model): + pzone = models.ForeignKey(PZone, related_name="history") data = JSONField(default=[]) date = models.DateTimeField(auto_now_add=True) + + class Meta: + # we want the most recently created to come out first + ordering = ["-date"] diff --git a/bulbs/promotion/operations.py b/bulbs/promotion/operations.py index be085d5c..7af80912 100644 --- a/bulbs/promotion/operations.py +++ b/bulbs/promotion/operations.py @@ -1,85 +1,93 @@ +import logging + from django.db import models from polymorphic import PolymorphicModel +logger = logging.getLogger(__name__) -class ContentListOperation(PolymorphicModel): - class Meta: - ordering = ["-when"] +class PZoneOperation(PolymorphicModel): - content_list = models.ForeignKey("promotion.ContentList", related_name="operations") + pzone = models.ForeignKey("promotion.PZone", related_name="operations") when = models.DateTimeField() applied = models.BooleanField(default=False) + content = models.ForeignKey("content.Content", related_name="+") def apply(self, data): raise NotImplemented() - -class InsertOperation(ContentListOperation): - - index = models.IntegerField(default=0) - content = models.ForeignKey("content.Content", related_name="+") - lock = models.BooleanField(default=False) - - def apply(self, data): - next = { - "id": self.content.pk, - "lock": self.lock - } - for i in range(self.index, min(len(data), 100)): - if data[i].get("lock", False): - continue - next, data[i] = data[i], next # Swap them - data.append(next) - return data + class Meta: + ordering = ["when", "id"] -class ReplaceOperation(ContentListOperation): +class InsertOperation(PZoneOperation): - content = models.ForeignKey("content.Content", related_name="+") - target = models.ForeignKey("content.Content", related_name="+") - lock = models.BooleanField(default=False) + index = models.IntegerField(default=0) def apply(self, data): - replace = { - "id": self.content.pk, - "lock": self.lock - } - for index, item in enumerate(data): - if item["id"] == self.target.pk: - if item.get("lock", False): - raise Exception("That item is locked!") - data[index] = replace - break + + if self.content.published and self.when >= self.content.published: + # content has a published date, and that date is before when this + data.insert(0, { + "id": self.content.pk + }) + data = data[:100] else: - raise Exception("No content in list!") + # warn that we failed to perform an operation on this content + logger.warning( + "Failed to perform insert operation on unpublished content (id: %i) %s in %s!", + self.content.pk, + self.content.title, + self.pzone.name) + return data -class LockOperation(ContentListOperation): +class ReplaceOperation(PZoneOperation): - target = models.ForeignKey("content.Content", related_name="+") + index = models.IntegerField(default=0) def apply(self, data): - for index, item in enumerate(data): - if item["id"] == self.target.pk: - data[index]["lock"] = True - break + + if self.content.published and self.when >= self.content.published: + # content has a published date, and that date is before when this + # operation is occurring + try: + data[self.index] = { + "id": self.content.pk + } + except IndexError: + logger.warning( + "Failed to perform replace operation on content (id: %i) %s in %s at index %i!", + self.content.pk, + self.content.title, + self.pzone.name, + self.index) else: - raise Exception("No content in list!") - return data + # warn that we failed to perform an operation on this content + logger.warning( + "Failed to perform replace operation on unpublished content (id: %i) %s in %s!", + self.content.pk, + self.content.title, + self.pzone.name) + return data -class UnlockOperation(ContentListOperation): - target = models.ForeignKey("content.Content", related_name="+") +class DeleteOperation(PZoneOperation): + """Delete a piece of content from the list, assumes only one instance of a + piece of content is in the list.""" def apply(self, data): for index, item in enumerate(data): - if item["id"] == self.target.pk: - data[index]["lock"] = False + if item["id"] == self.content.pk: + del(data[index]) break else: - raise Exception("No content in list!") + logger.warning( + "Failed to perform delete operation on content (id: %i) %s in %s!", + self.content.pk, + self.content.title, + self.pzone.name) return data diff --git a/bulbs/promotion/serializers.py b/bulbs/promotion/serializers.py index 784c5b4d..57b0d449 100644 --- a/bulbs/promotion/serializers.py +++ b/bulbs/promotion/serializers.py @@ -1,11 +1,15 @@ +from elastimorphic.serializers import ContentTypeField + from rest_framework import serializers +from bulbs.content.models import Content from bulbs.content.serializers import ContentSerializer -from .models import ContentList +from .models import PZone +from .operations import PZoneOperation, InsertOperation, DeleteOperation, ReplaceOperation -class ContentListField(serializers.WritableField): +class PZoneField(serializers.WritableField): def field_to_native(self, obj, field_name): data = [] for content in obj: @@ -17,9 +21,49 @@ def from_native(self, data): return [{"id": content_data["id"]} for content_data in data] -class ContentListSerializer(serializers.ModelSerializer): +class PZoneSerializer(serializers.ModelSerializer): class Meta: - model = ContentList + model = PZone exclude = ("data",) - content = ContentListField(source="data") + content = PZoneField(source="data") + + +class _PZoneOperationSerializer(serializers.ModelSerializer): + """Parent class of pzone operation serializers.""" + + type_name = ContentTypeField(source="polymorphic_ctype_id") + pzone = serializers.PrimaryKeyRelatedField() + when = serializers.DateTimeField() + applied = serializers.BooleanField(default=False) + content = serializers.PrimaryKeyRelatedField() + content_title = serializers.SerializerMethodField('get_content_title') + + class Meta: + model = PZoneOperation + + def get_content_title(self, obj): + """Get content's title.""" + return Content.objects.get(id=obj.content.id).title + + +class InsertOperationSerializer(_PZoneOperationSerializer): + + index = serializers.IntegerField(min_value=0) + + class Meta: + model = InsertOperation + + +class ReplaceOperationSerializer(_PZoneOperationSerializer): + + index = serializers.IntegerField(min_value=0) + + class Meta: + model = ReplaceOperation + + +class DeleteOperationSerializer(_PZoneOperationSerializer): + + class Meta: + model = DeleteOperation diff --git a/bulbs/promotion/templatetags/__init__.py b/bulbs/promotion/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bulbs/promotion/templatetags/promotion.py b/bulbs/promotion/templatetags/promotion.py new file mode 100644 index 00000000..32f737b3 --- /dev/null +++ b/bulbs/promotion/templatetags/promotion.py @@ -0,0 +1,95 @@ +import operator + +from django import template +from django.template.base import parse_bits, Variable, VariableDoesNotExist +from django.template.defaulttags import ForNode + +from bulbs.promotion.models import PZone + + +register = template.Library() + + +class PZoneSequence(object): + + def __init__(self, pzone_name, slice_string=None, apply=True): + self.pzone_name = pzone_name + if slice_string: + bits = [] + for x in slice_string.split(':'): + if len(x) == 0: + bits.append(None) + else: + bits.append(int(x)) + self.slice = slice(*bits) + else: + self.slice = None + + self.apply = apply + + def resolve(self, context, ignore_failures=False): + + try: + when = Variable("pzone_preview").resolve(context) + pzone = PZone.objects.preview(name=self.pzone_name, when=when) + except VariableDoesNotExist: + pzone = PZone.objects.applied(name=self.pzone_name) + if self.slice: + return pzone[self.slice] + else: + return pzone + + +@register.tag('forpzone') +def do_pzone(parser, token): + """ + + {% forpzone "homepage" slice=":3" %} +

+ {{ content.title }} +

+ {{ content.description }} + {% endforpzone %} + + """ + + bits = token.split_contents() + + if len(bits) < 2: + raise TemplateSyntaxError("'pzone' statements should have at least two" + " words: %s" % token.contents) + + nodelist_loop = parser.parse(("endforpzone","empty")) + token = parser.next_token() + if token.contents == 'empty': + nodelist_empty = parser.parse(('endfor',)) + parser.delete_first_token() + else: + nodelist_empty = None + + params = ["slice", "name", "apply"] + args, kwargs = parse_bits(parser, bits, params, None, None, [], False, "forpzone") + + pzone_name = kwargs["name"].resolve({}) + + slice_string = None + if "slice" in kwargs: + slice_string = kwargs["slice"].resolve({}) + slice_bits = [] + for x in slice_string.split(':'): + if len(x) == 0: + slice_bits.append(None) + else: + slice_bits.append(int(x)) + slice_object = slice(*slice_bits) + + apply = False + if "apply" in kwargs: + apply = kwargs["apply"].resolve({}) + + sequence = PZoneSequence(pzone_name, slice_string=slice_string, apply=apply) + + loopvars = ["content"] + is_reversed = False + + return ForNode(loopvars, sequence, is_reversed, nodelist_loop, nodelist_empty) \ No newline at end of file diff --git a/bulbs/promotion/urls.py b/bulbs/promotion/urls.py new file mode 100644 index 00000000..b4705e49 --- /dev/null +++ b/bulbs/promotion/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url, include + +from .api import api_v1_router +from .views import OperationsViewSet + +urlpatterns = ( + url( + r"^pzone/(?P\d+)/operations/$", + OperationsViewSet.as_view(), + name="pzone_operations" + ), + url( + r"^pzone/(?P\d+)/operations/(?P\d+)/$", + OperationsViewSet.as_view(), + name="pzone_operations_delete" + ), + url(r"^", include(api_v1_router.urls)), # noqa +) diff --git a/bulbs/promotion/views.py b/bulbs/promotion/views.py new file mode 100644 index 00000000..7e78d0e0 --- /dev/null +++ b/bulbs/promotion/views.py @@ -0,0 +1,185 @@ +import json + +from dateutil.parser import parse as parse_date + +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.http import Http404 +from django.utils import timezone + +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from bulbs.api.permissions import CanPromoteContent +from bulbs.api.mixins import UncachedResponse + +from .models import PZone +from .operations import PZoneOperation, InsertOperation, DeleteOperation, ReplaceOperation +from .serializers import PZoneSerializer, InsertOperationSerializer, DeleteOperationSerializer, ReplaceOperationSerializer + + +class OperationsViewSet(APIView): + + permission_classes = [IsAdminUser, CanPromoteContent] + + def get_serializer_class_by_name(self, type_name): + try: + # try to convert type name into a serializer class + app_label, model_name = type_name.split("_") + operation_type = ContentType.objects.get_by_natural_key(app_label, model_name) + operation_model = operation_type.model_class() + return self.get_serializer_class(operation_model) + except (ValueError, ContentType.DoesNotExist): + # invalid type name + raise Exception("Provided type_name is invalid.") + + def get_serializer_class(self, obj_class): + serializer_class = None + if obj_class is InsertOperation: + serializer_class = InsertOperationSerializer + elif obj_class is DeleteOperation: + serializer_class = DeleteOperationSerializer + elif obj_class is ReplaceOperation: + serializer_class = ReplaceOperationSerializer + return serializer_class + + def serialize_operations(self, operations): + """Serialize a list of operations into JSON.""" + + serialized_ops = [] + for operation in operations: + serializer = self.get_serializer_class(operation.__class__) + serialized_ops.append(serializer(operation).data) + return serialized_ops + + def get(self, request, pzone_pk): + """Get all the operations for a given pzone.""" + + # attempt to get given pzone + try: + pzone = PZone.objects.get(pk=pzone_pk) + except PZone.DoesNotExist: + raise Http404("Cannot find given pzone.") + + # get operations and serialize them + operations = PZoneOperation.objects.filter(pzone=pzone) + + # return a json response with serialized operations + return Response(self.serialize_operations(operations), content_type="application/json") + + def post(self, request, pzone_pk): + """Add a new operation to the given pzone, return json of the new operation.""" + + # attempt to get given content list + pzone = None + try: + pzone = PZone.objects.get(pk=pzone_pk) + except PZone.DoesNotExist: + raise Http404("Cannot find given pzone.") + + json_obj = None + http_status = 500 + + # get requested data, and serialize it + try: + # load json into an object + json_op = json.loads(request.body) + + # serialize json object into actual + serializer = self.get_serializer_class_by_name(json_op["type_name"]) + serialized = serializer(data=json_op) + + if serialized.is_valid(): + # object is valid, save it + serialized.object.save() + + # set response data + json_obj = serialized.data + http_status = 200 + else: + # object is not valid, return errors in a 400 response + json_obj = serialized.errors + http_status = 400 + except Exception as e: + # some error happened, return it + json_obj = {"errors": [str(e)]} + http_status = 400 + + # cache the time in seconds until the next operation occurs + next_ops = PZoneOperation.objects.filter(when__lte=timezone.now()) + if len(next_ops) > 0: + # we have at least one operation, ordered soonest first + next_op = next_ops[0] + # cache with expiry number of seconds until op should exec + cache.set('pzone-operation-expiry-' + pzone.name, next_op.when, 60 * 60 * 5) + + return Response( + json_obj, + status=http_status, + content_type="application/json" + ) + + def delete(self, request, pzone_pk, operation_pk): + """Remove an operation from the given pzone.""" + + # note : we're not using the pzone_pk here since it's not actually + # necessary for getting an operation by pk, but it sure makes the urls + # nicer! + + # attempt to delete operation + try: + operation = PZoneOperation.objects.get(pk=operation_pk) + except PZoneOperation.DoesNotExist: + raise Http404("Cannot find given operation.") + + # delete operation + operation.delete() + + # successful delete, return 204 + return Response("", 204) + + +class PZoneViewSet(UncachedResponse, viewsets.ModelViewSet): + """Uncached viewset for `bulbs.promotions.PZone` model.""" + + model = PZone + serializer_class = PZoneSerializer + paginate_by = 20 + permission_classes = [IsAdminUser, CanPromoteContent] + + def post_save(self, obj, created=False): + """creates a record in the `bulbs.promotion.PZoneHistory` + + :param obj: the instance saved + :param created: boolean expressing if the object was newly created (`False` if updated) + """ + + # create history object + obj.history.create(data=obj.data) + + def retrieve(self, request, *args, **kwargs): + """Retrieve pzone as a preview or applied if no preview is provided.""" + + when_param = self.request.QUERY_PARAMS.get("preview", None) + pk = self.kwargs["pk"] + + when = None + if when_param: + try: + when = parse_date(when_param) + except ValueError: + # invalid format, set back to None + when = None + + pzone = None + if when: + # we have a date, use it + pzone = PZone.objects.preview(pk=pk, when=when) + else: + # we have no date, just get the pzone + pzone = PZone.objects.applied(pk=pk) + + # turn content list into json + return Response(PZoneSerializer(pzone).data, content_type="application/json") diff --git a/conftest.py b/conftest.py index 1dd490e1..b7199cf0 100644 --- a/conftest.py +++ b/conftest.py @@ -21,6 +21,12 @@ def pytest_configure(): }, USE_TZ=True, + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } + }, + TEMPLATE_DIRS=(os.path.join(MODULE_ROOT, 'tests', 'templates'),), INSTALLED_APPS=( @@ -67,6 +73,7 @@ def pytest_configure(): 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'bulbs.promotion.middleware.PromotionMiddleware' ), CELERY_ALWAYS_EAGER=True, diff --git a/requirements.txt b/requirements.txt index 6550f82a..7b88e7c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ South==0.8.1 django-betty-cropper>=0.1.4 djangorestframework==2.4.3 django-elastimorphic>=0.1.0 -django_polymorphic==0.5.1 +django_polymorphic==0.6.1 django-json-field==0.5.5 djangorestframework-csv==1.3.3 python-dateutil==2.1 diff --git a/tests/promotion/test_api.py b/tests/promotion/test_api.py index b0728cfa..3425d36f 100644 --- a/tests/promotion/test_api.py +++ b/tests/promotion/test_api.py @@ -1,33 +1,43 @@ +import datetime import json from django.core.urlresolvers import reverse +from django.contrib.auth import get_user_model from django.test.client import Client +from django.utils import timezone -from bulbs.promotion.models import ContentList, ContentListHistory +from bulbs.promotion.models import PZone, PZoneHistory +from bulbs.promotion.operations import PZoneOperation, InsertOperation, DeleteOperation from tests.utils import BaseAPITestCase, make_content class PromotionApiTestCase(BaseAPITestCase): - def test_content_list_api(self): - client = Client() - client.login(username="admin", password="secret") - content_list = ContentList.objects.create(name="homepage") + def setUp(self): + # set up client + super(PromotionApiTestCase, self).setUp() + + # do client stuff + self.client = Client() + self.client.login(username="admin", password="secret") + + # set up a test pzone + self.pzone = PZone.objects.create(name="homepage") data = [] for i in range(10): content = make_content(title="Content test #{}".format(i)) data.append({"id": content.pk}) - content_list.data = data - content_list.save() + self.pzone.data = data + self.pzone.save() - endpoint = reverse("contentlist-detail", kwargs={"pk": content_list.pk}) - response = client.get(endpoint) - # no permission, no promotion - self.assertEqual(response.status_code, 403) - # ok, have some permissions + def test_pzone_api(self): + + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + response = self.client.get(endpoint) + # give permissions self.give_permissions() - response = client.get(endpoint) + response = self.client.get(endpoint) # permission allows promotion self.assertEqual(response.status_code, 200) @@ -38,13 +48,414 @@ def test_content_list_api(self): # This sucks, but it just reverses the list new_data["content"] = [{"id": content["id"]} for content in response.data["content"]][::-1] - self.assertEqual(ContentListHistory.objects.count(), 0) + self.assertEqual(PZoneHistory.objects.count(), 0) - response = client.put(endpoint, json.dumps(new_data), content_type="application/json") + response = self.client.put(endpoint, json.dumps(new_data), content_type="application/json") self.assertEqual(response.status_code, 200) for index, content in enumerate(response.data["content"]): self.assertEqual(content["title"], "Content test #{}".format(9 - index)) - self.assertEqual(ContentListHistory.objects.count(), 1) - content_list = ContentList.objects.get(name=content_list.name) - self.assertEqual(ContentListHistory.objects.get().data, content_list.data) + self.assertEqual(PZoneHistory.objects.count(), 1) + pzone = PZone.objects.get(name=self.pzone.name) + self.assertEqual(PZoneHistory.objects.get().data, pzone.data) + + def test_operations_permissions(self): + """Ensure permissions limit who can edit operations.""" + + # create regular user + regular_user_name = "regularuser" + regular_user_pass = "12345" + get_user_model().objects.create_user( + regular_user_name, + "regularguy@aol.com", + regular_user_pass + ) + self.client.login(username=regular_user_name, password=regular_user_pass) + + # do requests + response_get = self.client.get(reverse( + "pzone_operations", + kwargs={ + "pzone_pk": self.pzone.pk + } + )) + response_post = self.client.post(reverse( + "pzone_operations", + kwargs={ + "pzone_pk": self.pzone.pk + } + )) + response_delete = self.client.delete(reverse( + "pzone_operations_delete", + kwargs={ + "pzone_pk": self.pzone.pk, + "operation_pk": 1 + } + )) + + # check that we get 403 for everything + self.assertEqual(response_get.status_code, 403) + self.assertEqual(response_post.status_code, 403) + self.assertEqual(response_delete.status_code, 403) + + def test_get_operations(self): + """Test that we can get a pzone's operations.""" + + # set up some test operations + test_time = (timezone.now() + datetime.timedelta(hours=-1)).replace(microsecond=0) + new_content_1 = make_content() + op_1 = InsertOperation.objects.create( + pzone=self.pzone, + when=test_time, + index=0, + content=new_content_1 + ) + op_1_type = "{}_{}".format(op_1.polymorphic_ctype.app_label, op_1.polymorphic_ctype.model) + # this one should end up above the first one + new_content_2 = make_content() + op_2 = InsertOperation.objects.create( + pzone=self.pzone, + when=test_time, + index=0, + content=new_content_2 + ) + op_2_type = "{}_{}".format(op_2.polymorphic_ctype.app_label, op_2.polymorphic_ctype.model) + + # query the endpoint + endpoint = reverse("pzone_operations", kwargs={ + "pzone_pk": self.pzone.pk + }) + response = self.client.get(endpoint) + + # check that we got an OK response + self.assertEqual(response.status_code, 200, msg=response.data) + + # start checking data + operations = response.data + self.assertEqual(len(operations), 2) + + # check first operation made it in + self.assertEqual(operations[0]["type_name"], op_1_type) + self.assertEqual(operations[0]["content"], new_content_1.pk) + self.assertEqual(operations[0]["pzone"], self.pzone.pk) + self.assertEqual(operations[0]["index"], 0) + self.assertEqual(operations[0]["content_title"], new_content_1.title) + self.assertEqual(operations[0]["when"], test_time) + # check second operation made it in + self.assertEqual(operations[1]["type_name"], op_2_type) + self.assertEqual(operations[1]["content"], new_content_2.pk) + self.assertEqual(operations[1]["pzone"], self.pzone.pk) + self.assertEqual(operations[1]["index"], 0) + self.assertEqual(operations[1]["content_title"], new_content_2.title) + self.assertEqual(operations[1]["when"], test_time) + + def test_get_operations_404(self): + """Test that a 404 is thrown when an id not associated with a pzone + is given.""" + + # query endpoint + endpoint = reverse("pzone_operations", kwargs={ + "pzone_pk": 1234 + }) + response = self.client.get(endpoint) + + # check that we got a 404 + self.assertEqual(response.status_code, 404) + + def test_get_operations_empty(self): + """Test that the response of an empty operations list is JSON.""" + + # query the endpoint + endpoint = reverse("pzone_operations", kwargs={ + "pzone_pk": self.pzone.pk + }) + response = self.client.get(endpoint) + + # check that we got an OK response + self.assertEqual(response.status_code, 200, msg=response.data) + + # start checking data + operations = response.data + self.assertEqual(len(operations), 0) + + def test_post_operation(self): + """Test that a new operation can be added to a pzone operations.""" + + # test objects + test_time = (timezone.now() + datetime.timedelta(hours=1)).replace(microsecond=0) + + # setup and query endpoint + endpoint = reverse("pzone_operations", kwargs={ + "pzone_pk": self.pzone.pk + }) + response = self.client.post( + endpoint, + json.dumps({ + "type_name": "promotion_replaceoperation", + "pzone": self.pzone.pk, + "when": test_time.isoformat(), + "index": 0, + "content": 1 + }), + content_type="application/json" + ) + + # check that we got an OK response + self.assertEqual(response.status_code, 200, msg=response.data) + + # check that operations made it into the db + self.assertEqual(PZoneOperation.objects.count(), 1) + + # check response data data + operation = response.data + self.assertNotEqual(operation["id"], None) + self.assertEqual(operation["type_name"], "promotion_replaceoperation") + self.assertEqual(operation["pzone"], self.pzone.pk) + self.assertEqual(operation["when"], test_time) + self.assertEqual(operation["index"], 0) + self.assertEqual(operation["content"], 1) + + def test_post_operation_400(self): + """Test that posting with bad data returns 400 and errors in json.""" + + # setup and query endpoint + endpoint = reverse("pzone_operations", kwargs={ + "pzone_pk": self.pzone.pk + }) + response = self.client.post( + endpoint, + json.dumps({ + "type_name": "not a real operation type" + }), + content_type="application/json" + ) + + # check that we got a 400 response + self.assertEqual(response.status_code, 400) + # check the data that came back to see if it has an error + self.assertEqual(len(response.data["errors"]), 1) + + # let's try another error + response = self.client.post( + endpoint, + json.dumps({ + "type_name": "promotion_replaceoperation", + "pzone": "123" + }), + content_type="application/json" + ) + + # check that we got a 400 response + self.assertEqual(response.status_code, 400) + # check that the data came back with 4 errors + self.assertEqual(len(response.data), 4) + + def test_post_operation_404(self): + """Test that posting with invalid pzone pk returns a 404.""" + + # query endpoint + endpoint = reverse("pzone_operations", kwargs={ + "pzone_pk": 123456789 + }) + response = self.client.post(endpoint) + + # check that we got a 400 response + self.assertEqual(response.status_code, 404) + + def test_delete_operation(self): + """Test that we can remove an operation from a pzone.""" + + # add an operation to the db + test_time = timezone.now() + datetime.timedelta(hours=1) + new_content = make_content() + new_operation = InsertOperation.objects.create( + pzone=self.pzone, + when=test_time, + index=0, + content=new_content + ) + + # setup and query endpoint + endpoint = reverse("pzone_operations_delete", kwargs={ + "pzone_pk": self.pzone.pk, + "operation_pk": new_operation.pk + }) + response = self.client.delete(endpoint) + + # check that we got an No Content (204) response + self.assertEqual(response.status_code, 204, msg=response.data) + + # start checking data + self.assertEqual(InsertOperation.objects.count(), 0) + + def test_delete_operation_404(self): + """Test that we get a 404 when providing wrong operation info.""" + + # query endpoint + endpoint = reverse("pzone_operations_delete", kwargs={ + "pzone_pk": 1234, + "operation_pk": 1234 + }) + response = self.client.delete(endpoint) + + # check that we got a 404 + self.assertEqual(response.status_code, 404) + + def test_get_applied(self): + """Test that we can get a pzone applied when requesting to pzone detail + without a preview time.""" + + # make some testing stuff + test_time = timezone.now() + datetime.timedelta(hours=-1) + content_1 = self.pzone[0] + content_2 = self.pzone[1] + DeleteOperation.objects.create( + pzone=self.pzone, + when=test_time, + content=content_1 + ) + DeleteOperation.objects.create( + pzone=self.pzone, + when=test_time, + content=content_2 + ) + + # setup endpoint + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + self.give_permissions() + response = self.client.get(endpoint) + + # get updated pzone + self.pzone = PZone.objects.get(name=self.pzone.name) + + # check response status + self.assertEqual(response.status_code, 200) + # check that response list is correct + self.assertEqual(len(response.data["content"]), 8) + self.assertNotEqual(response.data["content"][0]["id"], content_1.id) + self.assertNotEqual(response.data["content"][0]["id"], content_2.id) + # check that in db list is correct + self.assertEqual(len(self.pzone), 8) + self.assertNotEqual(self.pzone[0].id, content_1.id) + self.assertNotEqual(self.pzone[0].id, content_2.id) + # ensure we created a history object and its data matches + self.assertEqual(self.pzone.history.count(), 1) + self.assertEqual(self.pzone.history.all()[0].data, self.pzone.data) + + def test_get_preview(self): + """Test that we can get a pzone preview.""" + + # make some testing stuff + test_time = timezone.now() + datetime.timedelta(hours=1) + DeleteOperation.objects.create( + pzone=self.pzone, + when=test_time, + content=self.pzone[0] + ) + DeleteOperation.objects.create( + pzone=self.pzone, + when=test_time, + content=self.pzone[1] + ) + + # setup endpoint + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + self.give_permissions() + response = self.client.get(endpoint, {"preview": test_time.isoformat()}) + + # check response status + self.assertEqual(response.status_code, 200) + # ensure nothing was actually deleted + self.assertEqual(len(self.pzone), 10) + # check that the preview has items deleted + self.assertEqual(len(response.data["content"]), 8) + + def test_get_preview_403(self): + """Ensure that we cannot get a preview if we don't have permission.""" + + # create regular user + regular_user_name = "regularuser" + regular_user_pass = "12345" + get_user_model().objects.create_user( + regular_user_name, + "regularguy@aol.com", + regular_user_pass + ) + self.client.login(username=regular_user_name, password=regular_user_pass) + + # setup endpoint and request + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + response = self.client.get(endpoint, {"preview": "1234"}) + + self.assertEqual(response.status_code, 403) + + def test_put_pzone(self): + """Ensure that PUTing a pzone works.""" + + # swap two entries + old_0 = self.pzone[0] + old_1 = self.pzone[1] + self.pzone[0], self.pzone[1] = self.pzone[1], self.pzone[0] + + # setup endpoint + self.give_permissions() + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + response = self.client.put( + endpoint, + data=json.dumps({"content": self.pzone.data, "name": "homepage"}), + content_type="application/json" + ) + + # check status + self.assertEqual( + response.status_code, 200, + msg="Failed ({}): {}".format(response.status_code, response.data)) + + # check that the pzone has been modified + self.pzone = PZone.objects.get(name=self.pzone.name) + self.assertEqual(self.pzone[0].id, old_1.id) + self.assertEqual(self.pzone[1].id, old_0.id) + + def test_put_pzone_403(self): + """Ensure that PUTing to a pzone without permission gives a 403.""" + + # create regular user + regular_user_name = "regularuser" + regular_user_pass = "12345" + get_user_model().objects.create_user( + regular_user_name, + "regularguy@aol.com", + regular_user_pass + ) + self.client.login(username=regular_user_name, password=regular_user_pass) + + # setup endpoint and make request + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + response = self.client.put(endpoint) + + self.assertEqual(response.status_code, 403) + + def test_get_past_pzone(self): + """Ensure retrieving a past pzone accesses pzone history.""" + + # create history, then take timestamp to test again, this order is important for timing + content_id = 5 + history = self.pzone.history.create( + data=[{"id": content_id}] + ) + test_time = timezone.now() + + # cache pzone + from django.core.cache import cache + cache.set('pzone-history-' + self.pzone.name, history) + + # retrieve data + endpoint = reverse("pzone-detail", kwargs={"pk": self.pzone.pk}) + self.give_permissions() + response = self.client.get(endpoint, {"preview": test_time.isoformat()}) + + # check that history was used + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["content"]), 1) + self.assertEqual(response.data["content"][0]["id"], content_id) + diff --git a/tests/promotion/test_contentlist.py b/tests/promotion/test_contentlist.py deleted file mode 100644 index d71c13cf..00000000 --- a/tests/promotion/test_contentlist.py +++ /dev/null @@ -1,54 +0,0 @@ -from elastimorphic.tests.base import BaseIndexableTestCase - -from bulbs.promotion.models import ContentList -from tests.testcontent.models import TestContentObj -from tests.utils import make_content - - -class ContentListTestCase(BaseIndexableTestCase): - def setUp(self): - super(ContentListTestCase, self).setUp() - self.content_list = ContentList.objects.create(name="homepage") - data = [] - for i in range(11): - content = make_content(title="Content test #{}".format(i), ) - data.append({"id": content.pk}) - - self.content_list.data = data - self.content_list.save() - - def test_content_list_len(self): - - self.assertEqual(len(self.content_list), 10) - - def test_content_list_iter(self): - for index, content in enumerate(self.content_list): - self.assertEqual(self.content_list[index].title, "Content test #{}".format(index)) - - def test_content_list_getitem(self): - self.assertEqual(self.content_list[0].title, "Content test #0") - with self.assertRaises(IndexError): - self.content_list[10] - - def test_contet_list_slice(self): - self.assertEqual(len(self.content_list[:2]), 2) - - def test_content_list_setitem(self): - new_content = make_content(TestContentObj) - self.content_list[0] = new_content - self.assertEqual(self.content_list[0].pk, new_content.pk) - - newer_content = make_content(TestContentObj) - self.content_list[1] = newer_content.id - self.assertEqual(self.content_list[1].pk, newer_content.pk) - - def test_content_list_contains(self): - newer_content = make_content(TestContentObj) - self.content_list[1] = newer_content.id - - self.assertTrue(newer_content.pk in self.content_list) - self.assertTrue(newer_content in self.content_list) - - invisible_content = make_content(TestContentObj) - self.assertFalse(invisible_content.pk in self.content_list) - self.assertFalse(invisible_content in self.content_list) diff --git a/tests/promotion/test_operations.py b/tests/promotion/test_operations.py index 8bdd6265..8a85ceb4 100644 --- a/tests/promotion/test_operations.py +++ b/tests/promotion/test_operations.py @@ -2,64 +2,264 @@ from django.utils import timezone from elastimorphic.tests.base import BaseIndexableTestCase +from mock import patch -from bulbs.content.models import Content -from bulbs.promotion.models import ContentList -from bulbs.promotion.operations import InsertOperation, ReplaceOperation, LockOperation +from bulbs.promotion.models import PZone, update_pzone +from bulbs.promotion.operations import InsertOperation, ReplaceOperation, DeleteOperation from tests.utils import make_content -class ContentListOperationsTestCase(BaseIndexableTestCase): +class PZoneOperationsTestCase(BaseIndexableTestCase): + def setUp(self): - super(ContentListOperationsTestCase, self).setUp() - self.content_list = ContentList.objects.create(name="homepage", length=10) + super(PZoneOperationsTestCase, self).setUp() + self.pzone = PZone.objects.create(name="homepage", zone_length=10) data = [] for i in range(10): data.append({"id": make_content().pk}) - self.content_list.data = data - self.content_list.save() + self.pzone.data = data + self.pzone.save() + + def test_insert_preview(self): + """Test that an insert can be previewed.""" + test_time = timezone.now() + datetime.timedelta(hours=1) - def test_insert(self): new_content = make_content() + new_content.published = test_time + new_content.save() + InsertOperation.objects.create( - content_list=self.content_list, - when=timezone.now() + datetime.timedelta(hours=1), + pzone=self.pzone, + when=test_time, index=0, - content=new_content, - lock=False + content=new_content ) - modified_list = ContentList.objects.preview("homepage", - when=timezone.now() + datetime.timedelta( - hours=1)) - self.assertEqual(len(modified_list), 10) # We should only get 10 pieces of content - self.assertEqual(len(modified_list.data), 11) # ...though the list contains 11 items + #apply operations in preview mode + modified_list = PZone.objects.preview(pk=self.pzone.id, when=test_time) + # check that the original content hasn't changed + self.assertEqual(len(self.pzone), 10) + # we should only get 10 pieces of content + self.assertEqual(len(modified_list), 10) + # ...though the list contains 11 items + self.assertEqual(len(modified_list.data), 11) + # check the id of the newly inserted item self.assertEqual(modified_list[0].pk, new_content.pk) - def test_replace(self): + def test_ordering(self): + one_hour = timezone.now() + datetime.timedelta(hours=1) + + test_one = make_content(published=one_hour) + InsertOperation.objects.create( + pzone=self.pzone, + when=one_hour + datetime.timedelta(hours=1), + index=0, + content=test_one + ) + + test_two = make_content(published=one_hour) + InsertOperation.objects.create( + pzone=self.pzone, + when=one_hour + datetime.timedelta(hours=2), + index=0, + content=test_two + ) + modified_list = PZone.objects.preview(pk=self.pzone.id, when=one_hour + datetime.timedelta(hours=3)) + self.assertEqual(len(self.pzone), 10) + self.assertEqual(len(modified_list), 10) + self.assertEqual(len(modified_list.data), 12) + self.assertEqual(modified_list[0].pk, test_two.pk) + self.assertEqual(modified_list[1].pk, test_one.pk) + + def test_replace_preview(self): + """Test that a replace can be previewed.""" + test_time = timezone.now() + datetime.timedelta(hours=1) + new_content = make_content() - target = Content.objects.get(id=self.content_list[3].pk) + new_content.published = test_time + new_content.save() + + i_to_replace = 3 ReplaceOperation.objects.create( - content_list=self.content_list, - when=timezone.now() + datetime.timedelta(hours=1), + pzone=self.pzone, + when=test_time, content=new_content, - target=target + index=i_to_replace ) - modified_list = ContentList.objects.preview("homepage", - when=timezone.now() + datetime.timedelta( - hours=1)) + # apply operations in preview mode + modified_list = PZone.objects.preview(pk=self.pzone.id, when=test_time) + # check that we haven't changed the length of the content list self.assertEqual(len(modified_list), 10) + # double check the data to ensure it wasn't modified self.assertEqual(len(modified_list.data), 10) - self.assertEqual(modified_list[3].pk, new_content.pk) - - def test_lock(self): - target = Content.objects.get(id=self.content_list[3].pk) - LockOperation.objects.create( - content_list=self.content_list, - when=timezone.now() + datetime.timedelta(hours=1), - target=target + # check that the item has been replaced at the given index + self.assertEqual(modified_list[i_to_replace].pk, new_content.pk) + + def test_delete_preview(self): + """Test that a delete can be previewed.""" + test_time = timezone.now() + datetime.timedelta(hours=1) + i_of_target = 3 + DeleteOperation.objects.create( + pzone=self.pzone, + when=test_time, + content=self.pzone[i_of_target] + ) + # apply operations in preview mode + modified_list = PZone.objects.preview(pk=self.pzone.id, when=test_time) + # "modified" list should be one shorter + self.assertEqual(len(modified_list), len(self.pzone) - 1) + # ensure the target is actually removed and the space is occupied by the next item + self.assertEqual(modified_list[i_of_target].pk, self.pzone[i_of_target + 1].pk) + + def test_apply(self): + """Test that actually applying an operation works.""" + test_time = timezone.now() + datetime.timedelta(hours=-1) + + new_content = make_content() + new_content.published = test_time + new_content.save() + + InsertOperation.objects.create( + pzone=self.pzone, + when=test_time, + index=0, + content=new_content + ) + + # apply operation + PZone.objects.operate_on(pk=self.pzone.id, apply=True) + + # get pzone again + self.pzone = PZone.objects.get(id=self.pzone.id) + + # check that an item was added + self.assertEqual(self.pzone[0].id, new_content.id) + + def test_apply_with_background_task(self): + """Test that applied function calls background task.""" + + test_time = timezone.now() - datetime.timedelta(hours=1) + new_content = make_content() + InsertOperation.objects.create( + pzone=self.pzone, + when=test_time, + index=0, + content=new_content + ) + + # call function with mock task method, so we can see if it was called + with patch.object(update_pzone, 'delay') as mock_method: + PZone.objects.applied(pk=1) + + # check that mock method was called + self.assertTrue(mock_method.called) + + def test_prevent_insert_of_article_with_no_publish_date(self): + """Insert operations should not complete on articles with no published date.""" + + new_content = make_content() + + # article has no published date + new_content.published = None + + # check old value at index where we're inserting + index = 0 + old_id = self.pzone[index].id + + # attempt to insert + InsertOperation.objects.create( + pzone=self.pzone, + when=timezone.now() + datetime.timedelta(hours=-1), + index=index, + content=new_content + ) + PZone.objects.operate_on(pk=self.pzone.id, apply=True) + + # get pzone again + self.pzone = PZone.objects.get(id=self.pzone.id) + + # make sure the pzone has not been modified + self.assertEqual(old_id, self.pzone[index].id) + + def test_prevent_insert_of_article_with_future_publish_date(self): + """Insert operations should not complete on articles with a publish date in + the future.""" + + new_content = make_content() + + # article has a future published date + new_content.published = timezone.now() + datetime.timedelta(hours=2) + + # check old value at index where we're inserting + index = 0 + old_id = self.pzone[index].id + + # attempt to insert + InsertOperation.objects.create( + pzone=self.pzone, + when=timezone.now() + datetime.timedelta(hours=-1), + index=index, + content=new_content + ) + PZone.objects.operate_on(pk=self.pzone.id, apply=True) + + # get pzone again + self.pzone = PZone.objects.get(id=self.pzone.id) + + # make sure the pzone has not been modified + self.assertEqual(old_id, self.pzone[index].id) + + def test_prevent_replace_of_article_with_no_publish_date(self): + """Replace operations should not complete on articles with no published date.""" + + new_content = make_content() + + # article has no published date + new_content.published = None + + # check old value at index where we're inserting + index = 0 + old_id = self.pzone[index].id + + # attempt to insert + ReplaceOperation.objects.create( + pzone=self.pzone, + when=timezone.now() + datetime.timedelta(hours=-1), + index=index, + content=new_content ) - modified_list = ContentList.objects.preview("homepage", - when=timezone.now() + datetime.timedelta( - hours=1)) - self.assertTrue(modified_list.data[3]["lock"]) + PZone.objects.operate_on(pk=self.pzone.id, apply=True) + + # get pzone again + self.pzone = PZone.objects.get(id=self.pzone.id) + + # make sure the pzone has not been modified + self.assertEqual(old_id, self.pzone[index].id) + + def test_prevent_replace_of_article_with_future_publish_date(self): + """Insert operations should not complete on articles with a publish date in + the future.""" + + new_content = make_content() + + # article has a future published date + new_content.published = timezone.now() + datetime.timedelta(hours=2) + + # check old value at index where we're inserting + index = 0 + old_id = self.pzone[index].id + + # attempt to insert + ReplaceOperation.objects.create( + pzone=self.pzone, + when=timezone.now() + datetime.timedelta(hours=-1), + index=index, + content=new_content + ) + PZone.objects.operate_on(pk=self.pzone.id, apply=True) + + # get pzone again + self.pzone = PZone.objects.get(id=self.pzone.id) + + # make sure the pzone has not been modified + self.assertEqual(old_id, self.pzone[index].id) diff --git a/tests/promotion/test_pzone.py b/tests/promotion/test_pzone.py new file mode 100644 index 00000000..696ba204 --- /dev/null +++ b/tests/promotion/test_pzone.py @@ -0,0 +1,78 @@ +import datetime + +from django.utils import timezone + +from elastimorphic.tests.base import BaseIndexableTestCase + +from bulbs.promotion.models import PZone, PZoneHistory +from tests.testcontent.models import TestContentObj +from tests.utils import make_content + + +class PZoneTestCase(BaseIndexableTestCase): + def setUp(self): + super(PZoneTestCase, self).setUp() + self.pzone = PZone.objects.create(name="homepage") + data = [] + for i in range(11): + content = make_content(title="Content test #{}".format(i), ) + data.append({"id": content.pk}) + + self.pzone.data = data + self.pzone.save() + + def test_len(self): + + self.assertEqual(len(self.pzone), 10) + + def test_iter(self): + for index, content in enumerate(self.pzone): + self.assertEqual(self.pzone[index].title, "Content test #{}".format(index)) + + def test_getitem(self): + self.assertEqual(self.pzone[0].title, "Content test #0") + with self.assertRaises(IndexError): + self.pzone[10] + + def test_slice(self): + self.assertEqual(len(self.pzone[:2]), 2) + + def test_setitem(self): + new_content = make_content(TestContentObj) + self.pzone[0] = new_content + self.assertEqual(self.pzone[0].pk, new_content.pk) + + newer_content = make_content(TestContentObj) + self.pzone[1] = newer_content.id + self.assertEqual(self.pzone[1].pk, newer_content.pk) + + def test_contains(self): + newer_content = make_content(TestContentObj) + self.pzone[1] = newer_content.id + + self.assertTrue(newer_content.pk in self.pzone) + self.assertTrue(newer_content in self.pzone) + + invisible_content = make_content(TestContentObj) + self.assertFalse(invisible_content.pk in self.pzone) + self.assertFalse(invisible_content in self.pzone) + + def test_history_ordering(self): + """Test that pzone history objects come out most recently created before now first.""" + + pzone_oldest = PZoneHistory.objects.create( + pzone=self.pzone + ) + pzone_middlest = PZoneHistory.objects.create( + pzone=self.pzone + ) + pzone_newest = PZoneHistory.objects.create( + pzone=self.pzone + ) + + history = self.pzone.history.filter(date__lte=timezone.now()) + + self.assertEqual(len(history), 3) + self.assertEqual(history[0].id, pzone_newest.id) + self.assertEqual(history[1].id, pzone_middlest.id) + self.assertEqual(history[2].id, pzone_oldest.id) diff --git a/tests/promotion/test_template_tags.py b/tests/promotion/test_template_tags.py new file mode 100644 index 00000000..59187013 --- /dev/null +++ b/tests/promotion/test_template_tags.py @@ -0,0 +1,53 @@ +import datetime + +from django.template import Template, Context +from django.utils import timezone + +from elastimorphic.tests.base import BaseIndexableTestCase + +from bulbs.promotion.models import PZone, InsertOperation +from tests.testcontent.models import TestContentObj +from tests.utils import make_content + + +class ForPZoneTestCase(BaseIndexableTestCase): + def setUp(self): + super(ForPZoneTestCase, self).setUp() + self.pzone = PZone.objects.create(name="homepage", zone_length=5) + data = [] + for i in range(6): + content = make_content(title="Content test #{}".format(i), ) + data.append({"id": content.pk}) + + self.pzone.data = data + self.pzone.save() + + def test_simple_tag(self): + + t = Template("""{% load promotion %}{% forpzone name="homepage" %}{{ content.title }} | {% endforpzone %}""") + c = Context({}) + self.assertEquals(t.render(c), "Content test #0 | Content test #1 | Content test #2 | Content test #3 | Content test #4 | ") + + + def test_pzone_tag_with_slice(self): + t = Template("""{% load promotion %}{% forpzone name="homepage" slice=":2" %}{{ content.title }} | {% endforpzone %}""") + c = Context({}) + self.assertEquals(t.render(c), "Content test #0 | Content test #1 | ") + + def test_pzone_preview(self): + test_time = timezone.now() + datetime.timedelta(hours=1) + new_content = make_content(published=test_time, title="Something New") + InsertOperation.objects.create( + pzone=self.pzone, + when=test_time, + index=0, + content=new_content + ) + + t = Template("""{% load promotion %}{% forpzone name="homepage" %}{{ content.title }} | {% endforpzone %}""") + c = Context({}) + self.assertEquals(t.render(c), "Content test #0 | Content test #1 | Content test #2 | Content test #3 | Content test #4 | ") + + c = Context({"pzone_preview": test_time + datetime.timedelta(minutes=30)}) + self.assertEquals(t.render(c), "Something New | Content test #0 | Content test #1 | Content test #2 | Content test #3 | ") +