Changeset - d9e433659d01
[Not reviewed]
design/design.md
Show inline comments
 
new file 100644
 
# Logic
 

	
 
## Definitions
 
- User has one 'active Cart' at a time. The Cart remains active until a paid Invoice is attached to it.
 
- A 'paid Cart' is a Cart with a paid Invoice attached to it, where the Invoice has not been voided.
 
- An unpaid Cart is 'reserved' if
 
 - CURRENT_TIME - "Time last updated" <= max(reservation duration of Products in Cart),
 
 - A Voucher was added and CURRENT_TIME - "Time last updated" < VOUCHER_RESERVATION_TIME (15 minutes?)
 
- An Item is 'reserved' if:
 
  - it belongs to a reserved Cart
 
  - it belongs to a paid Cart
 
- A Cart can have any number of Items added to it, subject to limits.
 

	
 

	
 
## Entering Vouchers
 
- Vouchers are attached to Carts
 
- A user can enter codes for as many different Vouchers as they like.
 
- A Voucher is added to the Cart if the number of paid or reserved Carts containing the Voucher is less than the "total available" for the voucher.
 
- A cart is invalid if it contains a voucher that has been overused
 

	
 

	
 
## Are products available?
 

	
 
- Availability is determined by the number of items we want to add to the cart: items_to_add
 

	
 
- If items_to_add + count(Product in their active and paid Carts) > "Limit per user" for the Product, the Product is "unavailable".
 
- If the Product belongs to an exhausted Ceiling, the Product is "unavailable".
 
- Otherwise, the product is available
 

	
 

	
 
## Displaying Products:
 

	
 
- If there is at least one mandatory EnablingCondition attached to the Product, display it only if all EnablingConditions are met
 
- If there is at least one EnablingCondition attached to the Product, display it only if at least one EnablingCondition is met
 
- If there are zero EnablingConditions attached to the Product, display it
 
- If the product is not available for items_to_add=0, mark it as "unavailable"
 

	
 
- If the Product is displayed and available, its price is the price for the Product, minus the greatest Discount available to this Cart and Product
 

	
 
- The product is displayed per the rendering characteristics of the Category it belongs to
 

	
 

	
 
## Displaying Categories
 

	
 
- If the Category contains only "unavailable" Products, mark it as "unavailable"
 
- If the Category contains no displayed Products, do not display the Category
 
- If the Category contains at least one EnablingCondition, display it only if at least one EnablingCondition is met
 
- If the Category contains no EnablingConditions, display it
 

	
 

	
 
## Exhausting Ceilings
 

	
 
- Exhaustion is determined by the number of items we want to add to the cart: items_to_add
 

	
 
- A ceiling is exhausted if:
 
 - Its start date has not yet been reached
 
 - Its end date has been exceeded
 
 - items_to_add + sum(paid and reserved Items for each Product in the ceiling) > Total available
 

	
 

	
 
## Applying Discounts
 

	
 
- Discounts only apply to the current cart
 
- Discounts can be applied to multiple carts until the user has exhausted the quantity for each product attached to the discount.
 
- Only one discount discount can be applied to each single item. Discounts are applied as follows:
 
 - All non-exhausted discounts for the product or its category are ordered by value
 
 - The highest discount is applied for the lower of the quantity of the product in the cart, or the remaining quantity from this discount
 
 - If the quantity remaining is non-zero, apply the next available discount
 

	
 
- Individual discount objects should not contain more than one DiscountForProduct for the same product
 
- Individual discount objects should not contain more than one DiscountForCategory for the same category
 
- Individual discount objects should not contain a discount for both a product and its category
 

	
 

	
 
## Adding Items to the Cart
 

	
 
- Products that are not displayed may not be added to a Cart
 
- The requested number of items must be available for those items to be added to a Cart
 
- If a different price applies to a Product when it is added to a cart, add at the new price, and display an alert to the user
 
- If a discount is used when adding a Product to the cart, add the discount as well
 
- Adding an item resets the "Time last updated" for the cart
 
- Each time carts have items added or removed, the revision number is updated
 

	
 

	
 
## Generating an invoice
 

	
 
- User can ask to 'check out' the active Cart. Doing so generates an Invoice. The invoice corresponds to a revision number of the cart.
 
- Checking out the active Cart resets the "Time last updated" for the cart.
 
- The invoice represents the current state of the cart.
 
- If the revision number for the cart is different to the cart's revision number for the invoice, the invoice is void.
 
- The invoice is void if
 

	
 

	
 
## Paying an invoice
 

	
 
- A payment can only be attached to an invoice if all of the items in it are available at the time payment is processed
 

	
 
### One-Shot
 
- Update the "Time last updated" for the cart based on the expected time it takes for a payment to complete
 
- Verify that all items are available, and if so:
 
- Proceed to make payment
 
- Apply payment record from amount received
 

	
 

	
 
### Authorization-based approach:
 
- Capture an authorization on the card
 
- Verify that all items are available, and if so:
 
- Apply payment record
 
- Take payment
 

	
 

	
 
# Registration workflow:
 

	
 
## User has not taken a guided registration yet:
 

	
 
User is shown two options:
 

	
 
1. Undertake guided registration ("for current user")
 
1. Purchase vouchers
 

	
 

	
 
## User has not purchased a ticket, and wishes to:
 

	
 
This gives the user a guided registration process.
 

	
 
1. Take list of categories, sorted by display order, and display the next lowest enabled & available category
 
1. Take user to category page
 
1. User can click "back" to go to previous screen, or "next" to go the next lowest enabled & available category
 

	
 
Once all categories have been seen:
 
1. Ask for badge information -- badge information is *not* the same as the invoicee.
 
1. User is taken to the "user has purchased a ticket" workflow
 

	
 

	
 
## User is buying vouchers
 
TODO: Consider separate workflow for purchasing ticket vouchers.
 

	
 

	
 
## User has completed a guided registration or purchased vouchers
 

	
 
1. Show list of products that are pending purchase.
 
1. Show list of categories + badge information, as well as 'checkout' button if the user has items in their current cart
 

	
 

	
 
## Category page
 

	
 
- User can enter a voucher at any time
 
- User is shown the list of products that have been paid for
 
- User has the option to add/remove products that are in the current cart
 

	
 

	
 
## Checkout
 

	
 
1. Ask for invoicing details (pre-fill from previous invoice?)
 
1. Ask for payment
 

	
 

	
 
# User Models
 

	
 
- Profile:
 
 - User
 
 - Has done guided registration?
 
 - Badge
 
 -
 

	
 
## Transaction Models
 

	
 
- Cart:
 
 - User
 
 - {Items}
 
 - {Voucher}
 
 - {DiscountItems}
 
 - Time last updated
 
 - Revision Number
 
 - Active?
 

	
 
- Item
 
 - Product
 
 - Quantity
 

	
 
- DiscountItem
 
 - Product
 
 - Discount
 
 - Quantity
 

	
 
- Invoice:
 
 - Invoice number
 
 - User
 
 - Cart
 
 - Cart Revision
 
 - {Line Items}
 
 - (Invoice Details)
 
 - {Payments}
 
 - Voided?
 

	
 
- LineItem
 
 - Description
 
 - Quantity
 
 - Price
 

	
 
- Payment
 
 - Time
 
 - Amount
 
 - Reference
 

	
 

	
 
## Inventory Model
 

	
 
- Product:
 
 - Name
 
 - Description
 
 - Category
 
 - Price
 
 - Limit per user
 
 - Reservation duration
 
 - Display order
 
 - {Ceilings}
 

	
 

	
 
- Voucher
 
 - Description
 
 - Code
 
 - Total available
 

	
 

	
 
- Category?
 
 - Name
 
 - Description
 
 - Display Order
 
 - Rendering Style
 

	
 

	
 
## Product Modifiers
 

	
 
- Discount:
 
 - Description
 
 - {DiscountForProduct}
 
 - {DiscountForCategory}
 

	
 
 - Discount Types:
 
    - TimeOrStockLimitDiscount:
 
     * A discount that is available for a limited amount of time, e.g. Early Bird sales *
 
     - Start date
 
     - End date
 
     - Total available
 

	
 
    - VoucherDiscount:
 
     * A discount that is available to a specific voucher *
 
     - Voucher
 

	
 
    - RoleDiscount
 
     * A discount that is available to a specific role *
 
     - Role
 

	
 
    - IncludedProductDiscount:
 
     * A discount that is available because another product has been purchased *
 
     - {Parent Product}
 

	
 
- DiscountForProduct
 
 - Product
 
 - Amount
 
 - Percentage
 
 - Quantity
 

	
 
- DiscountForCategory
 
 - Category
 
 - Percentage
 
 - Quantity
 

	
 

	
 
- EnablingCondition:
 
 - Description
 
 - Mandatory?
 
 - {Products}
 
 - {Categories}
 

	
 
 - EnablingCondition Types:
 
   - ProductEnablingCondition:
 
    * Enabling because the user has purchased a specific product *
 
    - {Products that enable}
 

	
 
   - CategoryEnablingCondition:
 
    * Enabling because the user has purchased a product in a specific category *
 
    - {Categories that enable}
 

	
 
   - VoucherEnablingCondition:
 
    * Enabling because the user has entered a voucher code *
 
     - Voucher
 

	
 
   - RoleEnablingCondition:
 
     * Enabling because the user has a specific role *
 
     - Role
 

	
 
   - TimeOrStockLimitEnablingCondition:
 
    * Enabling because a time condition has been met, or a number of items underneath it have not been sold *
 
    - Start date
 
    - End date
 
    - Total available
registrasion/__init__.py
Show inline comments
 
new file 100644
 
__version__ = "0.1a1"
 

	
 
default_app_config = "registrasion.apps.RegistrasionConfig"
registrasion/admin.py
Show inline comments
 
new file 100644
 
from django.contrib import admin
 
from django.utils.translation import ugettext_lazy as _
 

	
 
import nested_admin
 

	
 
from registrasion import models as rego
 

	
 

	
 

	
 
# Inventory admin
 

	
 
class ProductInline(admin.TabularInline):
 
    model = rego.Product
 

	
 

	
 
@admin.register(rego.Category)
 
class CategoryAdmin(admin.ModelAdmin):
 
    model = rego.Category
 
    verbose_name_plural = _("Categories")
 
    inlines = [
 
        ProductInline,
 
    ]
 

	
 
admin.site.register(rego.Product)
 

	
 

	
 
# Discounts
 

	
 
