diff --git a/registrasion/admin.py b/registrasion/admin.py index 35f73e9f..4d704c9b 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -12,6 +12,7 @@ class EffectsDisplayMixin(object): def effects(self, obj): return list(obj.effects()) + # Inventory admin @@ -69,14 +70,11 @@ class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): @admin.register(conditions.IncludedProductDiscount) -class IncludedProductDiscountAdmin(admin.ModelAdmin): +class IncludedProductDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): def enablers(self, obj): return list(obj.enabling_products.all()) - def effects(self, obj): - return list(obj.effects()) - list_display = ("description", "enablers", "effects") inlines = [ @@ -84,6 +82,20 @@ class IncludedProductDiscountAdmin(admin.ModelAdmin): DiscountForCategoryInline, ] +@admin.register(conditions.SpeakerDiscount) +class SpeakerDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): + + fields = ("description", "is_presenter", "is_copresenter", "proposal_kind") + + list_display = ("description", "is_presenter", "is_copresenter", "effects") + + ordering = ("-is_presenter", "-is_copresenter") + + inlines = [ + DiscountForProductInline, + DiscountForCategoryInline, + ] + # Vouchers @@ -172,18 +184,13 @@ class CategoryFlagAdmin( ordering = ("enabling_category",) -# Enabling conditions -@admin.register(conditions.TimeOrStockLimitFlag) -class TimeOrStockLimitFlagAdmin( - nested_admin.NestedAdmin, - EffectsDisplayMixin): - model = conditions.TimeOrStockLimitFlag +@admin.register(conditions.SpeakerFlag) +class SpeakerFlagAdmin(nested_admin.NestedAdmin, EffectsDisplayMixin): - list_display = ( - "description", - "start_time", - "end_time", - "limit", - "effects", - ) - ordering = ("start_time", "end_time", "limit") + model = conditions.SpeakerFlag + fields = ("description", "is_presenter", "is_copresenter", "proposal_kind", + "products", "categories") + + list_display = ("description", "is_presenter", "is_copresenter", "effects") + + ordering = ("-is_presenter", "-is_copresenter") diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 72e592a2..ac198347 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -25,6 +25,8 @@ class ConditionController(object): conditions.CategoryFlag: CategoryConditionController, conditions.IncludedProductDiscount: ProductConditionController, conditions.ProductFlag: ProductConditionController, + conditions.SpeakerFlag: SpeakerConditionController, + conditions.SpeakerDiscount: SpeakerConditionController, conditions.TimeOrStockLimitDiscount: TimeOrStockLimitDiscountController, conditions.TimeOrStockLimitFlag: @@ -299,3 +301,25 @@ class VoucherConditionController(IsMetByFilter, ConditionController): a voucher that invokes that item's condition in one of their carts. ''' return queryset.filter(voucher__cart__user=user) + + +class SpeakerConditionController(IsMetByFilter, ConditionController): + + @classmethod + def pre_filter(self, queryset, user): + ''' Returns all of the items from queryset which are enabled by a user + being a presenter or copresenter of a proposal. ''' + + u = user + # User is a presenter + user_is_presenter = Q( + is_presenter=True, + proposal_kind__proposalbase__presentation__speaker__user=u, + ) + # User is a copresenter + user_is_copresenter = Q( + is_copresenter=True, + proposal_kind__proposalbase__presentation__additional_speakers__user=u, + ) + + return queryset.filter(user_is_presenter | user_is_copresenter) diff --git a/registrasion/migrations/0003_auto_20160904_0235.py b/registrasion/migrations/0003_auto_20160904_0235.py new file mode 100644 index 00000000..77b74ab0 --- /dev/null +++ b/registrasion/migrations/0003_auto_20160904_0235.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-09-04 02:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_proposals', '0001_initial'), + ('registrasion', '0002_auto_20160822_0034'), + ] + + operations = [ + migrations.CreateModel( + name='SpeakerDiscount', + fields=[ + ('discountbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), + ('is_presenter', models.BooleanField(help_text='This condition is met if the user is the primary presenter of a presentation.')), + ('is_copresenter', models.BooleanField(help_text='This condition is met if the user is a copresenter of a presentation.')), + ('proposal_kind', models.ManyToManyField(help_text='The types of proposals that these users may be presenters of.', to='symposion_proposals.ProposalKind')), + ], + options={ + 'verbose_name': 'discount (speaker)', + 'verbose_name_plural': 'discounts (speaker)', + }, + bases=('registrasion.discountbase', models.Model), + ), + migrations.CreateModel( + name='SpeakerFlag', + fields=[ + ('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')), + ('is_presenter', models.BooleanField(help_text='This condition is met if the user is the primary presenter of a presentation.')), + ('is_copresenter', models.BooleanField(help_text='This condition is met if the user is a copresenter of a presentation.')), + ('proposal_kind', models.ManyToManyField(help_text='The types of proposals that these users may be presenters of.', to='symposion_proposals.ProposalKind')), + ], + options={ + 'verbose_name': 'flag (speaker)', + 'verbose_name_plural': 'flags (speaker)', + }, + bases=('registrasion.flagbase', models.Model), + ), + migrations.AlterField( + model_name='includedproductdiscount', + name='enabling_products', + field=models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product', verbose_name='Including product'), + ), + migrations.AlterField( + model_name='productflag', + name='enabling_products', + field=models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product', verbose_name='Including product'), + ), + migrations.AlterField( + model_name='timeorstocklimitdiscount', + name='end_time', + field=models.DateTimeField(blank=True, help_text='When the condition should stop being true.', null=True, verbose_name='End time'), + ), + migrations.AlterField( + model_name='timeorstocklimitdiscount', + name='limit', + field=models.PositiveIntegerField(blank=True, help_text='How many times this condition may be applied for all users.', null=True, verbose_name='Limit'), + ), + migrations.AlterField( + model_name='timeorstocklimitdiscount', + name='start_time', + field=models.DateTimeField(blank=True, help_text='When the condition should start being true', null=True, verbose_name='Start time'), + ), + migrations.AlterField( + model_name='timeorstocklimitflag', + name='end_time', + field=models.DateTimeField(blank=True, help_text='When the condition should stop being true.', null=True, verbose_name='End time'), + ), + migrations.AlterField( + model_name='timeorstocklimitflag', + name='limit', + field=models.PositiveIntegerField(blank=True, help_text='How many times this condition may be applied for all users.', null=True, verbose_name='Limit'), + ), + migrations.AlterField( + model_name='timeorstocklimitflag', + name='start_time', + field=models.DateTimeField(blank=True, help_text='When the condition should start being true', null=True, verbose_name='Start time'), + ), + migrations.AlterField( + model_name='voucherflag', + name='voucher', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Voucher', verbose_name='Voucher'), + ), + ] diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index 41e1a320..c04b453f 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -8,8 +8,102 @@ from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from model_utils.managers import InheritanceManager +from symposion import proposals -# Product Modifiers + +# Condition Types + +class TimeOrStockLimitCondition(models.Model): + ''' Attributes for a condition that is limited by timespan or a count of + purchased or reserved items. + + Attributes: + start_time (Optional[datetime]): When the condition should start being + true. + + end_time (Optional[datetime]): When the condition should stop being + true. + + limit (Optional[int]): How many items may fall under the condition + the condition until it stops being false -- for all users. + ''' + + class Meta: + abstract = True + + start_time = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Start time"), + help_text=_("When the condition should start being true"), + ) + end_time = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("End time"), + help_text=_("When the condition should stop being true."), + ) + limit = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit"), + help_text=_( + "How many times this condition may be applied for all users." + ), + ) + + +class VoucherCondition(models.Model): + ''' A condition is met when a voucher code is in the current cart. ''' + + class Meta: + abstract = True + + voucher = models.OneToOneField( + inventory.Voucher, + on_delete=models.CASCADE, + verbose_name=_("Voucher"), + db_index=True, + ) + + +class IncludedProductCondition(models.Model): + class Meta: + abstract = True + + enabling_products = models.ManyToManyField( + inventory.Product, + verbose_name=_("Including product"), + help_text=_("If one of these products are purchased, this condition " + "is met."), + ) + + +class SpeakerCondition(models.Model): + ''' Conditions that are met if a user is a presenter, or copresenter, + of a specific of presentation. ''' + + class Meta: + abstract = True + + is_presenter = models.BooleanField( + blank=True, + help_text=_("This condition is met if the user is the primary " + "presenter of a presentation."), + ) + is_copresenter = models.BooleanField( + blank=True, + help_text=_("This condition is met if the user is a copresenter of a " + "presentation."), + ) + proposal_kind = models.ManyToManyField( + proposals.models.ProposalKind, + help_text=_("The types of proposals that these users may be " + "presenters of."), + ) + + +# Discounts @python_2_unicode_compatible class DiscountBase(models.Model): @@ -154,7 +248,7 @@ class DiscountForCategory(models.Model): quantity = models.PositiveIntegerField() -class TimeOrStockLimitDiscount(DiscountBase): +class TimeOrStockLimitDiscount(TimeOrStockLimitCondition, DiscountBase): ''' Discounts that are generally available, but are limited by timespan or usage count. This is for e.g. Early Bird discounts. @@ -175,27 +269,8 @@ class TimeOrStockLimitDiscount(DiscountBase): verbose_name = _("discount (time/stock limit)") verbose_name_plural = _("discounts (time/stock limit)") - start_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("Start time"), - help_text=_("This discount will only be available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("End time"), - help_text=_("This discount will only be available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit"), - help_text=_("This discount may only be applied this many times."), - ) - -class VoucherDiscount(DiscountBase): +class VoucherDiscount(VoucherCondition, DiscountBase): ''' Discounts that are enabled when a voucher code is in the current cart. These are normally configured in the Admin page at the same time as creating a Voucher object. @@ -210,15 +285,8 @@ class VoucherDiscount(DiscountBase): verbose_name = _("discount (enabled by voucher)") verbose_name_plural = _("discounts (enabled by voucher)") - voucher = models.OneToOneField( - inventory.Voucher, - on_delete=models.CASCADE, - verbose_name=_("Voucher"), - db_index=True, - ) - -class IncludedProductDiscount(DiscountBase): +class IncludedProductDiscount(IncludedProductCondition, DiscountBase): ''' Discounts that are enabled because another product has been purchased. e.g. A conference ticket includes a free t-shirt. @@ -233,12 +301,28 @@ class IncludedProductDiscount(DiscountBase): verbose_name = _("discount (product inclusions)") verbose_name_plural = _("discounts (product inclusions)") - enabling_products = models.ManyToManyField( - inventory.Product, - verbose_name=_("Including product"), - help_text=_("If one of these products are purchased, the discounts " - "below will be enabled."), - ) + +class SpeakerDiscount(SpeakerCondition, DiscountBase): + ''' Discounts that are enabled because the user is a presenter or + co-presenter of a kind of presentation. + + Attributes: + is_presenter (bool): The condition should be met if the user is a + presenter of a presentation. + + is_copresenter (bool): The condition should be met if the user is a + copresenter of a presentation. + + proposal_kind ([symposion.proposals.models.ProposalKind, ...]): The + kinds of proposals that the user may be a presenter or + copresenter of for this condition to be met. + + ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("discount (speaker)") + verbose_name_plural = _("discounts (speaker)") class RoleDiscount(object): @@ -330,7 +414,7 @@ class FlagBase(models.Model): ) -class TimeOrStockLimitFlag(FlagBase): +class TimeOrStockLimitFlag(TimeOrStockLimitCondition, FlagBase): ''' Product groupings that can be used to enable a product during a specific date range, or when fewer than a limit of products have been sold. @@ -352,28 +436,9 @@ class TimeOrStockLimitFlag(FlagBase): verbose_name = _("flag (time/stock limit)") verbose_name_plural = _("flags (time/stock limit)") - start_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - help_text=_("The number of items under this grouping that can be " - "purchased."), - ) - @python_2_unicode_compatible -class ProductFlag(FlagBase): +class ProductFlag(IncludedProductCondition, FlagBase): ''' The condition is met because a specific product is purchased. Attributes: @@ -389,12 +454,6 @@ class ProductFlag(FlagBase): def __str__(self): return "Enabled by products: " + str(self.enabling_products.all()) - enabling_products = models.ManyToManyField( - inventory.Product, - help_text=_("If one of these products are purchased, this condition " - "is met."), - ) - @python_2_unicode_compatible class CategoryFlag(FlagBase): @@ -422,7 +481,7 @@ class CategoryFlag(FlagBase): @python_2_unicode_compatible -class VoucherFlag(FlagBase): +class VoucherFlag(VoucherCondition, FlagBase): ''' The condition is met because a Voucher is present. This is for e.g. enabling sponsor tickets. ''' @@ -434,7 +493,28 @@ class VoucherFlag(FlagBase): def __str__(self): return "Enabled by voucher: %s" % self.voucher - voucher = models.OneToOneField(inventory.Voucher) + +class SpeakerFlag(SpeakerCondition, FlagBase): + ''' Conditions that are enabled because the user is a presenter or + co-presenter of a kind of presentation. + + Attributes: + is_presenter (bool): The condition should be met if the user is a + presenter of a presentation. + + is_copresenter (bool): The condition should be met if the user is a + copresenter of a presentation. + + proposal_kind ([symposion.proposals.models.ProposalKind, ...]): The + kinds of proposals that the user may be a presenter or + copresenter of for this condition to be met. + + ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (speaker)") + verbose_name_plural = _("flags (speaker)") # @python_2_unicode_compatible diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py new file mode 100644 index 00000000..e3b14ed0 --- /dev/null +++ b/registrasion/tests/test_speaker.py @@ -0,0 +1,209 @@ +import pytz + +from django.core.exceptions import ValidationError + +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.controllers.category import CategoryController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController +from registrasion.controllers.product import ProductController + +from symposion.conference import models as conference_models +from symposion.proposals import models as proposal_models +from symposion.speakers import models as speaker_models +from symposion.reviews.models import promote_proposal + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + + +class SpeakerTestCase(RegistrationCartTestCase): + + @classmethod + def _create_proposals(cls): + ''' Creates two proposals: + + - User 1 will be presenter + - User 2 will be an additional presenter + + Each proposal is of a different ProposalKind. + ''' + + conference = conference_models.Conference.objects.create( + title="TEST CONFERENCE.", + ) + section = conference_models.Section.objects.create( + conference=conference, + name="TEST_SECTION", + slug="testsection", + ) + proposal_section = proposal_models.ProposalSection.objects.create( + section=section, + closed=False, + published=False, + ) + + kind_1 = proposal_models.ProposalKind.objects.create( + section=section, + name="Kind 1", + slug="kind1", + ) + kind_2 = proposal_models.ProposalKind.objects.create( + section=section, + name="Kind 2", + slug="kind2", + ) + + speaker_1 = speaker_models.Speaker.objects.create( + user=cls.USER_1, + name="Speaker 1", + annotation="", + ) + speaker_2 = speaker_models.Speaker.objects.create( + user=cls.USER_2, + name="Speaker 2", + annotation="", + ) + + proposal_1 = proposal_models.ProposalBase.objects.create( + kind=kind_1, + title="Proposal 1", + abstract="Abstract", + description="Description", + speaker=speaker_1, + ) + proposal_models.AdditionalSpeaker.objects.create( + speaker=speaker_2, + proposalbase=proposal_1, + status=proposal_models.AdditionalSpeaker.SPEAKING_STATUS_ACCEPTED, + ) + + proposal_2 = proposal_models.ProposalBase.objects.create( + kind=kind_2, + title="Proposal 2", + abstract="Abstract", + description="Description", + speaker=speaker_1, + ) + proposal_models.AdditionalSpeaker.objects.create( + speaker=speaker_2, + proposalbase=proposal_2, + status=proposal_models.AdditionalSpeaker.SPEAKING_STATUS_ACCEPTED, + ) + + cls.KIND_1 = kind_1 + cls.KIND_2 = kind_2 + cls.PROPOSAL_1 = proposal_1 + cls.PROPOSAL_2 = proposal_2 + + @classmethod + def _create_flag_for_primary_speaker(cls): + ''' Adds flag -- PROD_1 is not available unless user is a primary + presenter of a KIND_1 ''' + flag = conditions.SpeakerFlag.objects.create( + description="User must be presenter", + condition=conditions.FlagBase.ENABLE_IF_TRUE, + is_presenter=True, + is_copresenter=False, + ) + flag.proposal_kind.add(cls.KIND_1) + flag.products.add(cls.PROD_1) + + @classmethod + def _create_flag_for_additional_speaker(cls): + ''' Adds flag -- PROD_1 is not available unless user is a primary + presenter of a KIND_2 ''' + flag = conditions.SpeakerFlag.objects.create( + description="User must be copresenter", + condition=conditions.FlagBase.ENABLE_IF_TRUE, + is_presenter=False, + is_copresenter=True, + ) + flag.proposal_kind.add(cls.KIND_1) + flag.products.add(cls.PROD_1) + + def test_create_proposals(self): + self._create_proposals() + + self.assertIsNotNone(self.KIND_1) + self.assertIsNotNone(self.KIND_2) + self.assertIsNotNone(self.PROPOSAL_1) + self.assertIsNotNone(self.PROPOSAL_2) + + def test_primary_speaker_enables_item(self): + self._create_proposals() + self._create_flag_for_primary_speaker() + + # USER_1 cannot see PROD_1 until proposal is promoted. + available = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available) + + # promote proposal_1 so that USER_1 becomes a speaker + promote_proposal(self.PROPOSAL_1) + + # USER_1 can see PROD_1 + available_1 = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertIn(self.PROD_1, available_1) + # USER_2 can *NOT* see PROD_1 because they're a copresenter + available_2 = ProductController.available_products( + self.USER_2, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available_2) + + def test_additional_speaker_enables_item(self): + self._create_proposals() + self._create_flag_for_additional_speaker() + + # USER_2 cannot see PROD_1 until proposal is promoted. + available = ProductController.available_products( + self.USER_2, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available) + + # promote proposal_1 so that USER_2 becomes an additional speaker + promote_proposal(self.PROPOSAL_1) + + # USER_2 can see PROD_1 + available_2 = ProductController.available_products( + self.USER_2, + products=[self.PROD_1], + ) + self.assertIn(self.PROD_1, available_2) + # USER_1 can *NOT* see PROD_1 because they're a presenter + available_1 = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available_1) + + def test_speaker_on_different_proposal_kind_does_not_enable_item(self): + self._create_proposals() + self._create_flag_for_primary_speaker() + + # USER_1 cannot see PROD_1 until proposal is promoted. + available = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available) + + # promote proposal_2 so that USER_1 becomes a speaker, but of + # KIND_2, which is not covered by this condition + promote_proposal(self.PROPOSAL_2) + + # USER_1 cannot see PROD_1 + available_1 = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available_1)