From 875f736d67c52b98e9222bcb02fc486f0a608e29 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Apr 2016 15:06:24 +1000 Subject: [PATCH] Consolidates models.py into a directory module. --- registrasion/admin.py | 41 +- registrasion/controllers/cart.py | 36 +- registrasion/controllers/category.py | 11 +- registrasion/controllers/conditions.py | 40 +- registrasion/controllers/credit_note.py | 6 +- registrasion/controllers/discount.py | 13 +- registrasion/controllers/invoice.py | 46 +- registrasion/controllers/product.py | 9 +- registrasion/forms.py | 16 +- registrasion/models.py | 818 ------------------ registrasion/models/__init__.py | 4 + registrasion/models/commerce.py | 304 +++++++ registrasion/models/conditions.py | 361 ++++++++ registrasion/models/inventory.py | 172 ++++ registrasion/models/people.py | 79 ++ .../templatetags/registrasion_tags.py | 13 +- registrasion/tests/controller_helpers.py | 8 +- registrasion/tests/test_cart.py | 41 +- registrasion/tests/test_ceilings.py | 6 +- registrasion/tests/test_discount.py | 16 +- registrasion/tests/test_flag.py | 48 +- registrasion/tests/test_invoice.py | 78 +- registrasion/tests/test_voucher.py | 15 +- registrasion/views.py | 52 +- 24 files changed, 1193 insertions(+), 1040 deletions(-) delete mode 100644 registrasion/models.py create mode 100644 registrasion/models/__init__.py create mode 100644 registrasion/models/commerce.py create mode 100644 registrasion/models/conditions.py create mode 100644 registrasion/models/inventory.py create mode 100644 registrasion/models/people.py diff --git a/registrasion/admin.py b/registrasion/admin.py index ff9b0ec4..35f73e9f 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -4,7 +4,8 @@ from django.utils.translation import ugettext_lazy as _ import nested_admin -from registrasion import models as rego +from registrasion.models import conditions +from registrasion.models import inventory class EffectsDisplayMixin(object): @@ -15,12 +16,12 @@ class EffectsDisplayMixin(object): class ProductInline(admin.TabularInline): - model = rego.Product + model = inventory.Product -@admin.register(rego.Category) +@admin.register(inventory.Category) class CategoryAdmin(admin.ModelAdmin): - model = rego.Category + model = inventory.Category fields = ("name", "description", "required", "render_type", "limit_per_user", "order",) list_display = ("name", "description") @@ -29,9 +30,9 @@ class CategoryAdmin(admin.ModelAdmin): ] -@admin.register(rego.Product) +@admin.register(inventory.Product) class ProductAdmin(admin.ModelAdmin): - model = rego.Product + model = inventory.Product list_display = ("name", "category", "description") list_filter = ("category", ) @@ -39,18 +40,18 @@ class ProductAdmin(admin.ModelAdmin): # Discounts class DiscountForProductInline(admin.TabularInline): - model = rego.DiscountForProduct + model = conditions.DiscountForProduct verbose_name = _("Product included in discount") verbose_name_plural = _("Products included in discount") class DiscountForCategoryInline(admin.TabularInline): - model = rego.DiscountForCategory + model = conditions.DiscountForCategory verbose_name = _("Category included in discount") verbose_name_plural = _("Categories included in discount") -@admin.register(rego.TimeOrStockLimitDiscount) +@admin.register(conditions.TimeOrStockLimitDiscount) class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): list_display = ( "description", @@ -67,7 +68,7 @@ class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): ] -@admin.register(rego.IncludedProductDiscount) +@admin.register(conditions.IncludedProductDiscount) class IncludedProductDiscountAdmin(admin.ModelAdmin): def enablers(self, obj): @@ -87,7 +88,7 @@ class IncludedProductDiscountAdmin(admin.ModelAdmin): # Vouchers class VoucherDiscountInline(nested_admin.NestedStackedInline): - model = rego.VoucherDiscount + model = conditions.VoucherDiscount verbose_name = _("Discount") # TODO work out why we're allowed to add more than one? @@ -100,7 +101,7 @@ class VoucherDiscountInline(nested_admin.NestedStackedInline): class VoucherFlagInline(nested_admin.NestedStackedInline): - model = rego.VoucherFlag + model = conditions.VoucherFlag verbose_name = _("Product and category enabled by voucher") verbose_name_plural = _("Products and categories enabled by voucher") @@ -109,7 +110,7 @@ class VoucherFlagInline(nested_admin.NestedStackedInline): extra = 1 -@admin.register(rego.Voucher) +@admin.register(inventory.Voucher) class VoucherAdmin(nested_admin.NestedAdmin): def effects(self, obj): @@ -133,7 +134,7 @@ class VoucherAdmin(nested_admin.NestedAdmin): return "\n".join(out) - model = rego.Voucher + model = inventory.Voucher list_display = ("recipient", "code", "effects") inlines = [ VoucherDiscountInline, @@ -142,7 +143,7 @@ class VoucherAdmin(nested_admin.NestedAdmin): # Enabling conditions -@admin.register(rego.ProductFlag) +@admin.register(conditions.ProductFlag) class ProductFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): @@ -150,7 +151,7 @@ class ProductFlagAdmin( def enablers(self, obj): return list(obj.enabling_products.all()) - model = rego.ProductFlag + model = conditions.ProductFlag fields = ("description", "enabling_products", "condition", "products", "categories"), @@ -158,12 +159,12 @@ class ProductFlagAdmin( # Enabling conditions -@admin.register(rego.CategoryFlag) +@admin.register(conditions.CategoryFlag) class CategoryFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): - model = rego.CategoryFlag + model = conditions.CategoryFlag fields = ("description", "enabling_category", "condition", "products", "categories"), @@ -172,11 +173,11 @@ class CategoryFlagAdmin( # Enabling conditions -@admin.register(rego.TimeOrStockLimitFlag) +@admin.register(conditions.TimeOrStockLimitFlag) class TimeOrStockLimitFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): - model = rego.TimeOrStockLimitFlag + model = conditions.TimeOrStockLimitFlag list_display = ( "description", diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 70972be5..a43115de 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -9,8 +9,10 @@ from django.db import transaction from django.db.models import Max from django.utils import timezone -from registrasion import models as rego from registrasion.exceptions import CartValidationError +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory from category import CategoryController from conditions import ConditionController @@ -28,9 +30,9 @@ class CartController(object): if there isn't one ready yet. ''' try: - existing = rego.Cart.objects.get(user=user, active=True) + existing = commerce.Cart.objects.get(user=user, active=True) except ObjectDoesNotExist: - existing = rego.Cart.objects.create( + existing = commerce.Cart.objects.create( user=user, time_last_updated=timezone.now(), reservation_duration=datetime.timedelta(), @@ -47,10 +49,10 @@ class CartController(object): # 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) + reservations.append(inventory.Voucher.RESERVATION_DURATION) # Else, it's the maximum of the included products - items = rego.ProductItem.objects.filter(cart=self.cart) + items = commerce.ProductItem.objects.filter(cart=self.cart) agg = items.aggregate(Max("product__reservation_duration")) product_max = agg["product__reservation_duration__max"] @@ -79,7 +81,7 @@ class CartController(object): is violated. `product_quantities` is an iterable of (product, quantity) pairs. ''' - items_in_cart = rego.ProductItem.objects.filter(cart=self.cart) + items_in_cart = commerce.ProductItem.objects.filter(cart=self.cart) items_in_cart = items_in_cart.select_related( "product", "product__category", @@ -99,14 +101,14 @@ class CartController(object): for product, quantity in product_quantities: try: - product_item = rego.ProductItem.objects.get( + product_item = commerce.ProductItem.objects.get( cart=self.cart, product=product, ) product_item.quantity = quantity product_item.save() except ObjectDoesNotExist: - rego.ProductItem.objects.create( + commerce.ProductItem.objects.create( cart=self.cart, product=product, quantity=quantity, @@ -176,7 +178,7 @@ class CartController(object): ''' Applies the voucher with the given code to this cart. ''' # Try and find the voucher - voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + voucher = inventory.Voucher.objects.get(code=voucher_code.upper()) # Re-applying vouchers should be idempotent if voucher in self.cart.vouchers.all(): @@ -193,7 +195,7 @@ class CartController(object): Raises ValidationError if not. ''' # Is voucher exhausted? - active_carts = rego.Cart.reserved_carts() + active_carts = commerce.Cart.reserved_carts() # It's invalid for a user to enter a voucher that's exhausted carts_with_voucher = active_carts.filter(vouchers=voucher) @@ -238,7 +240,7 @@ class CartController(object): except ValidationError as ve: errors.append(ve) - items = rego.ProductItem.objects.filter(cart=cart) + items = commerce.ProductItem.objects.filter(cart=cart) product_quantities = list((i.product, i.quantity) for i in items) try: @@ -248,7 +250,7 @@ class CartController(object): errors.append(error.message[1]) # Validate the discounts - discount_items = rego.DiscountItem.objects.filter(cart=cart) + discount_items = commerce.DiscountItem.objects.filter(cart=cart) seen_discounts = set() for discount_item in discount_items: @@ -256,7 +258,7 @@ class CartController(object): if discount in seen_discounts: continue seen_discounts.add(discount) - real_discount = rego.DiscountBase.objects.get_subclass( + real_discount = conditions.DiscountBase.objects.get_subclass( pk=discount.pk) cond = ConditionController.for_condition(real_discount) @@ -287,7 +289,7 @@ class CartController(object): self.cart.vouchers.remove(voucher) # Fix products and discounts - items = rego.ProductItem.objects.filter(cart=self.cart) + items = commerce.ProductItem.objects.filter(cart=self.cart) items = items.select_related("product") products = set(i.product for i in items) available = set(ProductController.available_products( @@ -306,7 +308,7 @@ class CartController(object): ''' # Delete the existing entries. - rego.DiscountItem.objects.filter(cart=self.cart).delete() + commerce.DiscountItem.objects.filter(cart=self.cart).delete() product_items = self.cart.productitem_set.all().select_related( "product", "product__category", @@ -331,7 +333,7 @@ class CartController(object): def matches(discount): ''' Returns True if and only if the given discount apples to our product. ''' - if isinstance(discount.clause, rego.DiscountForCategory): + if isinstance(discount.clause, conditions.DiscountForCategory): return discount.clause.category == product.category else: return discount.clause.product == product @@ -356,7 +358,7 @@ class CartController(object): # 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( + discount_item = commerce.DiscountItem.objects.create( product=product, cart=self.cart, discount=candidate.discount, diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index a3753600..94040b5f 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -1,4 +1,5 @@ -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from django.db.models import Sum @@ -22,7 +23,9 @@ class CategoryController(object): from product import ProductController if products is AllProducts: - products = rego.Product.objects.all().select_related("category") + products = inventory.Product.objects.all().select_related( + "category", + ) available = ProductController.available_products( user, @@ -41,13 +44,13 @@ class CategoryController(object): # We don't need to waste the following queries return 99999999 - carts = rego.Cart.objects.filter( + carts = commerce.Cart.objects.filter( user=user, active=False, released=False, ) - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( cart__in=carts, product__category=self.category, ) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index fde2f805..e96b6590 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -7,7 +7,9 @@ from collections import namedtuple from django.db.models import Sum from django.utils import timezone -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory ConditionAndRemainder = namedtuple( @@ -29,15 +31,15 @@ class ConditionController(object): @staticmethod def for_condition(condition): CONTROLLERS = { - rego.CategoryFlag: CategoryConditionController, - rego.IncludedProductDiscount: ProductConditionController, - rego.ProductFlag: ProductConditionController, - rego.TimeOrStockLimitDiscount: + conditions.CategoryFlag: CategoryConditionController, + conditions.IncludedProductDiscount: ProductConditionController, + conditions.ProductFlag: ProductConditionController, + conditions.TimeOrStockLimitDiscount: TimeOrStockLimitDiscountController, - rego.TimeOrStockLimitFlag: + conditions.TimeOrStockLimitFlag: TimeOrStockLimitFlagController, - rego.VoucherDiscount: VoucherConditionController, - rego.VoucherFlag: VoucherConditionController, + conditions.VoucherDiscount: VoucherConditionController, + conditions.VoucherFlag: VoucherConditionController, } try: @@ -121,7 +123,7 @@ class ConditionController(object): # Get all products covered by this condition, and the products # from the categories covered by this condition cond_products = condition.products.all() - from_category = rego.Product.objects.filter( + from_category = inventory.Product.objects.filter( category__in=condition.categories.all(), ).all() all_products = cond_products | from_category @@ -199,11 +201,11 @@ class CategoryConditionController(ConditionController): ''' 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, released=False) - enabling_products = rego.Product.objects.filter( + carts = commerce.Cart.objects.filter(user=user, released=False) + enabling_products = inventory.Product.objects.filter( category=self.condition.enabling_category, ) - products_count = rego.ProductItem.objects.filter( + products_count = commerce.ProductItem.objects.filter( cart__in=carts, product__in=enabling_products, ).count() @@ -221,8 +223,8 @@ class ProductConditionController(ConditionController): ''' returns True if the user has a product that invokes this condition in one of their carts ''' - carts = rego.Cart.objects.filter(user=user, released=False) - products_count = rego.ProductItem.objects.filter( + carts = commerce.Cart.objects.filter(user=user, released=False) + products_count = commerce.ProductItem.objects.filter( cart__in=carts, product__in=self.condition.enabling_products.all(), ).count() @@ -267,7 +269,7 @@ class TimeOrStockLimitConditionController(ConditionController): return 99999999 # We care about all reserved carts, but not the user's current cart - reserved_carts = rego.Cart.reserved_carts() + reserved_carts = commerce.Cart.reserved_carts() reserved_carts = reserved_carts.exclude( user=user, active=True, @@ -284,12 +286,12 @@ class TimeOrStockLimitFlagController( TimeOrStockLimitConditionController): def _items(self): - category_products = rego.Product.objects.filter( + category_products = inventory.Product.objects.filter( category__in=self.ceiling.categories.all(), ) products = self.ceiling.products.all() | category_products - product_items = rego.ProductItem.objects.filter( + product_items = commerce.ProductItem.objects.filter( product__in=products.all(), ) return product_items @@ -298,7 +300,7 @@ class TimeOrStockLimitFlagController( class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController): def _items(self): - discount_items = rego.DiscountItem.objects.filter( + discount_items = commerce.DiscountItem.objects.filter( discount=self.ceiling, ) return discount_items @@ -312,7 +314,7 @@ class VoucherConditionController(ConditionController): def is_met(self, user): ''' returns True if the user has the given voucher attached. ''' - carts_count = rego.Cart.objects.filter( + carts_count = commerce.Cart.objects.filter( user=user, vouchers=self.condition.voucher, ).count() diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index bd15947d..e1f0ed2b 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -1,6 +1,6 @@ from django.db import transaction -from registrasion import models as rego +from registrasion.models import commerce class CreditNoteController(object): @@ -14,7 +14,7 @@ class CreditNoteController(object): the given invoice. You need to call InvoiceController.update_status() to set the status correctly, if appropriate. ''' - credit_note = rego.CreditNote.objects.create( + credit_note = commerce.CreditNote.objects.create( invoice=invoice, amount=0-value, # Credit notes start off as a payment against inv. reference="ONE MOMENT", @@ -39,7 +39,7 @@ class CreditNoteController(object): inv.validate_allowed_to_pay() # Apply payment to invoice - rego.CreditNoteApplication.objects.create( + commerce.CreditNoteApplication.objects.create( parent=self.credit_note, invoice=invoice, amount=self.credit_note.value, diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 624e78a1..8b11c36a 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -1,7 +1,8 @@ import itertools from conditions import ConditionController -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions from django.db.models import Sum @@ -24,15 +25,15 @@ def available_discounts(user, categories, products): not including products that are pending purchase. ''' # discounts that match provided categories - category_discounts = rego.DiscountForCategory.objects.filter( + category_discounts = conditions.DiscountForCategory.objects.filter( category__in=categories ) # discounts that match provided products - product_discounts = rego.DiscountForProduct.objects.filter( + product_discounts = conditions.DiscountForProduct.objects.filter( product__in=products ) # discounts that match categories for provided products - product_category_discounts = rego.DiscountForCategory.objects.filter( + product_category_discounts = conditions.DiscountForCategory.objects.filter( category__in=(product.category for product in products) ) # (Not relevant: discounts that match products in provided categories) @@ -60,7 +61,7 @@ def available_discounts(user, categories, products): failed_discounts = set() for discount in potential_discounts: - real_discount = rego.DiscountBase.objects.get_subclass( + real_discount = conditions.DiscountBase.objects.get_subclass( pk=discount.discount.pk, ) cond = ConditionController.for_condition(real_discount) @@ -68,7 +69,7 @@ def available_discounts(user, categories, products): # Count the past uses of the given discount item. # If this user has exceeded the limit for the clause, this clause # is not available any more. - past_uses = rego.DiscountItem.objects.filter( + past_uses = commerce.DiscountItem.objects.filter( cart__user=user, cart__active=False, # Only past carts count cart__released=False, # You can reuse refunded discounts diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 58b0beb6..fff97e8d 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -5,7 +5,9 @@ from django.db import transaction from django.db.models import Sum from django.utils import timezone -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import people from cart import CartController from credit_note import CreditNoteController @@ -25,8 +27,8 @@ class InvoiceController(object): an invoice is generated.''' try: - invoice = rego.Invoice.objects.exclude( - status=rego.Invoice.STATUS_VOID, + invoice = commerce.Invoice.objects.exclude( + status=commerce.Invoice.STATUS_VOID, ).get( cart=cart, cart_revision=cart.revision, @@ -42,19 +44,19 @@ class InvoiceController(object): @classmethod def void_all_invoices(cls, cart): - invoices = rego.Invoice.objects.filter(cart=cart).all() + invoices = commerce.Invoice.objects.filter(cart=cart).all() for invoice in invoices: cls(invoice).void() @classmethod def resolve_discount_value(cls, item): try: - condition = rego.DiscountForProduct.objects.get( + condition = conditions.DiscountForProduct.objects.get( discount=item.discount, product=item.product ) except ObjectDoesNotExist: - condition = rego.DiscountForCategory.objects.get( + condition = conditions.DiscountForCategory.objects.get( discount=item.discount, category=item.product.category ) @@ -75,22 +77,22 @@ class InvoiceController(object): due = max(issued, reservation_limit) # Get the invoice recipient - profile = rego.AttendeeProfileBase.objects.get_subclass( + profile = people.AttendeeProfileBase.objects.get_subclass( id=cart.user.attendee.attendeeprofilebase.id, ) recipient = profile.invoice_recipient() - invoice = rego.Invoice.objects.create( + invoice = commerce.Invoice.objects.create( user=cart.user, cart=cart, cart_revision=cart.revision, - status=rego.Invoice.STATUS_UNPAID, + status=commerce.Invoice.STATUS_UNPAID, value=Decimal(), issue_time=issued, due_time=due, recipient=recipient, ) - product_items = rego.ProductItem.objects.filter(cart=cart) + product_items = commerce.ProductItem.objects.filter(cart=cart) if len(product_items) == 0: raise ValidationError("Your cart is empty.") @@ -98,11 +100,11 @@ class InvoiceController(object): product_items = product_items.order_by( "product__category__order", "product__order" ) - discount_items = rego.DiscountItem.objects.filter(cart=cart) + discount_items = commerce.DiscountItem.objects.filter(cart=cart) invoice_value = Decimal() for item in product_items: product = item.product - line_item = rego.LineItem.objects.create( + line_item = commerce.LineItem.objects.create( invoice=invoice, description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, @@ -112,7 +114,7 @@ class InvoiceController(object): invoice_value += line_item.quantity * line_item.price for item in discount_items: - line_item = rego.LineItem.objects.create( + line_item = commerce.LineItem.objects.create( invoice=invoice, description=item.discount.description, quantity=item.quantity, @@ -170,7 +172,7 @@ class InvoiceController(object): def total_payments(self): ''' Returns the total amount paid towards this invoice. ''' - payments = rego.PaymentBase.objects.filter(invoice=self.invoice) + payments = commerce.PaymentBase.objects.filter(invoice=self.invoice) total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0 return total_paid @@ -180,12 +182,12 @@ class InvoiceController(object): old_status = self.invoice.status total_paid = self.total_payments() - num_payments = rego.PaymentBase.objects.filter( + num_payments = commerce.PaymentBase.objects.filter( invoice=self.invoice, ).count() remainder = self.invoice.value - total_paid - if old_status == rego.Invoice.STATUS_UNPAID: + if old_status == commerce.Invoice.STATUS_UNPAID: # Invoice had an amount owing if remainder <= 0: # Invoice no longer has amount owing @@ -199,15 +201,15 @@ class InvoiceController(object): elif total_paid == 0 and num_payments > 0: # Invoice has multiple payments totalling zero self._mark_void() - elif old_status == rego.Invoice.STATUS_PAID: + elif old_status == commerce.Invoice.STATUS_PAID: if remainder > 0: # Invoice went from having a remainder of zero or less # to having a positive remainder -- must be a refund self._mark_refunded() - elif old_status == rego.Invoice.STATUS_REFUNDED: + elif old_status == commerce.Invoice.STATUS_REFUNDED: # Should not ever change from here pass - elif old_status == rego.Invoice.STATUS_VOID: + elif old_status == commerce.Invoice.STATUS_VOID: # Should not ever change from here pass @@ -218,7 +220,7 @@ class InvoiceController(object): if cart: cart.active = False cart.save() - self.invoice.status = rego.Invoice.STATUS_PAID + self.invoice.status = commerce.Invoice.STATUS_PAID self.invoice.save() def _mark_refunded(self): @@ -229,13 +231,13 @@ class InvoiceController(object): cart.active = False cart.released = True cart.save() - self.invoice.status = rego.Invoice.STATUS_REFUNDED + self.invoice.status = commerce.Invoice.STATUS_REFUNDED self.invoice.save() def _mark_void(self): ''' Marks the invoice as refunded, and updates the attached cart if necessary. ''' - self.invoice.status = rego.Invoice.STATUS_VOID + self.invoice.status = commerce.Invoice.STATUS_VOID self.invoice.save() def _invoice_matches_cart(self): diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index a28f99cb..c6f8370c 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,7 +1,8 @@ import itertools from django.db.models import Sum -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from category import CategoryController from conditions import ConditionController @@ -22,7 +23,7 @@ class ProductController(object): raise ValueError("You must provide products or a category") if category is not None: - all_products = rego.Product.objects.filter(category=category) + all_products = inventory.Product.objects.filter(category=category) all_products = all_products.select_related("category") else: all_products = [] @@ -65,13 +66,13 @@ class ProductController(object): # Don't need to run the remaining queries return 999999 # We can do better - carts = rego.Cart.objects.filter( + carts = commerce.Cart.objects.filter( user=user, active=False, released=False, ) - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( cart__in=carts, product=self.product, ) diff --git a/registrasion/forms.py b/registrasion/forms.py index 2de043f2..1037d229 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -1,4 +1,5 @@ -import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from django import forms @@ -14,8 +15,8 @@ class ApplyCreditNoteForm(forms.Form): self.fields["invoice"].choices = self._unpaid_invoices_for_user def _unpaid_invoices_for_user(self): - invoices = rego.Invoice.objects.filter( - status=rego.Invoice.STATUS_UNPAID, + invoices = commerce.Invoice.objects.filter( + status=commerce.Invoice.STATUS_UNPAID, user=self.user, ) @@ -25,7 +26,6 @@ class ApplyCreditNoteForm(forms.Form): ] invoice = forms.ChoiceField( - #choices=_unpaid_invoices_for_user, required=True, ) @@ -33,14 +33,14 @@ class ApplyCreditNoteForm(forms.Form): class ManualCreditNoteRefundForm(forms.ModelForm): class Meta: - model = rego.ManualCreditNoteRefund + model = commerce.ManualCreditNoteRefund fields = ["reference"] class ManualPaymentForm(forms.ModelForm): class Meta: - model = rego.ManualPayment + model = commerce.ManualPayment fields = ["reference", "amount"] @@ -168,8 +168,8 @@ def ProductsForm(category, products): # Each Category.RENDER_TYPE value has a subclass here. RENDER_TYPES = { - rego.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, - rego.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, + inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, + inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on diff --git a/registrasion/models.py b/registrasion/models.py deleted file mode 100644 index 58dec909..00000000 --- a/registrasion/models.py +++ /dev/null @@ -1,818 +0,0 @@ -from __future__ import unicode_literals - -import util - -import datetime -import itertools - -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 - - -# User models - -@python_2_unicode_compatible -class Attendee(models.Model): - ''' Miscellaneous user-related data. ''' - - def __str__(self): - return "%s" % self.user - - @staticmethod - def get_instance(user): - ''' Returns the instance of attendee for the given user, or creates - a new one. ''' - try: - return Attendee.objects.get(user=user) - except ObjectDoesNotExist: - return Attendee.objects.create(user=user) - - def save(self, *a, **k): - while not self.access_code: - access_code = util.generate_access_code() - if Attendee.objects.filter(access_code=access_code).count() == 0: - self.access_code = access_code - return super(Attendee, self).save(*a, **k) - - user = models.OneToOneField(User, on_delete=models.CASCADE) - # Badge/profile is linked - access_code = models.CharField( - max_length=6, - unique=True, - db_index=True, - ) - completed_registration = models.BooleanField(default=False) - guided_categories_complete = models.ManyToManyField("category") - - -class AttendeeProfileBase(models.Model): - ''' Information for an attendee's badge and related preferences. - Subclass this in your Django site to ask for attendee information in your - registration progess. - ''' - - objects = InheritanceManager() - - @classmethod - def name_field(cls): - ''' This is used to pre-fill the attendee's name from the - speaker profile. If it's None, that functionality is disabled. ''' - return None - - def invoice_recipient(self): - ''' Returns a representation of this attendee profile for the purpose - of rendering to an invoice. Override in subclasses. ''' - - # Manual dispatch to subclass. Fleh. - slf = AttendeeProfileBase.objects.get_subclass(id=self.id) - # Actually compare the functions. - if type(slf).invoice_recipient != type(self).invoice_recipient: - return type(slf).invoice_recipient(slf) - - # Return a default - return slf.attendee.user.username - - attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) - - -# Inventory Models - -@python_2_unicode_compatible -class Category(models.Model): - ''' Registration product categories ''' - - class Meta: - verbose_name = _("inventory - category") - verbose_name_plural = _("inventory - categories") - ordering = ("order", ) - - 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"), - ) - limit_per_user = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit per user"), - help_text=_("The total number of items from this category one " - "attendee may purchase."), - ) - required = models.BooleanField( - blank=True, - help_text=_("If enabled, a user must select an " - "item from this category."), - ) - order = models.PositiveIntegerField( - verbose_name=("Display order"), - db_index=True, - ) - render_type = models.IntegerField( - choices=CATEGORY_RENDER_TYPES, - verbose_name=_("Render type"), - help_text=_("The registration form will render this category in this " - "style."), - ) - - -@python_2_unicode_compatible -class Product(models.Model): - ''' Registration products ''' - - class Meta: - verbose_name = _("inventory - product") - ordering = ("category__order", "order") - - def __str__(self): - return "%s - %s" % (self.category.name, self.name) - - name = models.CharField( - max_length=65, - verbose_name=_("Name"), - ) - description = models.CharField( - max_length=255, - verbose_name=_("Description"), - null=True, - blank=True, - ) - 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( - null=True, - blank=True, - verbose_name=_("Limit per user"), - ) - reservation_duration = models.DurationField( - default=datetime.timedelta(hours=1), - verbose_name=_("Reservation duration"), - help_text=_("The length of time this product will be reserved before " - "it is released for someone else to purchase."), - ) - order = models.PositiveIntegerField( - verbose_name=("Display order"), - db_index=True, - ) - - -@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 - - @classmethod - def normalise_code(cls, code): - return code.upper() - - def save(self, *a, **k): - ''' Normalise the voucher code to be uppercase ''' - self.code = self.normalise_code(self.code) - super(Voucher, self).save(*a, **k) - - 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 - - def effects(self): - ''' Returns all of the effects of this discount. ''' - products = self.discountforproduct_set.all() - categories = self.discountforcategory_set.all() - return itertools.chain(products, categories) - - description = models.CharField( - max_length=255, - verbose_name=_("Description"), - help_text=_("A description of this discount. This will be included on " - "invoices where this discount is applied."), - ) - - -@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.")) - - prods = DiscountForProduct.objects.filter( - discount=self.discount, - product=self.product) - cats = DiscountForCategory.objects.filter( - discount=self.discount, - category=self.product.category) - if len(prods) > 1: - raise ValidationError( - _("You may only have one discount line per product")) - if len(cats) != 0: - raise ValidationError( - _("You may only have one discount for " - "a product or its category")) - - 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, blank=True) - price = models.DecimalField( - max_digits=8, decimal_places=2, null=True, blank=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) - - def clean(self): - prods = DiscountForProduct.objects.filter( - discount=self.discount, - product__category=self.category) - cats = DiscountForCategory.objects.filter( - discount=self.discount, - category=self.category) - if len(prods) != 0: - raise ValidationError( - _("You may only have one discount for " - "a product or its category")) - if len(cats) > 1: - raise ValidationError( - _("You may only have one discount line per 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) - 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 = _("discount (time/stock limit)") - verbose_name_plural = _("discounts (time/stock limit)") - - start_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("Start time"), - help_text=_("This discount will only be available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("End time"), - help_text=_("This discount will only be available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit"), - help_text=_("This discount may only be applied this many times."), - ) - - -class VoucherDiscount(DiscountBase): - ''' Discounts that are enabled when a voucher code is in the current - cart. ''' - - class Meta: - verbose_name = _("discount (enabled by voucher)") - verbose_name_plural = _("discounts (enabled by voucher)") - - voucher = models.OneToOneField( - Voucher, - on_delete=models.CASCADE, - verbose_name=_("Voucher"), - db_index=True, - ) - - -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 = _("discount (product inclusions)") - verbose_name_plural = _("discounts (product inclusions)") - - enabling_products = models.ManyToManyField( - Product, - verbose_name=_("Including product"), - help_text=_("If one of these products are purchased, the discounts " - "below will be enabled."), - ) - - -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 FlagBase(models.Model): - ''' This defines a condition which allows products or categories to - be made visible, or be prevented from being visible. - - The various subclasses of this can define the conditions that enable - or disable products, by the following rules: - - If there is at least one 'disable if false' flag defined on a product or - category, all such flag conditions must be met. If there is at least one - 'enable if true' flag, at least one such condition must be met. - - If both types of conditions exist on a product, both of these rules apply. - ''' - - class Meta: - # TODO: make concrete once https://code.djangoproject.com/ticket/26488 - # is solved. - abstract = True - - DISABLE_IF_FALSE = 1 - ENABLE_IF_TRUE = 2 - - def __str__(self): - return self.description - - def effects(self): - ''' Returns all of the items affected by this condition. ''' - return itertools.chain(self.products.all(), self.categories.all()) - - @property - def is_disable_if_false(self): - return self.condition == FlagBase.DISABLE_IF_FALSE - - @property - def is_enable_if_true(self): - return self.condition == FlagBase.ENABLE_IF_TRUE - - description = models.CharField(max_length=255) - condition = models.IntegerField( - default=ENABLE_IF_TRUE, - choices=( - (DISABLE_IF_FALSE, _("Disable if false")), - (ENABLE_IF_TRUE, _("Enable if true")), - ), - help_text=_("If there is at least one 'disable if false' flag " - "defined on a product or category, all such flag " - " conditions must be met. If there is at least one " - "'enable if true' flag, at least one such condition must " - "be met. If both types of conditions exist on a product, " - "both of these rules apply." - ), - ) - products = models.ManyToManyField( - Product, - blank=True, - help_text=_("Products affected by this flag's condition."), - related_name="flagbase_set", - ) - categories = models.ManyToManyField( - Category, - blank=True, - help_text=_("Categories whose products are affected by this flag's " - "condition." - ), - related_name="flagbase_set", - ) - - -class EnablingConditionBase(FlagBase): - ''' Reifies the abstract FlagBase. This is necessary because django - prevents renaming base classes in migrations. ''' - # TODO: remove this, and make subclasses subclass FlagBase once - # https://code.djangoproject.com/ticket/26488 is solved. - - objects = InheritanceManager() - - -class TimeOrStockLimitFlag(EnablingConditionBase): - ''' Registration product ceilings ''' - - class Meta: - verbose_name = _("flag (time/stock limit)") - verbose_name_plural = _("flags (time/stock limit)") - - start_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - help_text=_("The number of items under this grouping that can be " - "purchased."), - ) - - -@python_2_unicode_compatible -class ProductFlag(EnablingConditionBase): - ''' The condition is met because a specific product is purchased. ''' - - class Meta: - verbose_name = _("flag (dependency on product)") - verbose_name_plural = _("flags (dependency on product)") - - def __str__(self): - return "Enabled by products: " + str(self.enabling_products.all()) - - enabling_products = models.ManyToManyField( - Product, - help_text=_("If one of these products are purchased, this condition " - "is met."), - ) - - -@python_2_unicode_compatible -class CategoryFlag(EnablingConditionBase): - ''' The condition is met because a product in a particular product is - purchased. ''' - - class Meta: - verbose_name = _("flag (dependency on product from category)") - verbose_name_plural = _("flags (dependency on product from category)") - - def __str__(self): - return "Enabled by product in category: " + str(self.enabling_category) - - enabling_category = models.ForeignKey( - Category, - help_text=_("If a product from this category is purchased, this " - "condition is met."), - ) - - -@python_2_unicode_compatible -class VoucherFlag(EnablingConditionBase): - ''' The condition is met because a Voucher is present. This is for e.g. - enabling sponsor tickets. ''' - - class Meta: - verbose_name = _("flag (dependency on voucher)") - verbose_name_plural = _("flags (dependency on voucher)") - - def __str__(self): - return "Enabled by voucher: %s" % self.voucher - - voucher = models.OneToOneField(Voucher) - - -# @python_2_unicode_compatible -class RoleFlag(object): - ''' The condition is met because the active user has a particular Role. - This is for e.g. enabling Team tickets. ''' - # TODO: implement RoleFlag - 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. ''' - - class Meta: - index_together = [ - ("active", "time_last_updated"), - ("active", "released"), - ("active", "user"), - ("released", "user"), - ] - - 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( - db_index=True, - ) - reservation_duration = models.DurationField() - revision = models.PositiveIntegerField(default=1) - active = models.BooleanField( - default=True, - db_index=True, - ) - released = models.BooleanField( - default=False, - db_index=True - ) # Refunds etc - - @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) & Q(released=False)) - ) - - -@python_2_unicode_compatible -class ProductItem(models.Model): - ''' Represents a product-quantity pair in a Cart. ''' - - class Meta: - ordering = ("product", ) - - 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(db_index=True) - - -@python_2_unicode_compatible -class DiscountItem(models.Model): - ''' Represents a discount-product-quantity relation in a Cart. ''' - - class Meta: - ordering = ("product", ) - - 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. ''' - - STATUS_UNPAID = 1 - STATUS_PAID = 2 - STATUS_REFUNDED = 3 - STATUS_VOID = 4 - - STATUS_TYPES = [ - (STATUS_UNPAID, _("Unpaid")), - (STATUS_PAID, _("Paid")), - (STATUS_REFUNDED, _("Refunded")), - (STATUS_VOID, _("VOID")), - ] - - 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") - - @property - def is_unpaid(self): - return self.status == self.STATUS_UNPAID - - @property - def is_void(self): - return self.status == self.STATUS_VOID - - @property - def is_paid(self): - return self.status == self.STATUS_PAID - - @property - def is_refunded(self): - return self.status == self.STATUS_REFUNDED - - # Invoice Number - user = models.ForeignKey(User) - cart = models.ForeignKey(Cart, null=True) - cart_revision = models.IntegerField( - null=True, - db_index=True, - ) - # Line Items (foreign key) - status = models.IntegerField( - choices=STATUS_TYPES, - db_index=True, - ) - recipient = models.CharField(max_length=1024) - issue_time = models.DateTimeField() - due_time = models.DateTimeField() - 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. ''' - - class Meta: - ordering = ("id", ) - - 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) - product = models.ForeignKey(Product, null=True, blank=True) - - -@python_2_unicode_compatible -class PaymentBase(models.Model): - ''' The base payment type for invoices. Payment apps should subclass this - class to handle implementation-specific issues. ''' - - class Meta: - ordering = ("time", ) - - objects = InheritanceManager() - - 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=255) - amount = models.DecimalField(max_digits=8, decimal_places=2) - - -class ManualPayment(PaymentBase): - ''' Payments that are manually entered by staff. ''' - pass - - -class CreditNote(PaymentBase): - ''' Credit notes represent money accounted for in the system that do not - belong to specific invoices. They may be paid into other invoices, or - cashed out as refunds. - - Each CreditNote may either be used to pay towards another Invoice in the - system (by attaching a CreditNoteApplication), or may be marked as - refunded (by attaching a CreditNoteRefund).''' - - @classmethod - def unclaimed(cls): - return cls.objects.filter( - creditnoteapplication=None, - creditnoterefund=None, - ) - - @property - def status(self): - if self.is_unclaimed: - return "Unclaimed" - - if hasattr(self, 'creditnoteapplication'): - destination = self.creditnoteapplication.invoice.id - return "Applied to invoice %d" % destination - - elif hasattr(self, 'creditnoterefund'): - reference = self.creditnoterefund.reference - print reference - return "Refunded with reference: %s" % reference - - raise ValueError("This should never happen.") - - @property - def is_unclaimed(self): - return not ( - hasattr(self, 'creditnoterefund') or - hasattr(self, 'creditnoteapplication') - ) - - @property - def value(self): - ''' Returns the value of the credit note. Because CreditNotes are - implemented as PaymentBase objects internally, the amount is a - negative payment against an invoice. ''' - return -self.amount - - -class CleanOnSave(object): - - def save(self, *a, **k): - self.full_clean() - super(CleanOnSave, self).save(*a, **k) - - -class CreditNoteApplication(CleanOnSave, PaymentBase): - ''' Represents an application of a credit note to an Invoice. ''' - - def clean(self): - if not hasattr(self, "parent"): - return - if hasattr(self.parent, 'creditnoterefund'): - raise ValidationError( - "Cannot apply a refunded credit note to an invoice" - ) - - parent = models.OneToOneField(CreditNote) - - -class CreditNoteRefund(CleanOnSave, models.Model): - ''' Represents a refund of a credit note to an external payment. - Credit notes may only be refunded in full. How those refunds are handled - is left as an exercise to the payment app. ''' - - def clean(self): - if not hasattr(self, "parent"): - return - if hasattr(self.parent, 'creditnoteapplication'): - raise ValidationError( - "Cannot refund a credit note that has been paid to an invoice" - ) - - parent = models.OneToOneField(CreditNote) - time = models.DateTimeField(default=timezone.now) - reference = models.CharField(max_length=255) - - -class ManualCreditNoteRefund(CreditNoteRefund): - ''' Credit notes that are entered by a staff member. ''' - - entered_by = models.ForeignKey(User) diff --git a/registrasion/models/__init__.py b/registrasion/models/__init__.py new file mode 100644 index 00000000..944229f4 --- /dev/null +++ b/registrasion/models/__init__.py @@ -0,0 +1,4 @@ +from commerce import * # NOQA +from conditions import * # NOQA +from inventory import * # NOQA +from people import * # NOQA diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py new file mode 100644 index 00000000..5df01f49 --- /dev/null +++ b/registrasion/models/commerce.py @@ -0,0 +1,304 @@ +from . import conditions +from . import inventory + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +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 + + +# Commerce Models + +@python_2_unicode_compatible +class Cart(models.Model): + ''' Represents a set of product items that have been purchased, or are + pending purchase. ''' + + class Meta: + app_label = "registrasion" + index_together = [ + ("active", "time_last_updated"), + ("active", "released"), + ("active", "user"), + ("released", "user"), + ] + + def __str__(self): + return "%d rev #%d" % (self.id, self.revision) + + user = models.ForeignKey(User) + # ProductItems (foreign key) + vouchers = models.ManyToManyField(inventory.Voucher, blank=True) + time_last_updated = models.DateTimeField( + db_index=True, + ) + reservation_duration = models.DurationField() + revision = models.PositiveIntegerField(default=1) + active = models.BooleanField( + default=True, + db_index=True, + ) + released = models.BooleanField( + default=False, + db_index=True + ) # Refunds etc + + @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) & Q(released=False)) + ) + + +@python_2_unicode_compatible +class ProductItem(models.Model): + ''' Represents a product-quantity pair in a Cart. ''' + + class Meta: + app_label = "registrasion" + ordering = ("product", ) + + def __str__(self): + return "product: %s * %d in Cart: %s" % ( + self.product, self.quantity, self.cart) + + cart = models.ForeignKey(Cart) + product = models.ForeignKey(inventory.Product) + quantity = models.PositiveIntegerField(db_index=True) + + +@python_2_unicode_compatible +class DiscountItem(models.Model): + ''' Represents a discount-product-quantity relation in a Cart. ''' + + class Meta: + app_label = "registrasion" + ordering = ("product", ) + + 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(inventory.Product) + discount = models.ForeignKey(conditions.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. ''' + + class Meta: + app_label = "registrasion" + + STATUS_UNPAID = 1 + STATUS_PAID = 2 + STATUS_REFUNDED = 3 + STATUS_VOID = 4 + + STATUS_TYPES = [ + (STATUS_UNPAID, _("Unpaid")), + (STATUS_PAID, _("Paid")), + (STATUS_REFUNDED, _("Refunded")), + (STATUS_VOID, _("VOID")), + ] + + 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") + + @property + def is_unpaid(self): + return self.status == self.STATUS_UNPAID + + @property + def is_void(self): + return self.status == self.STATUS_VOID + + @property + def is_paid(self): + return self.status == self.STATUS_PAID + + @property + def is_refunded(self): + return self.status == self.STATUS_REFUNDED + + # Invoice Number + user = models.ForeignKey(User) + cart = models.ForeignKey(Cart, null=True) + cart_revision = models.IntegerField( + null=True, + db_index=True, + ) + # Line Items (foreign key) + status = models.IntegerField( + choices=STATUS_TYPES, + db_index=True, + ) + recipient = models.CharField(max_length=1024) + issue_time = models.DateTimeField() + due_time = models.DateTimeField() + 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. ''' + + class Meta: + app_label = "registrasion" + ordering = ("id", ) + + 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) + product = models.ForeignKey(inventory.Product, null=True, blank=True) + + +@python_2_unicode_compatible +class PaymentBase(models.Model): + ''' The base payment type for invoices. Payment apps should subclass this + class to handle implementation-specific issues. ''' + + class Meta: + ordering = ("time", ) + + objects = InheritanceManager() + + 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=255) + amount = models.DecimalField(max_digits=8, decimal_places=2) + + +class ManualPayment(PaymentBase): + ''' Payments that are manually entered by staff. ''' + + class Meta: + app_label = "registrasion" + + +class CreditNote(PaymentBase): + ''' Credit notes represent money accounted for in the system that do not + belong to specific invoices. They may be paid into other invoices, or + cashed out as refunds. + + Each CreditNote may either be used to pay towards another Invoice in the + system (by attaching a CreditNoteApplication), or may be marked as + refunded (by attaching a CreditNoteRefund).''' + + class Meta: + app_label = "registrasion" + + @classmethod + def unclaimed(cls): + return cls.objects.filter( + creditnoteapplication=None, + creditnoterefund=None, + ) + + @property + def status(self): + if self.is_unclaimed: + return "Unclaimed" + + if hasattr(self, 'creditnoteapplication'): + destination = self.creditnoteapplication.invoice.id + return "Applied to invoice %d" % destination + + elif hasattr(self, 'creditnoterefund'): + reference = self.creditnoterefund.reference + print reference + return "Refunded with reference: %s" % reference + + raise ValueError("This should never happen.") + + @property + def is_unclaimed(self): + return not ( + hasattr(self, 'creditnoterefund') or + hasattr(self, 'creditnoteapplication') + ) + + @property + def value(self): + ''' Returns the value of the credit note. Because CreditNotes are + implemented as PaymentBase objects internally, the amount is a + negative payment against an invoice. ''' + return -self.amount + + +class CleanOnSave(object): + + def save(self, *a, **k): + self.full_clean() + super(CleanOnSave, self).save(*a, **k) + + +class CreditNoteApplication(CleanOnSave, PaymentBase): + ''' Represents an application of a credit note to an Invoice. ''' + + class Meta: + app_label = "registrasion" + + def clean(self): + if not hasattr(self, "parent"): + return + if hasattr(self.parent, 'creditnoterefund'): + raise ValidationError( + "Cannot apply a refunded credit note to an invoice" + ) + + parent = models.OneToOneField(CreditNote) + + +class CreditNoteRefund(CleanOnSave, models.Model): + ''' Represents a refund of a credit note to an external payment. + Credit notes may only be refunded in full. How those refunds are handled + is left as an exercise to the payment app. ''' + + def clean(self): + if not hasattr(self, "parent"): + return + if hasattr(self.parent, 'creditnoteapplication'): + raise ValidationError( + "Cannot refund a credit note that has been paid to an invoice" + ) + + parent = models.OneToOneField(CreditNote) + time = models.DateTimeField(default=timezone.now) + reference = models.CharField(max_length=255) + + +class ManualCreditNoteRefund(CreditNoteRefund): + ''' Credit notes that are entered by a staff member. ''' + + class Meta: + app_label = "registrasion" + + entered_by = models.ForeignKey(User) diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py new file mode 100644 index 00000000..804f3162 --- /dev/null +++ b/registrasion/models/conditions.py @@ -0,0 +1,361 @@ +import itertools + +from . import inventory + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ +from model_utils.managers import InheritanceManager + + +# 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. ''' + + class Meta: + app_label = "registrasion" + + objects = InheritanceManager() + + def __str__(self): + return "Discount: " + self.description + + def effects(self): + ''' Returns all of the effects of this discount. ''' + products = self.discountforproduct_set.all() + categories = self.discountforcategory_set.all() + return itertools.chain(products, categories) + + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + help_text=_("A description of this discount. This will be included on " + "invoices where this discount is applied."), + ) + + +@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. ''' + + class Meta: + app_label = "registrasion" + + 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.")) + + prods = DiscountForProduct.objects.filter( + discount=self.discount, + product=self.product) + cats = DiscountForCategory.objects.filter( + discount=self.discount, + category=self.product.category) + if len(prods) > 1: + raise ValidationError( + _("You may only have one discount line per product")) + if len(cats) != 0: + raise ValidationError( + _("You may only have one discount for " + "a product or its category")) + + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) + product = models.ForeignKey(inventory.Product, on_delete=models.CASCADE) + percentage = models.DecimalField( + max_digits=4, decimal_places=1, null=True, blank=True) + price = models.DecimalField( + max_digits=8, decimal_places=2, null=True, blank=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. ''' + + class Meta: + app_label = "registrasion" + + def __str__(self): + return "%s%% off %s" % (self.percentage, self.category) + + def clean(self): + prods = DiscountForProduct.objects.filter( + discount=self.discount, + product__category=self.category) + cats = DiscountForCategory.objects.filter( + discount=self.discount, + category=self.category) + if len(prods) != 0: + raise ValidationError( + _("You may only have one discount for " + "a product or its category")) + if len(cats) > 1: + raise ValidationError( + _("You may only have one discount line per category")) + + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) + category = models.ForeignKey(inventory.Category, on_delete=models.CASCADE) + percentage = models.DecimalField( + max_digits=4, + decimal_places=1) + 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: + app_label = "registrasion" + verbose_name = _("discount (time/stock limit)") + verbose_name_plural = _("discounts (time/stock limit)") + + start_time = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Start time"), + help_text=_("This discount will only be available after this time."), + ) + end_time = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("End time"), + help_text=_("This discount will only be available before this time."), + ) + limit = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit"), + help_text=_("This discount may only be applied this many times."), + ) + + +class VoucherDiscount(DiscountBase): + ''' Discounts that are enabled when a voucher code is in the current + cart. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("discount (enabled by voucher)") + verbose_name_plural = _("discounts (enabled by voucher)") + + voucher = models.OneToOneField( + inventory.Voucher, + on_delete=models.CASCADE, + verbose_name=_("Voucher"), + db_index=True, + ) + + +class IncludedProductDiscount(DiscountBase): + ''' Discounts that are enabled because another product has been purchased. + e.g. A conference ticket includes a free t-shirt. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("discount (product inclusions)") + verbose_name_plural = _("discounts (product inclusions)") + + enabling_products = models.ManyToManyField( + inventory.Product, + verbose_name=_("Including product"), + help_text=_("If one of these products are purchased, the discounts " + "below will be enabled."), + ) + + +class 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 FlagBase(models.Model): + ''' This defines a condition which allows products or categories to + be made visible, or be prevented from being visible. + + The various subclasses of this can define the conditions that enable + or disable products, by the following rules: + + If there is at least one 'disable if false' flag defined on a product or + category, all such flag conditions must be met. If there is at least one + 'enable if true' flag, at least one such condition must be met. + + If both types of conditions exist on a product, both of these rules apply. + ''' + + class Meta: + # TODO: make concrete once https://code.djangoproject.com/ticket/26488 + # is solved. + abstract = True + + DISABLE_IF_FALSE = 1 + ENABLE_IF_TRUE = 2 + + def __str__(self): + return self.description + + def effects(self): + ''' Returns all of the items affected by this condition. ''' + return itertools.chain(self.products.all(), self.categories.all()) + + @property + def is_disable_if_false(self): + return self.condition == FlagBase.DISABLE_IF_FALSE + + @property + def is_enable_if_true(self): + return self.condition == FlagBase.ENABLE_IF_TRUE + + description = models.CharField(max_length=255) + condition = models.IntegerField( + default=ENABLE_IF_TRUE, + choices=( + (DISABLE_IF_FALSE, _("Disable if false")), + (ENABLE_IF_TRUE, _("Enable if true")), + ), + help_text=_("If there is at least one 'disable if false' flag " + "defined on a product or category, all such flag " + " conditions must be met. If there is at least one " + "'enable if true' flag, at least one such condition must " + "be met. If both types of conditions exist on a product, " + "both of these rules apply." + ), + ) + products = models.ManyToManyField( + inventory.Product, + blank=True, + help_text=_("Products affected by this flag's condition."), + related_name="flagbase_set", + ) + categories = models.ManyToManyField( + inventory.Category, + blank=True, + help_text=_("Categories whose products are affected by this flag's " + "condition." + ), + related_name="flagbase_set", + ) + + +class EnablingConditionBase(FlagBase): + ''' Reifies the abstract FlagBase. This is necessary because django + prevents renaming base classes in migrations. ''' + # TODO: remove this, and make subclasses subclass FlagBase once + # https://code.djangoproject.com/ticket/26488 is solved. + + class Meta: + app_label = "registrasion" + + objects = InheritanceManager() + + +class TimeOrStockLimitFlag(EnablingConditionBase): + ''' Registration product ceilings ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (time/stock limit)") + verbose_name_plural = _("flags (time/stock limit)") + + start_time = models.DateTimeField( + null=True, + blank=True, + help_text=_("Products included in this condition will only be " + "available after this time."), + ) + end_time = models.DateTimeField( + null=True, + blank=True, + help_text=_("Products included in this condition will only be " + "available before this time."), + ) + limit = models.PositiveIntegerField( + null=True, + blank=True, + help_text=_("The number of items under this grouping that can be " + "purchased."), + ) + + +@python_2_unicode_compatible +class ProductFlag(EnablingConditionBase): + ''' The condition is met because a specific product is purchased. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (dependency on product)") + verbose_name_plural = _("flags (dependency on product)") + + def __str__(self): + return "Enabled by products: " + str(self.enabling_products.all()) + + enabling_products = models.ManyToManyField( + inventory.Product, + help_text=_("If one of these products are purchased, this condition " + "is met."), + ) + + +@python_2_unicode_compatible +class CategoryFlag(EnablingConditionBase): + ''' The condition is met because a product in a particular product is + purchased. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (dependency on product from category)") + verbose_name_plural = _("flags (dependency on product from category)") + + def __str__(self): + return "Enabled by product in category: " + str(self.enabling_category) + + enabling_category = models.ForeignKey( + inventory.Category, + help_text=_("If a product from this category is purchased, this " + "condition is met."), + ) + + +@python_2_unicode_compatible +class VoucherFlag(EnablingConditionBase): + ''' The condition is met because a Voucher is present. This is for e.g. + enabling sponsor tickets. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (dependency on voucher)") + verbose_name_plural = _("flags (dependency on voucher)") + + def __str__(self): + return "Enabled by voucher: %s" % self.voucher + + voucher = models.OneToOneField(inventory.Voucher) + + +# @python_2_unicode_compatible +class RoleFlag(object): + ''' The condition is met because the active user has a particular Role. + This is for e.g. enabling Team tickets. ''' + # TODO: implement RoleFlag + pass diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py new file mode 100644 index 00000000..e4e27feb --- /dev/null +++ b/registrasion/models/inventory.py @@ -0,0 +1,172 @@ +import datetime + +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + + +# Inventory Models + +@python_2_unicode_compatible +class Category(models.Model): + ''' Registration product categories, used as logical groupings for Products + in registration forms. + + Attributes: + name (str): The display name for the category. + + description (str): Some explanatory text for the category. This is + displayed alongside the forms where your attendees choose their + items. + + required (bool): Requires a user to select an item from this category + during initial registration. You can use this, e.g., for making + sure that the user has a ticket before they select whether they + want a t-shirt. + + render_type (int): This is used to determine what sort of form the + attendee will be presented with when choosing Products from this + category. These may be either of the following: + + ``RENDER_TYPE_RADIO`` presents the Products in the Category as a + list of radio buttons. At most one item can be chosen at a time. + This works well when setting limit_per_user to 1. + + ``RENDER_TYPE_QUANTITY`` shows each Product next to an input field, + where the user can specify a quantity of each Product type. This is + useful for additional extras, like Dinner Tickets. + + limit_per_user (Optional[int]): This restricts the number of items + from this Category that each attendee may claim. This extends + across multiple Invoices. + + display_order (int): An ascending order for displaying the Categories + available. By convention, your Category for ticket types should + have the lowest display order. + ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("inventory - category") + verbose_name_plural = _("inventory - categories") + ordering = ("order", ) + + 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"), + ) + limit_per_user = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit per user"), + help_text=_("The total number of items from this category one " + "attendee may purchase."), + ) + required = models.BooleanField( + blank=True, + help_text=_("If enabled, a user must select an " + "item from this category."), + ) + order = models.PositiveIntegerField( + verbose_name=("Display order"), + db_index=True, + ) + render_type = models.IntegerField( + choices=CATEGORY_RENDER_TYPES, + verbose_name=_("Render type"), + help_text=_("The registration form will render this category in this " + "style."), + ) + + +@python_2_unicode_compatible +class Product(models.Model): + ''' Registration products ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("inventory - product") + ordering = ("category__order", "order") + + def __str__(self): + return "%s - %s" % (self.category.name, self.name) + + name = models.CharField( + max_length=65, + verbose_name=_("Name"), + ) + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + null=True, + blank=True, + ) + 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( + null=True, + blank=True, + verbose_name=_("Limit per user"), + ) + reservation_duration = models.DurationField( + default=datetime.timedelta(hours=1), + verbose_name=_("Reservation duration"), + help_text=_("The length of time this product will be reserved before " + "it is released for someone else to purchase."), + ) + order = models.PositiveIntegerField( + verbose_name=("Display order"), + db_index=True, + ) + + +@python_2_unicode_compatible +class Voucher(models.Model): + ''' Registration vouchers ''' + + class Meta: + app_label = "registrasion" + + # 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 + + @classmethod + def normalise_code(cls, code): + return code.upper() + + def save(self, *a, **k): + ''' Normalise the voucher code to be uppercase ''' + self.code = self.normalise_code(self.code) + super(Voucher, self).save(*a, **k) + + 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")) diff --git a/registrasion/models/people.py b/registrasion/models/people.py new file mode 100644 index 00000000..bdd682ed --- /dev/null +++ b/registrasion/models/people.py @@ -0,0 +1,79 @@ +from registrasion import util + +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from model_utils.managers import InheritanceManager + + +# User models + +@python_2_unicode_compatible +class Attendee(models.Model): + ''' Miscellaneous user-related data. ''' + + class Meta: + app_label = "registrasion" + + def __str__(self): + return "%s" % self.user + + @staticmethod + def get_instance(user): + ''' Returns the instance of attendee for the given user, or creates + a new one. ''' + try: + return Attendee.objects.get(user=user) + except ObjectDoesNotExist: + return Attendee.objects.create(user=user) + + def save(self, *a, **k): + while not self.access_code: + access_code = util.generate_access_code() + if Attendee.objects.filter(access_code=access_code).count() == 0: + self.access_code = access_code + return super(Attendee, self).save(*a, **k) + + user = models.OneToOneField(User, on_delete=models.CASCADE) + # Badge/profile is linked + access_code = models.CharField( + max_length=6, + unique=True, + db_index=True, + ) + completed_registration = models.BooleanField(default=False) + guided_categories_complete = models.ManyToManyField("category") + + +class AttendeeProfileBase(models.Model): + ''' Information for an attendee's badge and related preferences. + Subclass this in your Django site to ask for attendee information in your + registration progess. + ''' + + class Meta: + app_label = "registrasion" + + objects = InheritanceManager() + + @classmethod + def name_field(cls): + ''' This is used to pre-fill the attendee's name from the + speaker profile. If it's None, that functionality is disabled. ''' + return None + + def invoice_recipient(self): + ''' Returns a representation of this attendee profile for the purpose + of rendering to an invoice. Override in subclasses. ''' + + # Manual dispatch to subclass. Fleh. + slf = AttendeeProfileBase.objects.get_subclass(id=self.id) + # Actually compare the functions. + if type(slf).invoice_recipient != type(self).invoice_recipient: + return type(slf).invoice_recipient(slf) + + # Return a default + return slf.attendee.user.username + + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 07ea7c14..b3d3cba3 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -1,4 +1,5 @@ -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from registrasion.controllers.category import CategoryController from collections import namedtuple @@ -19,7 +20,7 @@ def available_categories(context): @register.assignment_tag(takes_context=True) def available_credit(context): ''' Returns the amount of unclaimed credit available for this user. ''' - notes = rego.CreditNote.unclaimed().filter( + notes = commerce.CreditNote.unclaimed().filter( invoice__user=context.request.user, ) ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0 @@ -29,7 +30,7 @@ def available_credit(context): @register.assignment_tag(takes_context=True) def invoices(context): ''' Returns all of the invoices that this user has. ''' - return rego.Invoice.objects.filter(cart__user=context.request.user) + return commerce.Invoice.objects.filter(cart__user=context.request.user) @register.assignment_tag(takes_context=True) @@ -37,7 +38,7 @@ def items_pending(context): ''' Returns all of the items that this user has in their current cart, and is awaiting payment. ''' - all_items = rego.ProductItem.objects.filter( + all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, cart__active=True, ).select_related( @@ -55,7 +56,7 @@ def items_purchased(context, category=None): ''' Returns all of the items that this user has purchased, optionally from the given category. ''' - all_items = rego.ProductItem.objects.filter( + all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, cart__active=False, cart__released=False, @@ -65,7 +66,7 @@ def items_purchased(context, category=None): all_items = all_items.filter(product__category=category) pq = all_items.values("product").annotate(quantity=Sum("quantity")).all() - products = rego.Product.objects.all() + products = inventory.Product.objects.all() out = [] for item in pq: prod = products.get(pk=item["product"]) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index fe41f316..ac676c03 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -1,7 +1,7 @@ from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController -from registrasion import models as rego +from registrasion.models import commerce from django.core.exceptions import ObjectDoesNotExist @@ -19,7 +19,7 @@ class TestingCartController(CartController): ValidationError if constraints are violated.''' try: - product_item = rego.ProductItem.objects.get( + product_item = commerce.ProductItem.objects.get( cart=self.cart, product=product) old_quantity = product_item.quantity @@ -41,7 +41,7 @@ class TestingInvoiceController(InvoiceController): self.validate_allowed_to_pay() ''' Adds a payment ''' - rego.ManualPayment.objects.create( + commerce.ManualPayment.objects.create( invoice=self.invoice, reference=reference, amount=amount, @@ -53,7 +53,7 @@ class TestingInvoiceController(InvoiceController): class TestingCreditNoteController(CreditNoteController): def refund(self): - rego.CreditNoteRefund.objects.create( + commerce.CreditNoteRefund.objects.create( parent=self.credit_note, reference="Whoops." ) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 066bf377..0f5565e5 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -7,7 +7,10 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.test import TestCase -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory +from registrasion.models import people from registrasion.controllers.product import ProductController from controller_helpers import TestingCartController @@ -36,24 +39,28 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', password='top_secret') - attendee1 = rego.Attendee.get_instance(cls.USER_1) + attendee1 = people.Attendee.get_instance(cls.USER_1) attendee1.save() - profile1 = rego.AttendeeProfileBase.objects.create(attendee=attendee1) + profile1 = people.AttendeeProfileBase.objects.create( + attendee=attendee1, + ) profile1.save() - attendee2 = rego.Attendee.get_instance(cls.USER_2) + attendee2 = people.Attendee.get_instance(cls.USER_2) attendee2.save() - profile2 = rego.AttendeeProfileBase.objects.create(attendee=attendee2) + profile2 = people.AttendeeProfileBase.objects.create( + attendee=attendee2, + ) profile2.save() cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] for i in xrange(2): - cat = rego.Category.objects.create( + cat = inventory.Category.objects.create( name="Category " + str(i + 1), description="This is a test category", order=i, - render_type=rego.Category.RENDER_TYPE_RADIO, + render_type=inventory.Category.RENDER_TYPE_RADIO, required=False, ) cat.save() @@ -64,7 +71,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.products = [] for i in xrange(4): - prod = rego.Product.objects.create( + prod = inventory.Product.objects.create( name="Product " + str(i + 1), description="This is a test product.", category=cls.categories[i / 2], # 2 products per category @@ -95,9 +102,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): - limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( + limit_ceiling = conditions.TimeOrStockLimitFlag.objects.create( description=name, - condition=rego.FlagBase.DISABLE_IF_FALSE, + condition=conditions.FlagBase.DISABLE_IF_FALSE, limit=limit, start_time=start_time, end_time=end_time @@ -109,9 +116,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_category_ceiling( cls, name, limit=None, start_time=None, end_time=None): - limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( + limit_ceiling = conditions.TimeOrStockLimitFlag.objects.create( description=name, - condition=rego.FlagBase.DISABLE_IF_FALSE, + condition=conditions.FlagBase.DISABLE_IF_FALSE, limit=limit, start_time=start_time, end_time=end_time @@ -124,14 +131,14 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): def make_discount_ceiling( cls, name, limit=None, start_time=None, end_time=None, percentage=100): - limit_ceiling = rego.TimeOrStockLimitDiscount.objects.create( + limit_ceiling = conditions.TimeOrStockLimitDiscount.objects.create( description=name, start_time=start_time, end_time=end_time, limit=limit, ) limit_ceiling.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=limit_ceiling, product=cls.PROD_1, percentage=percentage, @@ -140,7 +147,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def new_voucher(self, code="VOUCHER", limit=1): - voucher = rego.Voucher.objects.create( + voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code=code, limit=limit, @@ -176,7 +183,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) # Count of products for a given user should be collapsed. - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( cart=current_cart.cart, product=self.PROD_1) self.assertEqual(1, len(items)) @@ -187,7 +194,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) def get_item(): - return rego.ProductItem.objects.get( + return commerce.ProductItem.objects.get( cart=current_cart.cart, product=self.PROD_1) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index c5664481..d3841ca8 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase -from registrasion import models as rego +from registrasion.models import conditions UTC = pytz.timezone('UTC') @@ -155,12 +155,12 @@ class CeilingsTestCases(RegistrationCartTestCase): self.make_discount_ceiling("Limit ceiling", limit=1, percentage=50) voucher = self.new_voucher(code="VOUCHER") - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=100, diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index e84ca283..3229a381 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -2,7 +2,7 @@ import pytz from decimal import Decimal -from registrasion import models as rego +from registrasion.models import conditions from registrasion.controllers import discount from controller_helpers import TestingCartController @@ -19,13 +19,13 @@ class DiscountTestCase(RegistrationCartTestCase): amount=Decimal(100), quantity=2, ): - discount = rego.IncludedProductDiscount.objects.create( + discount = conditions.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( + conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_2, percentage=amount, @@ -39,13 +39,13 @@ class DiscountTestCase(RegistrationCartTestCase): amount=Decimal(100), quantity=2, ): - discount = rego.IncludedProductDiscount.objects.create( + discount = conditions.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( + conditions.DiscountForCategory.objects.create( discount=discount, category=cls.CAT_2, percentage=amount, @@ -59,20 +59,20 @@ class DiscountTestCase(RegistrationCartTestCase): amount=Decimal(100), quantity=2, ): - discount = rego.IncludedProductDiscount.objects.create( + discount = conditions.IncludedProductDiscount.objects.create( description="PROD_1 includes PROD_3 and PROD_4 " + str(amount) + "%", ) discount.save() discount.enabling_products.add(cls.PROD_1) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_3, percentage=amount, quantity=quantity, ).save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_4, percentage=amount, diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index e1e1c166..1c03c6c8 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -2,7 +2,8 @@ import pytz from django.core.exceptions import ValidationError -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions from registrasion.controllers.category import CategoryController from controller_helpers import TestingCartController from registrasion.controllers.product import ProductController @@ -15,10 +16,10 @@ UTC = pytz.timezone('UTC') class FlagTestCases(RegistrationCartTestCase): @classmethod - def add_product_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): + def add_product_flag(cls, condition=conditions.FlagBase.ENABLE_IF_TRUE): ''' Adds a product flag condition: adding PROD_1 to a cart is predicated on adding PROD_2 beforehand. ''' - flag = rego.ProductFlag.objects.create( + flag = conditions.ProductFlag.objects.create( description="Product condition", condition=condition, ) @@ -28,10 +29,13 @@ class FlagTestCases(RegistrationCartTestCase): flag.save() @classmethod - def add_product_flag_on_category(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): + def add_product_flag_on_category( + cls, + condition=conditions.FlagBase.ENABLE_IF_TRUE, + ): ''' Adds a product flag condition that operates on a category: adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' - flag = rego.ProductFlag.objects.create( + flag = conditions.ProductFlag.objects.create( description="Product condition", condition=condition, ) @@ -40,10 +44,10 @@ class FlagTestCases(RegistrationCartTestCase): flag.enabling_products.add(cls.PROD_3) flag.save() - def add_category_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): + def add_category_flag(cls, condition=conditions.FlagBase.ENABLE_IF_TRUE): ''' Adds a category flag condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' - flag = rego.CategoryFlag.objects.create( + flag = conditions.CategoryFlag.objects.create( description="Category condition", condition=condition, enabling_category=cls.CAT_2, @@ -131,8 +135,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_1, 1) def test_multiple_dif_conditions(self): - self.add_product_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) - self.add_category_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) + self.add_product_flag(condition=conditions.FlagBase.DISABLE_IF_FALSE) + self.add_category_flag(condition=conditions.FlagBase.DISABLE_IF_FALSE) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -145,8 +149,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_1.add_to_cart(self.PROD_1, 1) def test_eit_and_dif_conditions_work_together(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) - self.add_category_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) + self.add_category_flag(condition=conditions.FlagBase.DISABLE_IF_FALSE) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -200,7 +204,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_4 in prods) def test_available_products_on_category_works_when_condition_not_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) prods = ProductController.available_products( self.USER_1, @@ -211,7 +215,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_category_works_when_condition_is_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -225,7 +229,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_not_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) prods = ProductController.available_products( self.USER_1, @@ -236,7 +240,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_is_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -250,7 +254,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_category_flag_fails_if_cart_refunded(self): - self.add_category_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_category_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) @@ -268,7 +272,7 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_product_flag_fails_if_cart_refunded(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -286,7 +290,9 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_available_categories(self): - self.add_product_flag_on_category(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag_on_category( + condition=conditions.FlagBase.ENABLE_IF_TRUE, + ) cart_1 = TestingCartController.for_user(self.USER_1) @@ -307,7 +313,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.CAT_2 in cats) def test_validate_cart_when_flags_become_unmet(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -332,7 +338,7 @@ class FlagTestCases(RegistrationCartTestCase): cart.validate_cart() # Should keep PROD_2 in the cart - items = rego.ProductItem.objects.filter(cart=cart.cart) + items = commerce.ProductItem.objects.filter(cart=cart.cart) self.assertFalse([i for i in items if i.product == self.PROD_1]) def test_fix_simple_errors_does_not_remove_limited_items(self): @@ -348,5 +354,5 @@ class FlagTestCases(RegistrationCartTestCase): # Should keep PROD_2 in the cart # and also PROD_1, which is now exhausted for user. - items = rego.ProductItem.objects.filter(cart=cart.cart) + items = commerce.ProductItem.objects.filter(cart=cart.cart) self.assertTrue([i for i in items if i.product == self.PROD_1]) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index f9ea59b6..3a655bb1 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -4,7 +4,9 @@ import pytz from decimal import Decimal from django.core.exceptions import ValidationError -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory from controller_helpers import TestingCartController from controller_helpers import TestingCreditNoteController from controller_helpers import TestingInvoiceController @@ -23,7 +25,9 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have a single line item - line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) + line_items = commerce.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) @@ -34,13 +38,15 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) # The old invoice should automatically be voided - invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id) - invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id) + invoice_1_new = commerce.Invoice.objects.get(pk=invoice_1.invoice.id) + invoice_2_new = commerce.Invoice.objects.get(pk=invoice_2.invoice.id) self.assertTrue(invoice_1_new.is_void) self.assertFalse(invoice_2_new.is_void) # Invoice should have two line items - line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) + line_items = commerce.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( @@ -79,16 +85,16 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertNotEqual(current_cart.cart, new_cart.cart) def test_invoice_includes_discounts(self): - voucher = rego.Voucher.objects.create( + voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code="VOUCHER", limit=1 ) - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(50), @@ -103,7 +109,9 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have two line items - line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) + line_items = commerce.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( @@ -111,16 +119,16 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.invoice.value) def test_zero_value_invoice_is_automatically_paid(self): - voucher = rego.Voucher.objects.create( + voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code="VOUCHER", limit=1 ) - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(100), @@ -239,7 +247,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_paid) # There should be a credit note generated out of the invoice. - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(1, credit_notes.count()) self.assertEqual(to_pay - invoice.invoice.value, credit_notes[0].value) @@ -257,7 +267,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_paid) # There should be no credit notes - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(0, credit_notes.count()) def test_refund_partially_paid_invoice_generates_correct_credit_note(self): @@ -276,7 +288,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_void) # There should be a credit note generated out of the invoice. - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(1, credit_notes.count()) self.assertEqual(to_pay, credit_notes[0].value) @@ -297,7 +311,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_refunded) # There should be a credit note generated out of the invoice. - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(1, credit_notes.count()) self.assertEqual(to_pay, credit_notes[0].value) @@ -314,11 +330,11 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) # That credit note should be in the unclaimed pile - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) # Create a new (identical) cart with invoice cart = TestingCartController.for_user(self.USER_1) @@ -330,7 +346,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice2.invoice.is_paid) # That invoice should not show up as unclaimed any more - self.assertEquals(0, rego.CreditNote.unclaimed().count()) + self.assertEquals(0, commerce.CreditNote.unclaimed().count()) def test_apply_credit_note_generates_new_credit_note_if_overpaying(self): cart = TestingCartController.for_user(self.USER_1) @@ -345,10 +361,10 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) # Create a new cart (of half value of inv 1) and get invoice cart = TestingCartController.for_user(self.USER_1) @@ -361,9 +377,11 @@ class InvoiceTestCase(RegistrationCartTestCase): # We generated a new credit note, and spent the old one, # unclaimed should still be 1. - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note2 = rego.CreditNote.objects.get(invoice=invoice2.invoice) + credit_note2 = commerce.CreditNote.objects.get( + invoice=invoice2.invoice, + ) # The new credit note should be the residual of the cost of cart 1 # minus the cost of cart 2. @@ -385,7 +403,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) # Create a new cart with invoice, pay it @@ -426,15 +444,15 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) cn.refund() # Refunding a credit note should mark it as claimed - self.assertEquals(0, rego.CreditNote.unclaimed().count()) + self.assertEquals(0, commerce.CreditNote.unclaimed().count()) # Create a new cart with invoice cart = TestingCartController.for_user(self.USER_1) @@ -458,9 +476,9 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) @@ -471,7 +489,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) cn.apply_to_invoice(invoice_2.invoice) - self.assertEquals(0, rego.CreditNote.unclaimed().count()) + self.assertEquals(0, commerce.CreditNote.unclaimed().count()) # Cannot refund this credit note as it is already applied. with self.assertRaises(ValidationError): diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 5f1e07f0..46b2270a 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -5,7 +5,8 @@ from decimal import Decimal from django.core.exceptions import ValidationError from django.db import IntegrityError -from registrasion import models as rego +from registrasion.models import conditions +from registrasion.models import inventory from controller_helpers import TestingCartController from controller_helpers import TestingInvoiceController @@ -32,14 +33,14 @@ class VoucherTestCases(RegistrationCartTestCase): # After the reservation duration # user 2 should be able to apply voucher - self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) + self.add_timedelta(inventory.Voucher.RESERVATION_DURATION * 2) cart_2.apply_voucher(voucher.code) cart_2.next_cart() # After the reservation duration, even though the voucher has applied, # it exceeds the number of vouchers available. - self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) + self.add_timedelta(inventory.Voucher.RESERVATION_DURATION * 2) with self.assertRaises(ValidationError): cart_1.validate_cart() @@ -58,10 +59,10 @@ class VoucherTestCases(RegistrationCartTestCase): def test_voucher_enables_item(self): voucher = self.new_voucher() - flag = rego.VoucherFlag.objects.create( + flag = conditions.VoucherFlag.objects.create( description="Voucher condition", voucher=voucher, - condition=rego.FlagBase.ENABLE_IF_TRUE, + condition=conditions.FlagBase.ENABLE_IF_TRUE, ) flag.save() flag.products.add(self.PROD_1) @@ -79,12 +80,12 @@ class VoucherTestCases(RegistrationCartTestCase): def test_voucher_enables_discount(self): voucher = self.new_voucher() - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(100), diff --git a/registrasion/views.py b/registrasion/views.py index b2ca8eca..ea4acc8b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,7 +1,9 @@ import sys from registrasion import forms -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory +from registrasion.models import people from registrasion.controllers import discount from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController @@ -60,7 +62,7 @@ def guided_registration(request, page_id=0): sections = [] - attendee = rego.Attendee.get_instance(request.user) + attendee = people.Attendee.get_instance(request.user) if attendee.completed_registration: return render( @@ -111,7 +113,7 @@ def guided_registration(request, page_id=0): starting = attendee.guided_categories_complete.count() == 0 # Get the next category - cats = rego.Category.objects + cats = inventory.Category.objects if SESSION_KEY in request.session: _cats = request.session[SESSION_KEY] cats = cats.filter(id__in=_cats) @@ -134,7 +136,7 @@ def guided_registration(request, page_id=0): current_step = 3 title = "Additional items" - all_products = rego.Product.objects.filter( + all_products = inventory.Product.objects.filter( category__in=cats, ).select_related("category") @@ -217,11 +219,13 @@ def edit_profile(request): def handle_profile(request, prefix): ''' Returns a profile form instance, and a boolean which is true if the form was handled. ''' - attendee = rego.Attendee.get_instance(request.user) + attendee = people.Attendee.get_instance(request.user) try: profile = attendee.attendeeprofilebase - profile = rego.AttendeeProfileBase.objects.get_subclass(pk=profile.id) + profile = people.AttendeeProfileBase.objects.get_subclass( + pk=profile.id, + ) except ObjectDoesNotExist: profile = None @@ -270,7 +274,7 @@ def product_category(request, category_id): voucher_form, voucher_handled = v category_id = int(category_id) # Routing is [0-9]+ - category = rego.Category.objects.get(pk=category_id) + category = inventory.Category.objects.get(pk=category_id) products = ProductController.available_products( request.user, @@ -316,7 +320,7 @@ def handle_products(request, category, products, prefix): ProductsForm = forms.ProductsForm(category, products) # Create initial data for each of products in category - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( product__in=products, cart=current_cart.cart, ).select_related("product") @@ -344,8 +348,8 @@ def handle_products(request, category, products, prefix): # If category is required, the user must have at least one # in an active+valid cart if category.required: - carts = rego.Cart.objects.filter(user=request.user) - items = rego.ProductItem.objects.filter( + carts = commerce.Cart.objects.filter(user=request.user) + items = commerce.ProductItem.objects.filter( product__category=category, cart=carts, ) @@ -366,7 +370,7 @@ def set_quantities_from_products_form(products_form, current_cart): quantities = list(products_form.product_quantities()) pks = [i[0] for i in quantities] - products = rego.Product.objects.filter( + products = inventory.Product.objects.filter( id__in=pks, ).select_related("category") @@ -384,7 +388,7 @@ def set_quantities_from_products_form(products_form, current_cart): product, message = ve_field.message if product in field_names: field = field_names[product] - elif isinstance(product, rego.Product): + elif isinstance(product, inventory.Product): continue else: field = None @@ -402,7 +406,7 @@ def handle_voucher(request, prefix): voucher_form.cleaned_data["voucher"].strip()): voucher = voucher_form.cleaned_data["voucher"] - voucher = rego.Voucher.normalise_code(voucher) + voucher = inventory.Voucher.normalise_code(voucher) if len(current_cart.cart.vouchers.filter(code=voucher)) > 0: # This voucher has already been applied to this cart. @@ -457,9 +461,9 @@ def invoice_access(request, access_code): ''' Redirects to the first unpaid invoice for the attendee that matches the given access code, if any. ''' - invoices = rego.Invoice.objects.filter( + invoices = commerce.Invoice.objects.filter( user__attendee__access_code=access_code, - status=rego.Invoice.STATUS_UNPAID, + status=commerce.Invoice.STATUS_UNPAID, ).order_by("issue_time") if not invoices: @@ -478,7 +482,7 @@ def invoice(request, invoice_id, access_code=None): ''' invoice_id = int(invoice_id) - inv = rego.Invoice.objects.get(pk=invoice_id) + inv = commerce.Invoice.objects.get(pk=invoice_id) current_invoice = InvoiceController(inv) @@ -505,7 +509,7 @@ def manual_payment(request, invoice_id): raise Http404() invoice_id = int(invoice_id) - inv = get_object_or_404(rego.Invoice, pk=invoice_id) + inv = get_object_or_404(commerce.Invoice, pk=invoice_id) current_invoice = InvoiceController(inv) form = forms.ManualPaymentForm( @@ -536,7 +540,7 @@ def refund(request, invoice_id): raise Http404() invoice_id = int(invoice_id) - inv = get_object_or_404(rego.Invoice, pk=invoice_id) + inv = get_object_or_404(commerce.Invoice, pk=invoice_id) current_invoice = InvoiceController(inv) try: @@ -557,7 +561,7 @@ def credit_note(request, note_id, access_code=None): raise Http404() note_id = int(note_id) - note = rego.CreditNote.objects.get(pk=note_id) + note = commerce.CreditNote.objects.get(pk=note_id) current_note = CreditNoteController(note) @@ -574,10 +578,11 @@ def credit_note(request, note_id, access_code=None): if request.POST and apply_form.is_valid(): inv_id = apply_form.cleaned_data["invoice"] - invoice = rego.Invoice.objects.get(pk=inv_id) + invoice = commerce.Invoice.objects.get(pk=inv_id) current_note.apply_to_invoice(invoice) - messages.success(request, - "Applied credit note %d to invoice." % note_id + messages.success( + request, + "Applied credit note %d to invoice." % note_id, ) return redirect("invoice", invoice.id) @@ -585,7 +590,8 @@ def credit_note(request, note_id, access_code=None): refund_form.instance.entered_by = request.user refund_form.instance.parent = note refund_form.save() - messages.success(request, + messages.success( + request, "Applied manual refund to credit note." ) return redirect("invoice", invoice.id)