class DiscountForProductInline(admin.TabularInline):
 
    model = rego.DiscountForProduct
 
    verbose_name = _("Product included in discount")
 
    verbose_name_plural = _("Products included in discount")
 

	
 

	
 
class DiscountForCategoryInline(admin.TabularInline):
 
    model = rego.DiscountForCategory
 
    verbose_name = _("Category included in discount")
 
    verbose_name_plural = _("Categories included in discount")
 

	
 

	
 
@admin.register(
 
    rego.TimeOrStockLimitDiscount,
 
    rego.IncludedProductDiscount,
 
)
 
class DiscountAdmin(admin.ModelAdmin):
 
    inlines = [
 
        DiscountForProductInline,
 
        DiscountForCategoryInline,
 
    ]
 

	
 

	
 
# Vouchers
 

	
 
class VoucherDiscountInline(nested_admin.NestedStackedInline):
 
    model = rego.VoucherDiscount
 
    verbose_name = _("Discount")
 

	
 
    # TODO work out why we're allowed to add more than one?
 
    max_num = 1
 
    extra = 1
 
    inlines = [
 
        DiscountForProductInline,
 
        DiscountForCategoryInline,
 
    ]
 

	
 

	
 
class VoucherEnablingConditionInline(nested_admin.NestedStackedInline):
 
    model = rego.VoucherEnablingCondition
 
    verbose_name = _("Product and category enabled by voucher")
 
    verbose_name_plural = _("Products and categories enabled by voucher")
 

	
 
    # TODO work out why we're allowed to add more than one?
 
    max_num = 1
 
    extra = 1
 

	
 

	
 
@admin.register(rego.Voucher)
 
class VoucherAdmin(nested_admin.NestedAdmin):
 
    model = rego.Voucher
 
    inlines = [
 
        VoucherDiscountInline,
 
        VoucherEnablingConditionInline,
 
    ]
registrasion/apps.py
Show inline comments
 
new file 100644
 
from __future__ import unicode_literals
 
from django.apps import AppConfig
 

	
 

	
 
class RegistrasionConfig(AppConfig):
 
    name = "registrasion"
 
    label = "registrasion"
 
    verbose_name = "Registrasion"
registrasion/cart.py
Show inline comments
 
new file 100644
 
import datetime
 
import itertools
 

	
 
from django.core.exceptions import ObjectDoesNotExist
 
from django.core.exceptions import ValidationError
 
from django.db.models import Avg, Min, Max, Sum
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 

	
 
from conditions import ConditionController
 
from controllers import ProductController
 

	
 

	
 
class CartController(object):
 

	
 
    def __init__(self, cart):
 
        self.cart = cart
 

	
 
    @staticmethod
 
    def for_user(user):
 
        ''' Returns the user's current cart, or creates a new cart
 
        if there isn't one ready yet. '''
 

	
 
        try:
 
            existing = rego.Cart.objects.get(user=user, active=True)
 
        except ObjectDoesNotExist:
 
            existing = rego.Cart.objects.create(
 
                user=user,
 
                time_last_updated=timezone.now(),
 
                reservation_duration=datetime.timedelta(),
 
                 )
 
            existing.save()
 
        return CartController(existing)
 

	
 

	
 
    def extend_reservation(self):
 
        ''' Updates the cart's time last updated value, which is used to
 
        determine whether the cart has reserved the items and discounts it
 
        holds. '''
 

	
 
        reservations = [datetime.timedelta()]
 

	
 
        # If we have vouchers, we're entitled to an hour at minimum.
 
        if len(self.cart.vouchers.all()) >= 1:
 
            reservations.append(rego.Voucher.RESERVATION_DURATION)
 

	
 
        # Else, it's the maximum of the included products
 
        items = rego.ProductItem.objects.filter(cart=self.cart)
 
        agg = items.aggregate(Max("product__reservation_duration"))
 
        product_max = agg["product__reservation_duration__max"]
 

	
 
        if product_max is not None:
 
            reservations.append(product_max)
 

	
 
        self.cart.time_last_updated = timezone.now()
 
        self.cart.reservation_duration = max(reservations)
 

	
 

	
 
    def add_to_cart(self, product, quantity):
 
        ''' Adds _quantity_ of the given _product_ to the cart. Raises
 
        ValidationError if constraints are violated.'''
 

	
 
        prod = ProductController(product)
 

	
 
        # TODO: Check enabling conditions for product for user
 

	
 
        if not prod.can_add_with_enabling_conditions(self.cart.user, quantity):
 
            raise ValidationError("Not enough of that product left (ec)")
 

	
 
        if not prod.user_can_add_within_limit(self.cart.user, quantity):
 
            raise ValidationError("Not enough of that product left (user)")
 

	
 
        try:
 
            # Try to update an existing item within this cart if possible.
 
            product_item = rego.ProductItem.objects.get(
 
                cart=self.cart,
 
                product=product)
 
            product_item.quantity += quantity
 
        except ObjectDoesNotExist:
 
            product_item = rego.ProductItem.objects.create(
 
                cart=self.cart,
 
                product=product,
 
                quantity=quantity,
 
            )
 
        product_item.save()
 

	
 
        self.recalculate_discounts()
 

	
 
        self.extend_reservation()
 
        self.cart.revision += 1
 
        self.cart.save()
 

	
 

	
 
    def apply_voucher(self, voucher):
 
        ''' Applies the given voucher to this cart. '''
 

	
 
        # TODO: is it valid for a cart to re-add a voucher that they have?
 

	
 
        # Is voucher exhausted?
 
        active_carts = rego.Cart.reserved_carts()
 
        carts_with_voucher = active_carts.filter(vouchers=voucher)
 
        if len(carts_with_voucher) >= voucher.limit:
 
            raise ValidationError("This voucher is no longer available")
 

	
 
        # If successful...
 
        self.cart.vouchers.add(voucher)
 

	
 
        self.extend_reservation()
 
        self.cart.revision += 1
 
        self.cart.save()
 

	
 

	
 
    def validate_cart(self):
 
        ''' Determines whether the status of the current cart is valid;
 
        this is normally called before generating or paying an invoice '''
 

	
 
        is_reserved = self.cart in rego.Cart.reserved_carts()
 

	
 
        # TODO: validate vouchers
 

	
 
        items = rego.ProductItem.objects.filter(cart=self.cart)
 
        for item in items:
 
            # per-user limits are tested at add time, and are unliklely to change
 
            prod = ProductController(item.product)
 

	
 
            # If the cart is not reserved, we need to see if we can re-reserve
 
            quantity = 0 if is_reserved else item.quantity
 

	
 
            if not prod.can_add_with_enabling_conditions(self.cart.user, quantity):
 
                raise ValidationError("Products are no longer available")
 

	
 
        # Validate the discounts
 
        discount_items = rego.DiscountItem.objects.filter(cart=self.cart)
 
        seen_discounts = set()
 

	
 
        for discount_item in discount_items:
 
            discount = discount_item.discount
 
            if discount in seen_discounts:
 
                continue
 
            seen_discounts.add(discount)
 
            real_discount = rego.DiscountBase.objects.get_subclass(pk=discount.pk)
 
            cond = ConditionController.for_condition(real_discount)
 

	
 
            quantity = 0 if is_reserved else discount_item.quantity
 

	
 
            if not cond.is_met(self.cart.user, quantity):
 
                raise ValidationError("Discounts are no longer available")
 

	
 

	
 

	
 
    def recalculate_discounts(self):
 
        ''' Calculates all of the discounts available for this product.
 
        NB should be transactional, and it's terribly inefficient.
 
        '''
 

	
 
        # Delete the existing entries.
 
        rego.DiscountItem.objects.filter(cart=self.cart).delete()
 

	
 
        for item in self.cart.productitem_set.all():
 
            self._add_discount(item.product, item.quantity)
 

	
 

	
 
    def _add_discount(self, product, quantity):
 
        ''' Calculates the best available discounts for this product.
 
        NB this will be super-inefficient in aggregate because discounts will be
 
        re-tested for each product. We should work on that.'''
 

	
 
        prod = ProductController(product)
 
        discounts = prod.available_discounts(self.cart.user)
 
        discounts.sort(key=lambda discount: discount.value)
 

	
 
        for discount in reversed(discounts):
 
            if quantity == 0:
 
                break
 

	
 
            # Get the count of past uses of this discount condition
 
            # as this affects the total amount we're allowed to use now.
 
            past_uses = rego.DiscountItem.objects.filter(
 
                cart__active=False,
 
                discount=discount.discount,
 
                product=product,
 
            )
 
            agg = past_uses.aggregate(Sum("quantity"))
 
            past_uses = agg["quantity__sum"]
 
            if past_uses is None:
 
                past_uses = 0
 
            if past_uses == discount.condition.quantity:
 
                continue
 

	
 
            # Get a provisional instance for this DiscountItem
 
            # with the quantity set to as much as we have in the cart
 
            discount_item = rego.DiscountItem.objects.create(
 
                product=product,
 
                cart=self.cart,
 
                discount=discount.discount,
 
                quantity=quantity,
 
            )
 

	
 
            # Truncate the quantity for this DiscountItem if we exceed quantity
 
            ours = discount_item.quantity
 
            allowed = discount.condition.quantity - past_uses
 
            if ours > allowed:
 
                discount_item.quantity = allowed
 
                # Update the remaining quantity.
 
                quantity = ours - allowed
 
            else:
 
                quantity = 0
 
                
 
            discount_item.save()
registrasion/conditions.py
Show inline comments
 
new file 100644
 
from django.db.models import F, Q
 
from django.db.models import Sum
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 

	
 

	
 
class ConditionController(object):
 
    ''' Base class for testing conditions that activate EnablingCondition
 
    or Discount objects. '''
 

	
 
    def __init__(self):
 
        pass
 

	
 
    @staticmethod
 
    def for_condition(condition):
 
        CONTROLLERS = {
 
            rego.CategoryEnablingCondition : CategoryConditionController,
 
            rego.IncludedProductDiscount : ProductConditionController,
 
            rego.ProductEnablingCondition : ProductConditionController,
 
            rego.TimeOrStockLimitDiscount :
 
                TimeOrStockLimitConditionController,
 
            rego.TimeOrStockLimitEnablingCondition :
 
                TimeOrStockLimitConditionController,
 
            rego.VoucherDiscount : VoucherConditionController,
 
            rego.VoucherEnablingCondition : VoucherConditionController,
 
        }
 

	
 
        try:
 
            return CONTROLLERS[type(condition)](condition)
 
        except KeyError:
 
            return ConditionController()
 

	
 

	
 
    def is_met(self, user, quantity):
 
        return True
 

	
 

	
 
