Merge branch 'chrisjrn/speaker_conditions'

Fixes #60, Fixes #61
This commit is contained in:
Christopher Neugebauer 2016-09-04 14:21:54 +10:00
commit b8cfb57269
5 changed files with 493 additions and 83 deletions

View file

@ -12,6 +12,7 @@ class EffectsDisplayMixin(object):
def effects(self, obj): def effects(self, obj):
return list(obj.effects()) return list(obj.effects())
# Inventory admin # Inventory admin
@ -69,14 +70,11 @@ class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin):
@admin.register(conditions.IncludedProductDiscount) @admin.register(conditions.IncludedProductDiscount)
class IncludedProductDiscountAdmin(admin.ModelAdmin): class IncludedProductDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin):
def enablers(self, obj): def enablers(self, obj):
return list(obj.enabling_products.all()) return list(obj.enabling_products.all())
def effects(self, obj):
return list(obj.effects())
list_display = ("description", "enablers", "effects") list_display = ("description", "enablers", "effects")
inlines = [ inlines = [
@ -84,6 +82,20 @@ class IncludedProductDiscountAdmin(admin.ModelAdmin):
DiscountForCategoryInline, 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 # Vouchers
@ -172,18 +184,13 @@ class CategoryFlagAdmin(
ordering = ("enabling_category",) ordering = ("enabling_category",)
# Enabling conditions @admin.register(conditions.SpeakerFlag)
@admin.register(conditions.TimeOrStockLimitFlag) class SpeakerFlagAdmin(nested_admin.NestedAdmin, EffectsDisplayMixin):
class TimeOrStockLimitFlagAdmin(
nested_admin.NestedAdmin,
EffectsDisplayMixin):
model = conditions.TimeOrStockLimitFlag
list_display = ( model = conditions.SpeakerFlag
"description", fields = ("description", "is_presenter", "is_copresenter", "proposal_kind",
"start_time", "products", "categories")
"end_time",
"limit", list_display = ("description", "is_presenter", "is_copresenter", "effects")
"effects",
) ordering = ("-is_presenter", "-is_copresenter")
ordering = ("start_time", "end_time", "limit")

View file

@ -25,6 +25,8 @@ class ConditionController(object):
conditions.CategoryFlag: CategoryConditionController, conditions.CategoryFlag: CategoryConditionController,
conditions.IncludedProductDiscount: ProductConditionController, conditions.IncludedProductDiscount: ProductConditionController,
conditions.ProductFlag: ProductConditionController, conditions.ProductFlag: ProductConditionController,
conditions.SpeakerFlag: SpeakerConditionController,
conditions.SpeakerDiscount: SpeakerConditionController,
conditions.TimeOrStockLimitDiscount: conditions.TimeOrStockLimitDiscount:
TimeOrStockLimitDiscountController, TimeOrStockLimitDiscountController,
conditions.TimeOrStockLimitFlag: conditions.TimeOrStockLimitFlag:
@ -299,3 +301,25 @@ class VoucherConditionController(IsMetByFilter, ConditionController):
a voucher that invokes that item's condition in one of their carts. ''' a voucher that invokes that item's condition in one of their carts. '''
return queryset.filter(voucher__cart__user=user) 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)

View file

@ -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'),
),
]

View file

@ -8,8 +8,102 @@ from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_utils.managers import InheritanceManager 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 @python_2_unicode_compatible
class DiscountBase(models.Model): class DiscountBase(models.Model):
@ -154,7 +248,7 @@ class DiscountForCategory(models.Model):
quantity = models.PositiveIntegerField() quantity = models.PositiveIntegerField()
class TimeOrStockLimitDiscount(DiscountBase): class TimeOrStockLimitDiscount(TimeOrStockLimitCondition, DiscountBase):
''' Discounts that are generally available, but are limited by timespan or ''' Discounts that are generally available, but are limited by timespan or
usage count. This is for e.g. Early Bird discounts. 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 = _("discount (time/stock limit)")
verbose_name_plural = _("discounts (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(VoucherCondition, DiscountBase):
class VoucherDiscount(DiscountBase):
''' Discounts that are enabled when a voucher code is in the current ''' 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 cart. These are normally configured in the Admin page at the same time as
creating a Voucher object. creating a Voucher object.
@ -210,15 +285,8 @@ class VoucherDiscount(DiscountBase):
verbose_name = _("discount (enabled by voucher)") verbose_name = _("discount (enabled by voucher)")
verbose_name_plural = _("discounts (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(IncludedProductCondition, DiscountBase):
class IncludedProductDiscount(DiscountBase):
''' Discounts that are enabled because another product has been purchased. ''' Discounts that are enabled because another product has been purchased.
e.g. A conference ticket includes a free t-shirt. e.g. A conference ticket includes a free t-shirt.
@ -233,12 +301,28 @@ class IncludedProductDiscount(DiscountBase):
verbose_name = _("discount (product inclusions)") verbose_name = _("discount (product inclusions)")
verbose_name_plural = _("discounts (product inclusions)") verbose_name_plural = _("discounts (product inclusions)")
enabling_products = models.ManyToManyField(
inventory.Product, class SpeakerDiscount(SpeakerCondition, DiscountBase):
verbose_name=_("Including product"), ''' Discounts that are enabled because the user is a presenter or
help_text=_("If one of these products are purchased, the discounts " co-presenter of a kind of presentation.
"below will be enabled."),
) 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): 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 ''' 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 specific date range, or when fewer than a limit of products have been
sold. sold.
@ -352,28 +436,9 @@ class TimeOrStockLimitFlag(FlagBase):
verbose_name = _("flag (time/stock limit)") verbose_name = _("flag (time/stock limit)")
verbose_name_plural = _("flags (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 @python_2_unicode_compatible
class ProductFlag(FlagBase): class ProductFlag(IncludedProductCondition, FlagBase):
''' The condition is met because a specific product is purchased. ''' The condition is met because a specific product is purchased.
Attributes: Attributes:
@ -389,12 +454,6 @@ class ProductFlag(FlagBase):
def __str__(self): def __str__(self):
return "Enabled by products: " + str(self.enabling_products.all()) 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 @python_2_unicode_compatible
class CategoryFlag(FlagBase): class CategoryFlag(FlagBase):
@ -422,7 +481,7 @@ class CategoryFlag(FlagBase):
@python_2_unicode_compatible @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. ''' The condition is met because a Voucher is present. This is for e.g.
enabling sponsor tickets. ''' enabling sponsor tickets. '''
@ -434,7 +493,28 @@ class VoucherFlag(FlagBase):
def __str__(self): def __str__(self):
return "Enabled by voucher: %s" % self.voucher 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 # @python_2_unicode_compatible

View file

@ -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)