From d9e433659d012540de3712fe8832970e36930567 2016-01-22 05:19:11 From: Christopher Neugebauer Date: 2016-01-22 05:19:11 Subject: [PATCH] Imports code from old Symposion repo --- diff --git a/design/design.md b/design/design.md new file mode 100644 index 0000000000000000000000000000000000000000..116ed2ca593e0857c52f0ba101e477171e325968 --- /dev/null +++ b/design/design.md @@ -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 diff --git a/registrasion/__init__.py b/registrasion/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..97a6f232e32b5f04f4434afcf60d812adb65f614 --- /dev/null +++ b/registrasion/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1a1" + +default_app_config = "registrasion.apps.RegistrasionConfig" diff --git a/registrasion/admin.py b/registrasion/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..7401695056312904102f169fc519d1cab070839e --- /dev/null +++ b/registrasion/admin.py @@ -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, + ] diff --git a/registrasion/apps.py b/registrasion/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..fd35af4a09cee8d1584f63c2cf48642916ebf934 --- /dev/null +++ b/registrasion/apps.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +from django.apps import AppConfig + + +class RegistrasionConfig(AppConfig): + name = "registrasion" + label = "registrasion" + verbose_name = "Registrasion" diff --git a/registrasion/cart.py b/registrasion/cart.py new file mode 100644 index 0000000000000000000000000000000000000000..521980f61bbface808f6edb21b923071174ad631 --- /dev/null +++ b/registrasion/cart.py @@ -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() diff --git a/registrasion/conditions.py b/registrasion/conditions.py new file mode 100644 index 0000000000000000000000000000000000000000..27770c9507a36f88adf7db7043739a2c08adf1f2 --- /dev/null +++ b/registrasion/conditions.py @@ -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 diff --git a/registrasion/controllers.py b/registrasion/controllers.py new file mode 100644 index 0000000000000000000000000000000000000000..795c2d48abbbdc1f690dd1578dfbb265393c1748 --- /dev/null +++ b/registrasion/controllers.py @@ -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 diff --git a/registrasion/invoice.py b/registrasion/invoice.py new file mode 100644 index 0000000000000000000000000000000000000000..67a8c836646798b3356c2ca632873385fb885d3d --- /dev/null +++ b/registrasion/invoice.py @@ -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() diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..bfa64439d2bf1005fe7d0f4b159ca7529913f202 --- /dev/null +++ b/registrasion/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/registrasion/migrations/__init__.py b/registrasion/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/registrasion/models.py b/registrasion/models.py new file mode 100644 index 0000000000000000000000000000000000000000..cc3c6aac0f97ec19bcecfb09619567dc0bfaaa24 --- /dev/null +++ b/registrasion/models.py @@ -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) diff --git a/registrasion/tests/__init__.py b/registrasion/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7f9e07036dace796c06545cbd796a18a0e17a84f --- /dev/null +++ b/registrasion/tests/__init__.py @@ -0,0 +1 @@ +default_app_config = "registrasion.apps.RegistrationConfig" diff --git a/registrasion/tests/patch_datetime.py b/registrasion/tests/patch_datetime.py new file mode 100644 index 0000000000000000000000000000000000000000..f114933185a598693f7aa24c78990a227e0a9b23 --- /dev/null +++ b/registrasion/tests/patch_datetime.py @@ -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 diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py new file mode 100644 index 0000000000000000000000000000000000000000..d994d3d4470c58eac45cc6566ba1f13e84ae0d8f --- /dev/null +++ b/registrasion/tests/test_cart.py @@ -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) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py new file mode 100644 index 0000000000000000000000000000000000000000..8e5f60d8597b8b0ee402ac6aa72a8f973e7a078e --- /dev/null +++ b/registrasion/tests/test_ceilings.py @@ -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() diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py new file mode 100644 index 0000000000000000000000000000000000000000..0199ac5205134f6289eb461993fa3d5861074326 --- /dev/null +++ b/registrasion/tests/test_discount.py @@ -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) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py new file mode 100644 index 0000000000000000000000000000000000000000..9e6b8966b0092eb0edb52c2599514d63793bb838 --- /dev/null +++ b/registrasion/tests/test_enabling_condition.py @@ -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) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py new file mode 100644 index 0000000000000000000000000000000000000000..323a425e2d0f341e22cf4771b6b230f97b0a1c9f --- /dev/null +++ b/registrasion/tests/test_invoice.py @@ -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) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py new file mode 100644 index 0000000000000000000000000000000000000000..0c9e362c218acd93e6940dd9b0ae5e7dd6f8f489 --- /dev/null +++ b/registrasion/tests/test_voucher.py @@ -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()))