class CategoryConditionController(ConditionController):
 

	
 
    def __init__(self, condition):
 
        self.condition = condition
 

	
 
    def is_met(self, user, quantity):
 
        ''' returns True if the user has a product from a category that invokes
 
        this condition in one of their carts '''
 

	
 
        carts = rego.Cart.objects.filter(user=user)
 
        enabling_products = rego.Product.objects.filter(
 
            category=self.condition.enabling_category)
 
        products = rego.ProductItem.objects.filter(cart=carts,
 
            product=enabling_products)
 
        return len(products) > 0
 

	
 

	
 
class ProductConditionController(ConditionController):
 
    ''' Condition tests for ProductEnablingCondition and
 
    IncludedProductDiscount. '''
 

	
 
    def __init__(self, condition):
 
        self.condition = condition
 

	
 
    def is_met(self, user, quantity):
 
        ''' returns True if the user has a product that invokes this
 
        condition in one of their carts '''
 

	
 
        carts = rego.Cart.objects.filter(user=user)
 
        products = rego.ProductItem.objects.filter(cart=carts,
 
            product=self.condition.enabling_products.all())
 
        return len(products) > 0
 

	
 

	
 
class TimeOrStockLimitConditionController(ConditionController):
 
    ''' Condition tests for TimeOrStockLimit EnablingCondition and
 
    Discount.'''
 

	
 
    def __init__(self, ceiling):
 
        self.ceiling = ceiling
 

	
 

	
 
    def is_met(self, user, quantity):
 
        ''' returns True if adding _quantity_ of _product_ will not vioilate
 
        this ceiling. '''
 

	
 
        # Test date range
 
        if not self.test_date_range():
 
            return False
 

	
 
        # Test limits
 
        if not self.test_limits(quantity):
 
            return False
 

	
 
        # All limits have been met
 
        return True
 

	
 

	
 
    def test_date_range(self):
 
        now = timezone.now()
 

	
 
        if self.ceiling.start_time is not None:
 
            if now < self.ceiling.start_time:
 
                return False
 

	
 
        if self.ceiling.end_time is not None:
 
            if now > self.ceiling.end_time:
 
                return False
 

	
 
        return True
 

	
 

	
 
    def _products(self):
 
        ''' Abstracts away the product list, becuase enabling conditions
 
        list products differently to discounts. '''
 
        if isinstance(self.ceiling, rego.TimeOrStockLimitEnablingCondition):
 
            category_products = rego.Product.objects.filter(
 
                category=self.ceiling.categories.all()
 
            )
 
            return self.ceiling.products.all() | category_products
 
        else:
 
            categories = rego.Category.objects.filter(
 
                discountforcategory__discount=self.ceiling
 
            )
 
            return rego.Product.objects.filter(
 
                Q(discountforproduct__discount=self.ceiling) |
 
                Q(category=categories.all())
 
            )
 

	
 

	
 
    def test_limits(self, quantity):
 
        if self.ceiling.limit is None:
 
            return True
 

	
 
        reserved_carts = rego.Cart.reserved_carts()
 
        product_items = rego.ProductItem.objects.filter(
 
            product=self._products().all()
 
        )
 
        product_items = product_items.filter(cart=reserved_carts)
 

	
 
        agg = product_items.aggregate(Sum("quantity"))
 
        count = agg["quantity__sum"]
 
        if count is None:
 
            count = 0
 

	
 
        if count + quantity > self.ceiling.limit:
 
            return False
 

	
 
        return True
 

	
 

	
 
class VoucherConditionController(ConditionController):
 
    ''' Condition test for VoucherEnablingCondition and VoucherDiscount.'''
 

	
 
    def __init__(self, condition):
 
        self.condition = condition
 

	
 
    def is_met(self, user, quantity):
 
        ''' returns True if the user has the given voucher attached. '''
 
        carts = rego.Cart.objects.filter(user=user,
 
            vouchers=self.condition.voucher)
 
        return len(carts) > 0
registrasion/controllers.py
Show inline comments
 
new file 100644
 
import itertools
 

	
 
from collections import namedtuple
 

	
 
from django.db.models import F, Q
 
from registrasion import models as rego
 

	
 
from conditions import ConditionController
 

	
 
DiscountEnabler = namedtuple("DiscountEnabler", ("discount", "condition", "value"))
 

	
 
class ProductController(object):
 

	
 
    def __init__(self, product):
 
        self.product = product
 

	
 
    def user_can_add_within_limit(self, user, quantity):
 
        ''' Return true if the user is able to add _quantity_ to their count of
 
        this Product without exceeding _limit_per_user_.'''
 

	
 
        carts = rego.Cart.objects.filter(user=user)
 
        items = rego.ProductItem.objects.filter(product=self.product, cart=carts)
 

	
 
        count = 0
 
        for item in items:
 
            count += item.quantity
 

	
 
        if quantity + count > self.product.limit_per_user:
 
            return False
 
        else:
 
            return True
 

	
 
    def can_add_with_enabling_conditions(self, user, quantity):
 
        ''' Returns true if the user is able to add _quantity_ to their count
 
        of this Product without exceeding the ceilings the product is attached
 
        to. '''
 

	
 
        conditions = rego.EnablingConditionBase.objects.filter(
 
            Q(products=self.product) | Q(categories=self.product.category)
 
        ).select_subclasses()
 

	
 
        mandatory_violated = False
 
        non_mandatory_met = False
 

	
 
        for condition in conditions:
 
            cond = ConditionController.for_condition(condition)
 
            met = cond.is_met(user, quantity)
 

	
 
            if condition.mandatory and not met:
 
                mandatory_violated = True
 
                break
 
            if met:
 
                non_mandatory_met = True
 

	
 
        if mandatory_violated:
 
            # All mandatory conditions must be met
 
            return False
 

	
 
        if len(conditions) > 0 and not non_mandatory_met:
 
            # If there's any non-mandatory conditions, one must be met
 
            return False
 

	
 
        return True
 

	
 

	
 
    def get_enabler(self, condition):
 
        if condition.percentage is not None:
 
            value = condition.percentage * self.product.price
 
        else:
 
            value = condition.price
 
        return DiscountEnabler(
 
            discount=condition.discount,
 
            condition=condition,
 
            value=value
 
        )
 

	
 
    def available_discounts(self, user):
 
        ''' Returns the set of available discounts for this user, for this
 
        product. '''
 

	
 
        product_discounts = rego.DiscountForProduct.objects.filter(
 
            product=self.product)
 
        category_discounts = rego.DiscountForCategory.objects.filter(
 
            category=self.product.category
 
        )
 

	
 
        potential_discounts = set(itertools.chain(
 
            (self.get_enabler(i) for i in product_discounts),
 
            (self.get_enabler(i) for i in category_discounts),
 
        ))
 

	
 
        discounts = []
 
        for discount in potential_discounts:
 
            real_discount = rego.DiscountBase.objects.get_subclass(pk=discount.discount.pk)
 
            cond = ConditionController.for_condition(real_discount)
 
            if cond.is_met(user, 0):
 
                discounts.append(discount)
 

	
 
        return discounts
registrasion/invoice.py
Show inline comments
 
new file 100644
 
from decimal import Decimal
 
from django.core.exceptions import ObjectDoesNotExist
 
from django.db.models import Avg, Min, Max, Sum
 

	
 
from registrasion import models as rego
 

	
 
from cart import CartController
 

	
 
class InvoiceController(object):
 

	
 
    def __init__(self, invoice):
 
        self.invoice = invoice
 

	
 
    @classmethod
 
    def for_cart(cls, cart):
 
        ''' Returns an invoice object for a given cart at its current revision.
 
        If such an invoice does not exist, the cart is validated, and if valid,
 
        an invoice is generated.'''
 

	
 
        try:
 
            invoice = rego.Invoice.objects.get(
 
                cart=cart, cart_revision=cart.revision)
 
        except ObjectDoesNotExist:
 
            cart_controller = CartController(cart)
 
            cart_controller.validate_cart() # Raises ValidationError on fail.
 
            invoice = cls._generate(cart)
 

	
 
        return InvoiceController(invoice)
 

	
 

	
 
    @classmethod
 
    def resolve_discount_value(cls, item):
 
        try:
 
            condition = rego.DiscountForProduct.objects.get(
 
                discount=item.discount,
 
                product=item.product
 
            )
 
        except ObjectDoesNotExist:
 
            condition = rego.DiscountForCategory.objects.get(
 
                discount=item.discount,
 
                category=item.product.category
 
            )
 
        if condition.percentage is not None:
 
            value = item.product.price * (condition.percentage / 100)
 
        else:
 
            value = condition.price
 
        return value
 

	
 

	
 
    @classmethod
 
    def _generate(cls, cart):
 
        ''' Generates an invoice for the given cart. '''
 
        invoice = rego.Invoice.objects.create(
 
            user=cart.user,
 
            cart=cart,
 
            cart_revision=cart.revision,
 
            value=Decimal()
 
        )
 
        invoice.save()
 

	
 
        # TODO: calculate line items.
 
        product_items = rego.ProductItem.objects.filter(cart=cart)
 
        discount_items = rego.DiscountItem.objects.filter(cart=cart)
 
        invoice_value = Decimal()
 
        for item in product_items:
 
            line_item = rego.LineItem.objects.create(
 
                invoice=invoice,
 
                description=item.product.name,
 
                quantity=item.quantity,
 
                price=item.product.price,
 
            )
 
            line_item.save()
 
            invoice_value += line_item.quantity * line_item.price
 

	
 
        for item in discount_items:
 

	
 
            line_item = rego.LineItem.objects.create(
 
                invoice=invoice,
 
                description=item.discount.description,
 
                quantity=item.quantity,
 
                price=cls.resolve_discount_value(item) * -1,
 
            )
 
            line_item.save()
 
            invoice_value += line_item.quantity * line_item.price
 

	
 
        # TODO: calculate line items from discounts
 
        invoice.value = invoice_value
 
        invoice.save()
 

	
 
        return invoice
 

	
 

	
 
    def is_valid(self):
 
        ''' Returns true if the attached invoice is not void and it represents
 
        a valid cart. '''
 
        if self.invoice.void:
 
            return False
 
        if self.invoice.cart is not None:
 
            if self.invoice.cart.revision != self.invoice.cart_revision:
 
                return False
 
        return True
 

	
 

	
 
    def void(self):
 
        ''' Voids the invoice. '''
 
        self.invoice.void = True
 

	
 

	
 
    def pay(self, reference, amount):
 
        ''' Pays the invoice by the given amount. If the payment
 
        equals the total on the invoice, finalise the invoice.
 
        (NB should be transactional.)
 
        '''
 
        if self.invoice.cart is not None:
 
            cart = CartController(self.invoice.cart)
 
            cart.validate_cart() # Raises ValidationError if invalid
 

	
 
        ''' Adds a payment '''
 
        payment = rego.Payment.objects.create(
 
            invoice=self.invoice,
 
            reference=reference,
 
            amount=amount,
 
        )
 
        payment.save()
 

	
 
        payments = rego.Payment.objects .filter(invoice=self.invoice)
 
        agg = payments.aggregate(Sum("amount"))
 
        total = agg["amount__sum"]
 

	
 
        if total==self.invoice.value:
 
            self.invoice.paid = True
 

	
 
            cart = self.invoice.cart
 
            cart.active = False
 
            cart.save()
 

	
 
            self.invoice.save()
