Imports code from old Symposion repo
This commit is contained in:
parent
ecd5e08263
commit
d9e433659d
19 changed files with 2554 additions and 0 deletions
298
design/design.md
Normal file
298
design/design.md
Normal file
|
@ -0,0 +1,298 @@
|
|||
# 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
|
3
registrasion/__init__.py
Normal file
3
registrasion/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
__version__ = "0.1a1"
|
||||
|
||||
default_app_config = "registrasion.apps.RegistrasionConfig"
|
83
registrasion/admin.py
Normal file
83
registrasion/admin.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
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,
|
||||
]
|
8
registrasion/apps.py
Normal file
8
registrasion/apps.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from __future__ import unicode_literals
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RegistrasionConfig(AppConfig):
|
||||
name = "registrasion"
|
||||
label = "registrasion"
|
||||
verbose_name = "Registrasion"
|
210
registrasion/cart.py
Normal file
210
registrasion/cart.py
Normal file
|
@ -0,0 +1,210 @@
|
|||
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()
|
160
registrasion/conditions.py
Normal file
160
registrasion/conditions.py
Normal file
|
@ -0,0 +1,160 @@
|
|||
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
|
99
registrasion/controllers.py
Normal file
99
registrasion/controllers.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
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
|
137
registrasion/invoice.py
Normal file
137
registrasion/invoice.py
Normal file
|
@ -0,0 +1,137 @@
|
|||
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()
|
270
registrasion/migrations/0001_initial.py
Normal file
270
registrasion/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,270 @@
|
|||
# -*- 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'),
|
||||
),
|
||||
]
|
0
registrasion/migrations/__init__.py
Normal file
0
registrasion/migrations/__init__.py
Normal file
375
registrasion/models.py
Normal file
375
registrasion/models.py
Normal file
|
@ -0,0 +1,375 @@
|
|||
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)
|
1
registrasion/tests/__init__.py
Normal file
1
registrasion/tests/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
default_app_config = "registrasion.apps.RegistrationConfig"
|
24
registrasion/tests/patch_datetime.py
Normal file
24
registrasion/tests/patch_datetime.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
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
|
185
registrasion/tests/test_cart.py
Normal file
185
registrasion/tests/test_cart.py
Normal file
|
@ -0,0 +1,185 @@
|
|||
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)
|
141
registrasion/tests/test_ceilings.py
Normal file
141
registrasion/tests/test_ceilings.py
Normal file
|
@ -0,0 +1,141 @@
|
|||
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()
|
184
registrasion/tests/test_discount.py
Normal file
184
registrasion/tests/test_discount.py
Normal file
|
@ -0,0 +1,184 @@
|
|||
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)
|
171
registrasion/tests/test_enabling_condition.py
Normal file
171
registrasion/tests/test_enabling_condition.py
Normal file
|
@ -0,0 +1,171 @@
|
|||
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)
|
108
registrasion/tests/test_invoice.py
Normal file
108
registrasion/tests/test_invoice.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
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)
|
97
registrasion/tests/test_voucher.py
Normal file
97
registrasion/tests/test_voucher.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
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()))
|
Loading…
Reference in a new issue