registrasion/migrations/0001_initial.py
Show inline comments
 
new file 100644
 
# -*- coding: utf-8 -*-
 
from __future__ import unicode_literals
 

	
 
from django.db import migrations, models
 
import datetime
 
import django.utils.timezone
 
from django.conf import settings
 

	
 

	
 
class Migration(migrations.Migration):
 

	
 
    dependencies = [
 
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 
    ]
 

	
 
    operations = [
 
        migrations.CreateModel(
 
            name='Badge',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('name', models.CharField(max_length=256)),
 
                ('company', models.CharField(max_length=256)),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Cart',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('time_last_updated', models.DateTimeField()),
 
                ('reservation_duration', models.DurationField()),
 
                ('revision', models.PositiveIntegerField(default=1)),
 
                ('active', models.BooleanField(default=True)),
 
                ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Category',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('name', models.CharField(max_length=65, verbose_name='Name')),
 
                ('description', models.CharField(max_length=255, verbose_name='Description')),
 
                ('order', models.PositiveIntegerField(verbose_name='Display order')),
 
                ('render_type', models.IntegerField(verbose_name='Render type', choices=[(1, 'Radio button'), (2, 'Quantity boxes')])),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='DiscountBase',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('description', models.CharField(max_length=255, verbose_name='Description')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='DiscountForCategory',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('percentage', models.DecimalField(max_digits=4, decimal_places=1, blank=True)),
 
                ('quantity', models.PositiveIntegerField()),
 
                ('category', models.ForeignKey(to='registrasion.Category')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='DiscountForProduct',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('percentage', models.DecimalField(null=True, max_digits=4, decimal_places=1)),
 
                ('price', models.DecimalField(null=True, max_digits=8, decimal_places=2)),
 
                ('quantity', models.PositiveIntegerField()),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='DiscountItem',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('quantity', models.PositiveIntegerField()),
 
                ('cart', models.ForeignKey(to='registrasion.Cart')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='EnablingConditionBase',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('description', models.CharField(max_length=255)),
 
                ('mandatory', models.BooleanField(default=False)),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Invoice',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('cart_revision', models.IntegerField(null=True)),
 
                ('void', models.BooleanField(default=False)),
 
                ('paid', models.BooleanField(default=False)),
 
                ('value', models.DecimalField(max_digits=8, decimal_places=2)),
 
                ('cart', models.ForeignKey(to='registrasion.Cart', null=True)),
 
                ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='LineItem',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('description', models.CharField(max_length=255)),
 
                ('quantity', models.PositiveIntegerField()),
 
                ('price', models.DecimalField(max_digits=8, decimal_places=2)),
 
                ('invoice', models.ForeignKey(to='registrasion.Invoice')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Payment',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('time', models.DateTimeField(default=django.utils.timezone.now)),
 
                ('reference', models.CharField(max_length=64)),
 
                ('amount', models.DecimalField(max_digits=8, decimal_places=2)),
 
                ('invoice', models.ForeignKey(to='registrasion.Invoice')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Product',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('name', models.CharField(max_length=65, verbose_name='Name')),
 
                ('description', models.CharField(max_length=255, verbose_name='Description')),
 
                ('price', models.DecimalField(verbose_name='Price', max_digits=8, decimal_places=2)),
 
                ('limit_per_user', models.PositiveIntegerField(verbose_name='Limit per user', blank=True)),
 
                ('reservation_duration', models.DurationField(default=datetime.timedelta(0, 3600), verbose_name='Reservation duration')),
 
                ('order', models.PositiveIntegerField(verbose_name='Display order')),
 
                ('category', models.ForeignKey(verbose_name='Product category', to='registrasion.Category')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='ProductItem',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('quantity', models.PositiveIntegerField()),
 
                ('cart', models.ForeignKey(to='registrasion.Cart')),
 
                ('product', models.ForeignKey(to='registrasion.Product')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Profile',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('completed_registration', models.BooleanField(default=False)),
 
                ('highest_complete_category', models.IntegerField(default=0)),
 
                ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='Voucher',
 
            fields=[
 
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
 
                ('recipient', models.CharField(max_length=64, verbose_name='Recipient')),
 
                ('code', models.CharField(unique=True, max_length=16, verbose_name='Voucher code')),
 
                ('limit', models.PositiveIntegerField(verbose_name='Voucher use limit')),
 
            ],
 
        ),
 
        migrations.CreateModel(
 
            name='CategoryEnablingCondition',
 
            fields=[
 
                ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')),
 
                ('enabling_category', models.ForeignKey(to='registrasion.Category')),
 
            ],
 
            bases=('registrasion.enablingconditionbase',),
 
        ),
 
        migrations.CreateModel(
 
            name='IncludedProductDiscount',
 
            fields=[
 
                ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
 
                ('enabling_products', models.ManyToManyField(to='registrasion.Product', verbose_name='Including product')),
 
            ],
 
            options={
 
                'verbose_name': 'Product inclusion',
 
            },
 
            bases=('registrasion.discountbase',),
 
        ),
 
        migrations.CreateModel(
 
            name='ProductEnablingCondition',
 
            fields=[
 
                ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')),
 
                ('enabling_products', models.ManyToManyField(to='registrasion.Product')),
 
            ],
 
            bases=('registrasion.enablingconditionbase',),
 
        ),
 
        migrations.CreateModel(
 
            name='TimeOrStockLimitDiscount',
 
            fields=[
 
                ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
 
                ('start_time', models.DateTimeField(null=True, verbose_name='Start time')),
 
                ('end_time', models.DateTimeField(null=True, verbose_name='End time')),
 
                ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit')),
 
            ],
 
            options={
 
                'verbose_name': 'Promotional discount',
 
            },
 
            bases=('registrasion.discountbase',),
 
        ),
 
        migrations.CreateModel(
 
            name='TimeOrStockLimitEnablingCondition',
 
            fields=[
 
                ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')),
 
                ('start_time', models.DateTimeField(null=True, verbose_name='Start time')),
 
                ('end_time', models.DateTimeField(null=True, verbose_name='End time')),
 
                ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit')),
 
            ],
 
            bases=('registrasion.enablingconditionbase',),
 
        ),
 
        migrations.CreateModel(
 
            name='VoucherDiscount',
 
            fields=[
 
                ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
 
                ('voucher', models.OneToOneField(verbose_name='Voucher', to='registrasion.Voucher')),
 
            ],
 
            bases=('registrasion.discountbase',),
 
        ),
 
        migrations.CreateModel(
 
            name='VoucherEnablingCondition',
 
            fields=[
 
                ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')),
 
                ('voucher', models.OneToOneField(to='registrasion.Voucher')),
 
            ],
 
            bases=('registrasion.enablingconditionbase',),
 
        ),
 
        migrations.AddField(
 
            model_name='enablingconditionbase',
 
            name='categories',
 
            field=models.ManyToManyField(to='registrasion.Category'),
 
        ),
 
        migrations.AddField(
 
            model_name='enablingconditionbase',
 
            name='products',
 
            field=models.ManyToManyField(to='registrasion.Product'),
 
        ),
 
        migrations.AddField(
 
            model_name='discountitem',
 
            name='discount',
 
            field=models.ForeignKey(to='registrasion.DiscountBase'),
 
        ),
 
        migrations.AddField(
 
            model_name='discountitem',
 
            name='product',
 
            field=models.ForeignKey(to='registrasion.Product'),
 
        ),
 
        migrations.AddField(
 
            model_name='discountforproduct',
 
            name='discount',
 
            field=models.ForeignKey(to='registrasion.DiscountBase'),
 
        ),
 
        migrations.AddField(
 
            model_name='discountforproduct',
 
            name='product',
 
            field=models.ForeignKey(to='registrasion.Product'),
 
        ),
 
        migrations.AddField(
 
            model_name='discountforcategory',
 
            name='discount',
 
            field=models.ForeignKey(to='registrasion.DiscountBase'),
 
        ),
 
        migrations.AddField(
 
            model_name='cart',
 
            name='vouchers',
 
            field=models.ManyToManyField(to='registrasion.Voucher', blank=True),
 
        ),
 
        migrations.AddField(
 
            model_name='badge',
 
            name='profile',
 
            field=models.OneToOneField(to='registrasion.Profile'),
 
        ),
 
    ]
registrasion/migrations/__init__.py
Show inline comments
 
new file 100644
registrasion/models.py
Show inline comments
 
new file 100644
 
from __future__ import unicode_literals
 

	
 
import datetime
 

	
 
from django.core.exceptions import ObjectDoesNotExist
 
from django.core.exceptions import ValidationError
 
from django.contrib.auth.models import User
 
from django.db import models
 
from django.db.models import F, Q
 
from django.utils import timezone
 
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.markdown_parser import parse
 
from symposion.proposals.models import ProposalBase
 

	
 

	
 
# User models
 

	
 
@python_2_unicode_compatible
 
class Profile(models.Model):
 
    ''' Miscellaneous user-related data. '''
 

	
 
    def __str__(self):
 
        return "%s" % self.user
 

	
 
    user = models.OneToOneField(User, on_delete=models.CASCADE)
 
    # Badge is linked
 
    completed_registration = models.BooleanField(default=False)
 
    highest_complete_category = models.IntegerField(default=0)
 

	
 

	
 
@python_2_unicode_compatible
 
class Badge(models.Model):
 
    ''' Information for an attendee's badge. '''
 

	
 
    def __str__(self):
 
        return "Badge for: %s of %s" % (self.name, self.company)
 

	
 
    profile = models.OneToOneField(Profile, on_delete=models.CASCADE)
 

	
 
    name = models.CharField(max_length=256)
 
    company = models.CharField(max_length=256)
 

	
 

	
 
# Inventory Models
 

	
 
@python_2_unicode_compatible
 
class Category(models.Model):
 
    ''' Registration product categories '''
 

	
 
    def __str__(self):
 
        return self.name
 

	
 
    RENDER_TYPE_RADIO = 1
 
    RENDER_TYPE_QUANTITY = 2
 

	
 
    CATEGORY_RENDER_TYPES = [
 
        (RENDER_TYPE_RADIO, _("Radio button")),
 
        (RENDER_TYPE_QUANTITY, _("Quantity boxes")),
 
    ]
 

	
 
    name = models.CharField(max_length=65, verbose_name=_("Name"))
 
    description = models.CharField(max_length=255, verbose_name=_("Description"))
 
    order = models.PositiveIntegerField(verbose_name=("Display order"))
 
    render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, verbose_name=_("Render type"))
 

	
 

	
 
@python_2_unicode_compatible
 
class Product(models.Model):
 
    ''' Registration products '''
 

	
 
    def __str__(self):
 
        return self.name
 

	
 
    name = models.CharField(max_length=65, verbose_name=_("Name"))
 
    description = models.CharField(max_length=255, verbose_name=_("Description"))
 
    category = models.ForeignKey(Category, verbose_name=_("Product category"))
 
    price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name=_("Price"))
 
    limit_per_user = models.PositiveIntegerField(blank=True, verbose_name=_("Limit per user"))
 
    reservation_duration = models.DurationField(
 
        default=datetime.timedelta(hours=1),
 
        verbose_name=_("Reservation duration"))
 
    order = models.PositiveIntegerField(verbose_name=("Display order"))
 

	
 

	
 
@python_2_unicode_compatible
 
class Voucher(models.Model):
 
    ''' Registration vouchers '''
 

	
 
    # Vouchers reserve a cart for a fixed amount of time, so that
 
    # items may be added without the voucher being swiped by someone else
 
    RESERVATION_DURATION = datetime.timedelta(hours=1)
 

	
 
    def __str__(self):
 
        return "Voucher for %s" % self.recipient
 

	
 
    recipient = models.CharField(max_length=64, verbose_name=_("Recipient"))
 
    code = models.CharField(max_length=16, unique=True, verbose_name=_("Voucher code"))
 
    limit = models.PositiveIntegerField(verbose_name=_("Voucher use limit"))
 

	
 

	
 
# Product Modifiers
 

	
 
@python_2_unicode_compatible
 
class DiscountBase(models.Model):
 
    ''' Base class for discounts. Each subclass has controller code that
 
    determines whether or not the given discount is available to be added to the
 
    current cart. '''
 

	
 
    objects = InheritanceManager()
 

	
 
    def __str__(self):
 
        return "Discount: " + self.description
 

	
 
    description = models.CharField(max_length=255,
 
        verbose_name=_("Description"))
 

	
 

	
 
@python_2_unicode_compatible
 
class DiscountForProduct(models.Model):
 
    ''' Represents a discount on an individual product. Each Discount can
 
    contain multiple products and categories. Discounts can either be a
 
    percentage or a fixed amount, but not both. '''
 

	
 
    def __str__(self):
 
        if self.percentage:
 
            return "%s%% off %s" % (self.percentage, self.product)
 
        elif self.price:
 
            return "$%s off %s" % (self.price, self.product)
 

	
 
    def clean(self):
 
        if self.percentage is None and self.price is None:
 
            raise ValidationError(
 
                _("Discount must have a percentage or a price."))
 
        elif self.percentage is not None and self.price is not None:
 
            raise ValidationError(
 
                _("Discount may only have a percentage or only a price."))
 

	
 
    discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE)
 
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
 
    percentage = models.DecimalField(max_digits=4, decimal_places=1, null=True)
 
    price = models.DecimalField(max_digits=8, decimal_places=2, null=True)
 
    quantity = models.PositiveIntegerField()
 

	
 

	
 
@python_2_unicode_compatible
 
class DiscountForCategory(models.Model):
 
    ''' Represents a discount for a category of products. Each discount can
 
    contain multiple products. Category discounts can only be a percentage. '''
 

	
 
    def __str__(self):
 
        return "%s%% off %s" % (self.percentage, self.category)
 

	
 
    discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE)
 
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
 
    percentage = models.DecimalField(max_digits=4, decimal_places=1, blank=True)
 
    quantity = models.PositiveIntegerField()
 

	
 

	
 
class TimeOrStockLimitDiscount(DiscountBase):
 
    ''' Discounts that are generally available, but are limited by timespan or
 
    usage count. This is for e.g. Early Bird discounts. '''
 

	
 
    class Meta:
 
        verbose_name = _("Promotional discount")
 

	
 
    start_time = models.DateTimeField(null=True, verbose_name=_("Start time"))
 
    end_time = models.DateTimeField(null=True, verbose_name=_("End time"))
 
    limit = models.PositiveIntegerField(null=True, verbose_name=_("Limit"))
 

	
 

	
 
class VoucherDiscount(DiscountBase):
 
    ''' Discounts that are enabled when a voucher code is in the current
 
    cart. '''
 

	
 
    voucher = models.OneToOneField(Voucher, on_delete=models.CASCADE,
 
        verbose_name=_("Voucher"))
 

	
 

	
 
class IncludedProductDiscount(DiscountBase):
 
    ''' Discounts that are enabled because another product has been purchased.
 
    e.g. A conference ticket includes a free t-shirt. '''
 

	
 
    class Meta:
 
        verbose_name = _("Product inclusion")
 

	
 
    enabling_products = models.ManyToManyField(Product,
 
        verbose_name=_("Including product"))
 

	
 

	
 
class RoleDiscount(object):
 
    ''' Discounts that are enabled because the active user has a specific
 
    role. This is for e.g. volunteers who can get a discount ticket. '''
 
    ## TODO: implement RoleDiscount
 
    pass
 

	
 

	
 
@python_2_unicode_compatible
 
class EnablingConditionBase(models.Model):
 
    ''' This defines a condition which allows products or categories to
 
    be made visible. If there is at least one mandatory enabling condition
 
    defined on a Product or Category, it will only be enabled if *all*
 
    mandatory conditions are met, otherwise, if there is at least one enabling
 
    condition defined on a Product or Category, it will only be enabled if at
 
    least one condition is met. '''
 

	
 
    objects = InheritanceManager()
 

	
 
    def __str__(self):
 
        return self.name
 

	
 
    description = models.CharField(max_length=255)
 
    mandatory = models.BooleanField(default=False)
 
    products = models.ManyToManyField(Product)
 
    categories = models.ManyToManyField(Category)
 

	
 

	
 
class TimeOrStockLimitEnablingCondition(EnablingConditionBase):
 
    ''' Registration product ceilings '''
 

	
 
    start_time = models.DateTimeField(null=True, verbose_name=_("Start time"))
 
    end_time = models.DateTimeField(null=True, verbose_name=_("End time"))
 
    limit = models.PositiveIntegerField(null=True, verbose_name=_("Limit"))
 

	
 

	
 
@python_2_unicode_compatible
 
class ProductEnablingCondition(EnablingConditionBase):
 
    ''' The condition is met because a specific product is purchased. '''
 

	
 
    def __str__(self):
 
        return "Enabled by product: "
 

	
 
    enabling_products = models.ManyToManyField(Product)
 

	
 

	
 
@python_2_unicode_compatible
 
class CategoryEnablingCondition(EnablingConditionBase):
 
    ''' The condition is met because a product in a particular product is
 
    purchased. '''
 

	
 
    def __str__(self):
 
        return "Enabled by product in category: "
 

	
 
    enabling_category = models.ForeignKey(Category)
 

	
 

	
 
@python_2_unicode_compatible
 
class VoucherEnablingCondition(EnablingConditionBase):
 
    ''' The condition is met because a Voucher is present. This is for e.g.
 
    enabling sponsor tickets. '''
 

	
 
    def __str__(self):
 
        return "Enabled by voucher: %s" % voucher
 

	
 
    voucher = models.OneToOneField(Voucher)
 

	
 

	
 
#@python_2_unicode_compatible
 
class RoleEnablingCondition(object):
 
    ''' The condition is met because the active user has a particular Role.
 
    This is for e.g. enabling Team tickets. '''
 
    ## TODO: implement RoleEnablingCondition
 
    pass
 

	
 

	
 
# Commerce Models
 

	
 
@python_2_unicode_compatible
 
class Cart(models.Model):
 
    ''' Represents a set of product items that have been purchased, or are
 
    pending purchase. '''
 

	
 
    def __str__(self):
 
        return "%d rev #%d" % (self.id, self.revision)
 

	
 
    user = models.ForeignKey(User)
 
    # ProductItems (foreign key)
 
    vouchers = models.ManyToManyField(Voucher, blank=True)
 
    time_last_updated = models.DateTimeField()
 
    reservation_duration = models.DurationField()
 
    revision = models.PositiveIntegerField(default=1)
 
    active = models.BooleanField(default=True)
 

	
 
    @classmethod
 
    def reserved_carts(cls):
 
        ''' Gets all carts that are 'reserved' '''
 
        return Cart.objects.filter(
 
            (Q(active=True) &
 
                Q(time_last_updated__gt=timezone.now()-F('reservation_duration')
 
            )) |
 
            Q(active=False)
 
        )
 

	
 

	
 
@python_2_unicode_compatible
 
class ProductItem(models.Model):
 
    ''' Represents a product-quantity pair in a Cart. '''
 

	
 
    def __str__(self):
 
        return "product: %s * %d in Cart: %s" % (
 
            self.product, self.quantity, self.cart)
 

	
 
    cart = models.ForeignKey(Cart)
 
    product = models.ForeignKey(Product)
 
    quantity = models.PositiveIntegerField()
 

	
 

	
 
@python_2_unicode_compatible
 
class DiscountItem(models.Model):
 
    ''' Represents a discount-product-quantity relation in a Cart. '''
 

	
 
    def __str__(self):
 
        return "%s: %s * %d in Cart: %s" % (
 
            self.discount, self.product, self.quantity, self.cart)
 

	
 
    cart = models.ForeignKey(Cart)
 
    product = models.ForeignKey(Product)
 
    discount = models.ForeignKey(DiscountBase)
 
    quantity = models.PositiveIntegerField()
 

	
 

	
 
@python_2_unicode_compatible
 
class Invoice(models.Model):
 
    ''' An invoice. Invoices can be automatically generated when checking out
 
    a Cart, in which case, it is attached to a given revision of a Cart. '''
 

	
 
    def __str__(self):
 
        return "Invoice #%d" % self.id
 

	
 
    def clean(self):
 
        if self.cart is not None and self.cart_revision is None:
 
            raise ValidationError(
 
                "If this is a cart invoice, it must have a revision")
 

	
 
    # Invoice Number
 
    user = models.ForeignKey(User)
 
    cart = models.ForeignKey(Cart, null=True)
 
    cart_revision = models.IntegerField(null=True)
 
    # Line Items (foreign key)
 
    void = models.BooleanField(default=False)
 
    paid = models.BooleanField(default=False)
 
    value = models.DecimalField(max_digits=8, decimal_places=2)
 

	
 

	
 
@python_2_unicode_compatible
 
class LineItem(models.Model):
 
    ''' Line items for an invoice. These are denormalised from the ProductItems
 
    and DiscountItems that belong to a cart (for consistency), but also allow
 
    for arbitrary line items when required. '''
 

	
 
    def __str__(self):
 
        return "Line: %s * %d @ %s" % (
 
            self.description, self.quantity, self.price)
 

	
 
    invoice = models.ForeignKey(Invoice)
 
    description = models.CharField(max_length=255)
 
    quantity = models.PositiveIntegerField()
 
    price = models.DecimalField(max_digits=8, decimal_places=2)
 

	
 

	
 
@python_2_unicode_compatible
 
class Payment(models.Model):
 
    ''' A payment for an invoice. Each invoice can have multiple payments
 
    attached to it.'''
 

	
 
    def __str__(self):
 
        return "Payment: ref=%s amount=%s" % (self.reference, self.amount)
 

	
 
    invoice = models.ForeignKey(Invoice)
 
    time = models.DateTimeField(default=timezone.now)
 
    reference = models.CharField(max_length=64)
 
    amount = models.DecimalField(max_digits=8, decimal_places=2)
registrasion/tests/__init__.py
Show inline comments
 
new file 100644
 
default_app_config = "registrasion.apps.RegistrationConfig"
registrasion/tests/patch_datetime.py
Show inline comments
 
new file 100644
 
from django.utils import timezone
 

	
 
class SetTimeMixin(object):
 
    ''' Patches timezone.now() for the duration of a test case. Allows us to
 
    test time-based conditions (ceilings etc) relatively easily. '''
 

	
 
    def setUp(self):
 
        super(SetTimeMixin, self).setUp()
 
        self._old_timezone_now = timezone.now
 
        self.now = timezone.now()
 
        timezone.now = self.new_timezone_now
 

	
 
    def tearDown(self):
 
        timezone.now = self._old_timezone_now
 
        super(SetTimeMixin, self).tearDown()
 

	
 
    def set_time(self, time):
 
        self.now = time
 

	
 
    def add_timedelta(self, delta):
 
        self.now += delta
 

	
 
    def new_timezone_now(self):
 
        return self.now
registrasion/tests/test_cart.py
Show inline comments
 
new file 100644
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.test import TestCase
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 
from registrasion.cart import CartController
 

	
 
from patch_datetime import SetTimeMixin
 

	
 
UTC = pytz.timezone('UTC')
 

	
 
class RegistrationCartTestCase(SetTimeMixin, TestCase):
 

	
 
    def setUp(self):
 
        super(RegistrationCartTestCase, self).setUp()
 

	
 
    @classmethod
 
    def setUpTestData(cls):
 
        cls.USER_1 = User.objects.create_user(username='testuser',
 
            email='test@example.com', password='top_secret')
 

	
 
        cls.USER_2 = User.objects.create_user(username='testuser2',
 
            email='test2@example.com', password='top_secret')
 

	
 
        cls.CAT_1 = rego.Category.objects.create(
 
            name="Category 1",
 
            description="This is a test category",
 
            order=10,
 
            render_type=rego.Category.RENDER_TYPE_RADIO,
 
        )
 
        cls.CAT_1.save()
 

	
 
        cls.CAT_2 = rego.Category.objects.create(
 
            name="Category 2",
 
            description="This is a test category",
 
            order=10,
 
            render_type=rego.Category.RENDER_TYPE_RADIO,
 
        )
 
        cls.CAT_2.save()
 

	
 
        cls.RESERVATION = datetime.timedelta(hours=1)
 

	
 
        cls.PROD_1 = rego.Product.objects.create(
 
            name="Product 1",
 
            description= "This is a test product. It costs $10. " \
 
                "A user may have 10 of them.",
 
            category=cls.CAT_1,
 
            price=Decimal("10.00"),
 
            reservation_duration=cls.RESERVATION,
 
            limit_per_user=10,
 
            order=10,
 
        )
 
        cls.PROD_1.save()
 

	
 
        cls.PROD_2 = rego.Product.objects.create(
 
            name="Product 2",
 
            description= "This is a test product. It costs $10. " \
 
                "A user may have 10 of them.",
 
            category=cls.CAT_1,
 
            price=Decimal("10.00"),
 
            limit_per_user=10,
 
            order=10,
 
        )
 
        cls.PROD_2.save()
 

	
 
        cls.PROD_3 = rego.Product.objects.create(
 
            name="Product 3",
 
            description= "This is a test product. It costs $10. " \
 
                "A user may have 10 of them.",
 
            category=cls.CAT_2,
 
            price=Decimal("10.00"),
 
            limit_per_user=10,
 
            order=10,
 
        )
 
        cls.PROD_2.save()
 

	
 

	
 
    @classmethod
 
    def make_ceiling(cls, name, limit=None, start_time=None, end_time=None):
 
        limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create(
 
            description=name,
 
            mandatory=True,
 
            limit=limit,
 
            start_time=start_time,
 
            end_time=end_time
 
        )
 
        limit_ceiling.save()
 
        limit_ceiling.products.add(cls.PROD_1, cls.PROD_2)
 
        limit_ceiling.save()
 

	
 

	
 
    @classmethod
 
    def make_category_ceiling(cls, name, limit=None, start_time=None, end_time=None):
 
        limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create(
 
            description=name,
 
            mandatory=True,
 
            limit=limit,
 
            start_time=start_time,
 
            end_time=end_time
 
        )
 
        limit_ceiling.save()
 
        limit_ceiling.categories.add(cls.CAT_1)
 
        limit_ceiling.save()
 

	
 

	
 
    @classmethod
 
    def make_discount_ceiling(cls, name, limit=None, start_time=None, end_time=None):
 
        limit_ceiling = rego.TimeOrStockLimitDiscount.objects.create(
 
            description=name,
 
            start_time=start_time,
 
            end_time=end_time,
 
            limit=limit,
 
        )
 
        limit_ceiling.save()
 
        rego.DiscountForProduct.objects.create(
 
            discount=limit_ceiling,
 
            product=cls.PROD_1,
 
            percentage=100,
 
            quantity=10,
 
        ).save()
 

	
 

	
 
class BasicCartTests(RegistrationCartTestCase):
 

	
 
    def test_get_cart(self):
 
        current_cart = CartController.for_user(self.USER_1)
 

	
 
        current_cart.cart.active = False
 
        current_cart.cart.save()
 

	
 
        old_cart = current_cart
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 
        self.assertNotEqual(old_cart.cart, current_cart.cart)
 

	
 
        current_cart2 = CartController.for_user(self.USER_1)
 
        self.assertEqual(current_cart.cart, current_cart2.cart)
 

	
 

	
 
    def test_add_to_cart_collapses_product_items(self):
 
        current_cart = CartController.for_user(self.USER_1)
 

	
 
        # Add a product twice
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        ## Count of products for a given user should be collapsed.
 
        items = rego.ProductItem.objects.filter(cart=current_cart.cart,
 
            product=self.PROD_1)
 
        self.assertEqual(1, len(items))
 
        item = items[0]
 
        self.assertEquals(2, item.quantity)
 

	
 

	
 
    def test_add_to_cart_per_user_limit(self):
 
        current_cart = CartController.for_user(self.USER_1)
 

	
 
        # User should be able to add 1 of PROD_1 to the current cart.
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User should be able to add 1 of PROD_1 to the current cart.
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User should not be able to add 10 of PROD_1 to the current cart now,
 
        # because they have a limit of 10.
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 10)
 

	
 
        current_cart.cart.active = False
 
        current_cart.cart.save()
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 
        # User should not be able to add 10 of PROD_1 to the current cart now,
 
        # even though it's a new cart.
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 10)
 

	
 
        # Second user should not be affected by first user's limits
 
        second_user_cart = CartController.for_user(self.USER_2)
 
        second_user_cart.add_to_cart(self.PROD_1, 10)
registrasion/tests/test_ceilings.py
Show inline comments
 
new file 100644
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.test import TestCase
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 
from registrasion.cart import CartController
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 
class CeilingsTestCases(RegistrationCartTestCase):
 

	
 
    def test_add_to_cart_ceiling_limit(self):
 
        self.make_ceiling("Limit ceiling", limit=9)
 
        self.__add_to_cart_test()
 

	
 
    def test_add_to_cart_ceiling_category_limit(self):
 
        self.make_category_ceiling("Limit ceiling", limit=9)
 
        self.__add_to_cart_test()
 

	
 
    def __add_to_cart_test(self):
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 

	
 
        # User should not be able to add 10 of PROD_1 to the current cart
 
        # because it is affected by limit_ceiling
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_2, 10)
 

	
 
        # User should be able to add 5 of PROD_1 to the current cart
 
        current_cart.add_to_cart(self.PROD_1, 5)
 

	
 
        # User should not be able to add 6 of PROD_2 to the current cart
 
        # because it is affected by CEIL_1
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_2, 6)
 

	
 
        # User should be able to add 5 of PROD_2 to the current cart
 
        current_cart.add_to_cart(self.PROD_2, 4)
 

	
 

	
 
    def test_add_to_cart_ceiling_date_range(self):
 
        self.make_ceiling("date range ceiling",
 
            start_time=datetime.datetime(2015, 01, 01, tzinfo=UTC),
 
            end_time=datetime.datetime(2015, 02, 01, tzinfo=UTC)
 
        )
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 

	
 
        # User should not be able to add whilst we're before start_time
 
        self.set_time(datetime.datetime(2014, 01, 01, tzinfo=UTC))
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User should be able to add whilst we're during date range
 
        # On edge of start
 
        self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC))
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        # In middle
 
        self.set_time(datetime.datetime(2015, 01, 15, tzinfo=UTC))
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        # On edge of end
 
        self.set_time(datetime.datetime(2015, 02, 01, tzinfo=UTC))
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User should not be able to add whilst we're after date range
 
        self.set_time(datetime.datetime(2014, 01, 01, minute=01, tzinfo=UTC))
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_add_to_cart_ceiling_limit_reserved_carts(self):
 
        self.make_ceiling("Limit ceiling", limit=1)
 

	
 
        self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC))
 

	
 
        first_cart = CartController.for_user(self.USER_1)
 
        second_cart = CartController.for_user(self.USER_2)
 

	
 
        first_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User 2 should not be able to add item to their cart
 
        # because user 1 has item reserved, exhausting the ceiling
 
        with self.assertRaises(ValidationError):
 
            second_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User 2 should be able to add item to their cart once the
 
        # reservation duration is elapsed
 
        self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
 
        second_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # User 2 pays for their cart
 
        second_cart.cart.active = False
 
        second_cart.cart.save()
 

	
 
        # User 1 should not be able to add item to their cart
 
        # because user 2 has paid for their reserved item, exhausting
 
        # the ceiling, regardless of the reservation time.
 
        self.add_timedelta(self.RESERVATION * 20)
 
        with self.assertRaises(ValidationError):
 
            first_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_validate_cart_fails_product_ceilings(self):
 
        self.make_ceiling("Limit ceiling", limit=1)
 
        self.__validation_test()
 

	
 
    def test_validate_cart_fails_product_discount_ceilings(self):
 
        self.make_discount_ceiling("Limit ceiling", limit=1)
 
        self.__validation_test()
 

	
 
    def __validation_test(self):
 
        self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC))
 

	
 
        first_cart = CartController.for_user(self.USER_1)
 
        second_cart = CartController.for_user(self.USER_2)
 

	
 
        # Adding a valid product should validate.
 
        first_cart.add_to_cart(self.PROD_1, 1)
 
        first_cart.validate_cart()
 

	
 
        # Cart should become invalid if lapsed carts are claimed.
 
        self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
 

	
 
        # Unpaid cart within reservation window
 
        second_cart.add_to_cart(self.PROD_1, 1)
 
        with self.assertRaises(ValidationError):
 
            first_cart.validate_cart()
 

	
 
        # Paid cart outside the reservation window
 
        second_cart.cart.active = False
 
        second_cart.cart.save()
 
        self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
 
        with self.assertRaises(ValidationError):
 
            first_cart.validate_cart()
registrasion/tests/test_discount.py
Show inline comments
 
new file 100644
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.test import TestCase
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 
from registrasion.cart import CartController
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 
class DiscountTestCase(RegistrationCartTestCase):
 

	
 
    @classmethod
 
    def add_discount_prod_1_includes_prod_2(cls, amount=Decimal(100)):
 
        discount = rego.IncludedProductDiscount.objects.create(
 
            description="PROD_1 includes PROD_2 " + str(amount) + "%",
 
        )
 
        discount.save()
 
        discount.enabling_products.add(cls.PROD_1)
 
        discount.save()
 
        rego.DiscountForProduct.objects.create(
 
            discount=discount,
 
            product=cls.PROD_2,
 
            percentage=amount,
 
            quantity=2
 
        ).save()
 
        return discount
 

	
 

	
 
    @classmethod
 
    def add_discount_prod_1_includes_cat_2(cls, amount=Decimal(100)):
 
        discount = rego.IncludedProductDiscount.objects.create(
 
            description="PROD_1 includes CAT_2 " + str(amount) + "%",
 
        )
 
        discount.save()
 
        discount.enabling_products.add(cls.PROD_1)
 
        discount.save()
 
        rego.DiscountForCategory.objects.create(
 
            discount=discount,
 
            category=cls.CAT_2,
 
            percentage=amount,
 
            quantity=2
 
        ).save()
 
        return discount
 

	
 

	
 
    def test_discount_is_applied(self):
 
        discount = self.add_discount_prod_1_includes_prod_2()
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.add_to_cart(self.PROD_2, 1)
 

	
 
        # Discounts should be applied at this point...
 
        self.assertEqual(1, len(cart.cart.discountitem_set.all()))
 

	
 

	
 
    def test_discount_is_applied_for_category(self):
 
        discount = self.add_discount_prod_1_includes_cat_2()
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.add_to_cart(self.PROD_3, 1)
 

	
 
        # Discounts should be applied at this point...
 
        self.assertEqual(1, len(cart.cart.discountitem_set.all()))
 

	
 

	
 
    def test_discount_does_not_apply_if_not_met(self):
 
        discount = self.add_discount_prod_1_includes_prod_2()
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_2, 1)
 

	
 
        # No discount should be applied as the condition is not met
 
        self.assertEqual(0, len(cart.cart.discountitem_set.all()))
 

	
 

	
 
    def test_discount_applied_out_of_order(self):
 
        discount = self.add_discount_prod_1_includes_prod_2()
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_2, 1)
 
        cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # No discount should be applied as the condition is not met
 
        self.assertEqual(1, len(cart.cart.discountitem_set.all()))
 

	
 

	
 
    def test_discounts_collapse(self):
 
        discount = self.add_discount_prod_1_includes_prod_2()
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.add_to_cart(self.PROD_2, 1)
 
        cart.add_to_cart(self.PROD_2, 1)
 

	
 
        # Discounts should be applied and collapsed at this point...
 
        self.assertEqual(1, len(cart.cart.discountitem_set.all()))
 

	
 

	
 
    def test_discounts_respect_quantity(self):
 
        discount = self.add_discount_prod_1_includes_prod_2()
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.add_to_cart(self.PROD_2, 3)
 

	
 
        # There should be three items in the cart, but only two should
 
        # attract a discount.
 
        discount_items = list(cart.cart.discountitem_set.all())
 
        self.assertEqual(2, discount_items[0].quantity)
 

	
 

	
 
    def test_multiple_discounts_apply_in_order(self):
 
        discount_full = self.add_discount_prod_1_includes_prod_2()
 
        discount_half = self.add_discount_prod_1_includes_prod_2(Decimal(50))
 

	
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.add_to_cart(self.PROD_2, 3)
 

	
 
        # There should be two discounts
 
        discount_items = list(cart.cart.discountitem_set.all())
 
        discount_items.sort(key=lambda item: item.quantity)
 
        self.assertEqual(2, len(discount_items))
 
        # The half discount should be applied only once
 
        self.assertEqual(1, discount_items[0].quantity)
 
        self.assertEqual(discount_half.pk, discount_items[0].discount.pk)
 
        # The full discount should be applied twice
 
        self.assertEqual(2, discount_items[1].quantity)
 
        self.assertEqual(discount_full.pk, discount_items[1].discount.pk)
 

	
 

	
 
    def test_discount_applies_across_carts(self):
 
        discount_full = self.add_discount_prod_1_includes_prod_2()
 

	
 
        # Enable the discount during the first cart.
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.cart.active = False
 
        cart.cart.save()
 

	
 
        # Use the discount in the second cart
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_2, 1)
 

	
 
        # The discount should be applied.
 
        self.assertEqual(1, len(cart.cart.discountitem_set.all()))
 
        cart.cart.active = False
 
        cart.cart.save()
 

	
 
        # The discount should respect the total quantity across all
 
        # of the user's carts.
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_2, 2)
 

	
 
        # Having one item in the second cart leaves one more item where
 
        # the discount is applicable. The discount should apply, but only for
 
        # quantity=1
 
        discount_items = list(cart.cart.discountitem_set.all())
 
        self.assertEqual(1, discount_items[0].quantity)
 

	
 

	
 
    def test_discount_applies_only_once_enabled(self):
 
        # Enable the discount during the first cart.
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)
 
        cart.add_to_cart(self.PROD_2, 2) # This would exhaust discount if present
 
        cart.cart.active = False
 
        cart.cart.save()
 

	
 
        discount_full = self.add_discount_prod_1_includes_prod_2()
 
        cart = CartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_2, 2)
 

	
 
        discount_items = list(cart.cart.discountitem_set.all())
 
        self.assertEqual(2, discount_items[0].quantity)
registrasion/tests/test_enabling_condition.py
Show inline comments
 
new file 100644
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.test import TestCase
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 
from registrasion.cart import CartController
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 
class EnablingConditionTestCases(RegistrationCartTestCase):
 

	
 
    @classmethod
 
    def add_product_enabling_condition(cls, mandatory=False):
 
        ''' Adds a product enabling condition: adding PROD_1 to a cart is
 
        predicated on adding PROD_2 beforehand. '''
 
        enabling_condition = rego.ProductEnablingCondition.objects.create(
 
            description="Product condition",
 
            mandatory=mandatory,
 
        )
 
        enabling_condition.save()
 
        enabling_condition.products.add(cls.PROD_1)
 
        enabling_condition.enabling_products.add(cls.PROD_2)
 
        enabling_condition.save()
 

	
 

	
 
    @classmethod
 
    def add_product_enabling_condition_on_category(cls, mandatory=False):
 
        ''' Adds a product enabling condition that operates on a category:
 
        adding an item from CAT_1 is predicated on adding PROD_3 beforehand '''
 
        enabling_condition = rego.ProductEnablingCondition.objects.create(
 
            description="Product condition",
 
            mandatory=mandatory,
 
        )
 
        enabling_condition.save()
 
        enabling_condition.categories.add(cls.CAT_1)
 
        enabling_condition.enabling_products.add(cls.PROD_3)
 
        enabling_condition.save()
 

	
 

	
 
    def add_category_enabling_condition(cls, mandatory=False):
 
        ''' Adds a category enabling condition: adding PROD_1 to a cart is
 
        predicated on adding an item from CAT_2 beforehand.'''
 
        enabling_condition = rego.CategoryEnablingCondition.objects.create(
 
            description="Category condition",
 
            mandatory=mandatory,
 
            enabling_category=cls.CAT_2,
 
        )
 
        enabling_condition.save()
 
        enabling_condition.products.add(cls.PROD_1)
 
        enabling_condition.save()
 

	
 

	
 
    def test_product_enabling_condition_enables_product(self):
 
        self.add_product_enabling_condition()
 

	
 
        # Cannot buy PROD_1 without buying PROD_2
 
        current_cart = CartController.for_user(self.USER_1)
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        current_cart.add_to_cart(self.PROD_2, 1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_product_enabled_by_product_in_previous_cart(self):
 
        self.add_product_enabling_condition()
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_2, 1)
 
        current_cart.cart.active = False
 
        current_cart.cart.save()
 

	
 
        # Create new cart and try to add PROD_1
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_product_enabling_condition_enables_category(self):
 
        self.add_product_enabling_condition_on_category()
 

	
 
        # Cannot buy PROD_1 without buying item from CAT_2
 
        current_cart = CartController.for_user(self.USER_1)
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        current_cart.add_to_cart(self.PROD_3, 1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_category_enabling_condition_enables_product(self):
 
        self.add_category_enabling_condition()
 

	
 
        # Cannot buy PROD_1 without buying PROD_2
 
        current_cart = CartController.for_user(self.USER_1)
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # PROD_3 is in CAT_2
 
        current_cart.add_to_cart(self.PROD_3, 1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_product_enabled_by_category_in_previous_cart(self):
 
        self.add_category_enabling_condition()
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_3, 1)
 
        current_cart.cart.active = False
 
        current_cart.cart.save()
 

	
 
        # Create new cart and try to add PROD_1
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_multiple_non_mandatory_conditions(self):
 
        self.add_product_enabling_condition()
 
        self.add_category_enabling_condition()
 

	
 
        # User 1 is testing the product enabling condition
 
        cart_1 = CartController.for_user(self.USER_1)
 
        # Cannot add PROD_1 until a condition is met
 
        with self.assertRaises(ValidationError):
 
            cart_1.add_to_cart(self.PROD_1, 1)
 
        cart_1.add_to_cart(self.PROD_2, 1)
 
        cart_1.add_to_cart(self.PROD_1, 1)
 

	
 
        # User 2 is testing the category enabling condition
 
        cart_2 = CartController.for_user(self.USER_2)
 
        # Cannot add PROD_1 until a condition is met
 
        with self.assertRaises(ValidationError):
 
            cart_2.add_to_cart(self.PROD_1, 1)
 
        cart_2.add_to_cart(self.PROD_3, 1)
 
        cart_2.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_multiple_mandatory_conditions(self):
 
        self.add_product_enabling_condition(mandatory=True)
 
        self.add_category_enabling_condition(mandatory=True)
 

	
 
        cart_1 = CartController.for_user(self.USER_1)
 
        # Cannot add PROD_1 until both conditions are met
 
        with self.assertRaises(ValidationError):
 
            cart_1.add_to_cart(self.PROD_1, 1)
 
        cart_1.add_to_cart(self.PROD_2, 1) # Meets the product condition
 
        with self.assertRaises(ValidationError):
 
            cart_1.add_to_cart(self.PROD_1, 1)
 
        cart_1.add_to_cart(self.PROD_3, 1) # Meets the category condition
 
        cart_1.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_mandatory_conditions_are_mandatory(self):
 
        self.add_product_enabling_condition(mandatory=False)
 
        self.add_category_enabling_condition(mandatory=True)
 

	
 
        cart_1 = CartController.for_user(self.USER_1)
 
        # Cannot add PROD_1 until both conditions are met
 
        with self.assertRaises(ValidationError):
 
            cart_1.add_to_cart(self.PROD_1, 1)
 
        cart_1.add_to_cart(self.PROD_2, 1) # Meets the product condition
 
        with self.assertRaises(ValidationError):
 
            cart_1.add_to_cart(self.PROD_1, 1)
 
        cart_1.add_to_cart(self.PROD_3, 1) # Meets the category condition
 
        cart_1.add_to_cart(self.PROD_1, 1)
registrasion/tests/test_invoice.py
Show inline comments
 
new file 100644
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.test import TestCase
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 
from registrasion.cart import CartController
 
from registrasion.invoice import InvoiceController
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 

	
 
class InvoiceTestCase(RegistrationCartTestCase):
 

	
 
    def test_create_invoice(self):
 
        current_cart = CartController.for_user(self.USER_1)
 

	
 
        # Should be able to create an invoice after the product is added
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = InvoiceController.for_cart(current_cart.cart)
 
        # That invoice should have a single line item
 
        line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice)
 
        self.assertEqual(1, len(line_items))
 
        # That invoice should have a value equal to cost of PROD_1
 
        self.assertEqual(self.PROD_1.price, invoice_1.invoice.value)
 

	
 
        # Adding item to cart should void all active invoices and produce
 
        # a new invoice
 
        current_cart.add_to_cart(self.PROD_2, 1)
 
        invoice_2 = InvoiceController.for_cart(current_cart.cart)
 
        self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
 
        # Invoice should have two line items
 
        line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice)
 
        self.assertEqual(2, len(line_items))
 
        # Invoice should have a value equal to cost of PROD_1 and PROD_2
 
        self.assertEqual(
 
            self.PROD_1.price + self.PROD_2.price,
 
            invoice_2.invoice.value)
 

	
 
    def test_create_invoice_fails_if_cart_invalid(self):
 
        self.make_ceiling("Limit ceiling", limit=1)
 
        self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC))
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        self.add_timedelta(self.RESERVATION * 2)
 
        cart_2 = CartController.for_user(self.USER_2)
 
        cart_2.add_to_cart(self.PROD_1, 1)
 

	
 
        # Now try to invoice the first user
 
        with self.assertRaises(ValidationError):
 
            InvoiceController.for_cart(current_cart.cart)
 

	
 
    def test_paying_invoice_makes_new_cart(self):
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        invoice = InvoiceController.for_cart(current_cart.cart)
 
        invoice.pay("A payment!", invoice.invoice.value)
 

	
 
        # This payment is for the correct amount invoice should be paid.
 
        self.assertTrue(invoice.invoice.paid)
 

	
 
        # Cart should not be active
 
        self.assertFalse(invoice.invoice.cart.active)
 

	
 
        # Asking for a cart should generate a new one
 
        new_cart = CartController.for_user(self.USER_1)
 
        self.assertNotEqual(current_cart.cart, new_cart.cart)
 

	
 

	
 
    def test_invoice_includes_discounts(self):
 
        voucher = rego.Voucher.objects.create(
 
            recipient="Voucher recipient",
 
            code="VOUCHER",
 
            limit=1
 
        )
 
        voucher.save()
 
        discount = rego.VoucherDiscount.objects.create(
 
            description="VOUCHER RECIPIENT",
 
            voucher=voucher,
 
        )
 
        discount.save()
 
        rego.DiscountForProduct.objects.create(
 
            discount=discount,
 
            product=self.PROD_1,
 
            percentage=Decimal(50),
 
            quantity=1
 
        ).save()
 

	
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.apply_voucher(voucher)
 

	
 
        # Should be able to create an invoice after the product is added
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        invoice_1 = InvoiceController.for_cart(current_cart.cart)
 

	
 
        # That invoice should have two line items
 
        line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice)
 
        self.assertEqual(2, len(line_items))
 
        # That invoice should have a value equal to 50% of the cost of PROD_1
 
        self.assertEqual(self.PROD_1.price * Decimal("0.5"), invoice_1.invoice.value)
registrasion/tests/test_voucher.py
Show inline comments
 
new file 100644
 
import datetime
 
import pytz
 

	
 
from decimal import Decimal
 
from django.contrib.auth.models import User
 
from django.core.exceptions import ValidationError
 
from django.test import TestCase
 
from django.utils import timezone
 

	
 
from registrasion import models as rego
 
from registrasion.cart import CartController
 

	
 
from test_cart import RegistrationCartTestCase
 

	
 
UTC = pytz.timezone('UTC')
 

	
 

	
 
class VoucherTestCases(RegistrationCartTestCase):
 

	
 
    @classmethod
 
    def new_voucher(self):
 
        voucher = rego.Voucher.objects.create(
 
            recipient="Voucher recipient",
 
            code="VOUCHER",
 
            limit=1
 
        )
 
        voucher.save()
 
        return voucher
 

	
 
    def test_apply_voucher(self):
 
        voucher = self.new_voucher()
 

	
 
        self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC))
 

	
 
        cart_1 = CartController.for_user(self.USER_1)
 
        cart_1.apply_voucher(voucher)
 
        self.assertIn(voucher, cart_1.cart.vouchers.all())
 

	
 
        # Second user should not be able to apply this voucher (it's exhausted)
 
        cart_2 = CartController.for_user(self.USER_2)
 
        with self.assertRaises(ValidationError):
 
            cart_2.apply_voucher(voucher)
 

	
 
        # After the reservation duration, user 2 should be able to apply voucher
 
        self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2)
 
        cart_2.apply_voucher(voucher)
 
        cart_2.cart.active = False
 
        cart_2.cart.save()
 

	
 
        # After the reservation duration, user 1 should not be able to apply
 
        # voucher, as user 2 has paid for their cart.
 
        self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2)
 
        with self.assertRaises(ValidationError):
 
            cart_1.apply_voucher(voucher)
 

	
 
    def test_voucher_enables_item(self):
 
        voucher = self.new_voucher()
 

	
 
        enabling_condition = rego.VoucherEnablingCondition.objects.create(
 
            description="Voucher condition",
 
            voucher=voucher,
 
            mandatory=False,
 
        )
 
        enabling_condition.save()
 
        enabling_condition.products.add(self.PROD_1)
 
        enabling_condition.save()
 

	
 
        # Adding the product without a voucher will not work
 
        current_cart = CartController.for_user(self.USER_1)
 
        with self.assertRaises(ValidationError):
 
            current_cart.add_to_cart(self.PROD_1, 1)
 

	
 
        # Apply the voucher
 
        current_cart.apply_voucher(voucher)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 

	
 

	
 
    def test_voucher_enables_discount(self):
 
        voucher = self.new_voucher()
 

	
 
        discount = rego.VoucherDiscount.objects.create(
 
            description="VOUCHER RECIPIENT",
 
            voucher=voucher,
 
        )
 
        discount.save()
 
        rego.DiscountForProduct.objects.create(
 
            discount=discount,
 
            product=self.PROD_1,
 
            percentage=Decimal(100),
 
            quantity=1
 
        ).save()
 

	
 
        # Having PROD_1 in place should add a discount
 
        current_cart = CartController.for_user(self.USER_1)
 
        current_cart.apply_voucher(voucher)
 
        current_cart.add_to_cart(self.PROD_1, 1)
 
        self.assertEqual(1, len(current_cart.cart.discountitem_set.all()))
0 comments (0 inline, 0 general)