Changeset - 587e6e20b284
[Not reviewed]
0 2 0
Christopher Neugebauer - 8 years ago 2016-04-29 01:08:45
chrisjrn@gmail.com
Adds an operations_batch context manager that allows batches of modifying operations to be nested. Closes #44.
2 files changed with 75 insertions and 18 deletions:
0 comments (0 inline, 0 general)
registrasion/controllers/cart.py
Show inline comments
 
import collections
 
import contextlib
 
import datetime
 
import functools
 
import itertools
 

	
 
from django.core.exceptions import ObjectDoesNotExist
 
from django.core.exceptions import ValidationError
 
from django.db import transaction
 
from django.db.models import Max
 
from django.utils import timezone
 

	
 
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
 
from .discount import DiscountController
 
from .flag import FlagController
 
from .product import ProductController
 

	
 

	
 
def _modifies_cart(func):
 
    ''' Decorator that makes the wrapped function raise ValidationError
 
    if we're doing something that could modify the cart. '''
 
    if we're doing something that could modify the cart.
 

	
 
    It also wraps the execution of this function in a database transaction,
 
    and marks the boundaries of a cart operations batch.
 
    '''
 

	
 
    @functools.wraps(func)
 
    def inner(self, *a, **k):
 
        self._fail_if_cart_is_not_active()
 
        return func(self, *a, **k)
 
        with transaction.atomic():
 
            with CartController.operations_batch(self.cart.user) as mark:
 
                mark.mark = True  # Marker that we've modified the cart
 
                return func(self, *a, **k)
 

	
 
    return inner
 

	
 

	
 
class CartController(object):
 

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

	
 
    @classmethod
 
    def for_user(cls, user):
 
        ''' Returns the user's current cart, or creates a new cart
...
 
@@ -47,67 +55,116 @@ class CartController(object):
 
            existing = commerce.Cart.objects.get(
 
                user=user,
 
                status=commerce.Cart.STATUS_ACTIVE,
 
            )
 
        except ObjectDoesNotExist:
 
            existing = commerce.Cart.objects.create(
 
                user=user,
 
                time_last_updated=timezone.now(),
 
                reservation_duration=datetime.timedelta(),
 
            )
 
        return cls(existing)
 

	
 

	
 
    # Marks the carts that are currently in batches
 
    _BATCH_COUNT = collections.defaultdict(int)
 
    _MODIFIED_CARTS = set()
 

	
 
    class _ModificationMarker(object):
 
        pass
 

	
 
    @classmethod
 
    @contextlib.contextmanager
 
    def operations_batch(cls, user):
 
        ''' Marks the boundary for a batch of operations on a user's cart.
 

	
 
        These markers can be nested. Only on exiting the outermost marker will
 
        a batch be ended.
 

	
 
        When a batch is ended, discounts are recalculated, and the cart's
 
        revision is increased.
 
        '''
 

	
 
        ctrl = cls.for_user(user)
 
        _id = ctrl.cart.id
 

	
 
        cls._BATCH_COUNT[_id] += 1
 
        try:
 
            success = False
 

	
 
            marker = cls._ModificationMarker()
 
            yield marker
 

	
 
            if hasattr(marker, "mark"):
 
                cls._MODIFIED_CARTS.add(_id)
 

	
 
            success = True
 
        finally:
 

	
 
            cls._BATCH_COUNT[_id] -= 1
 

	
 
            # Only end on the outermost batch marker, and only if
 
            # it excited cleanly, and a modification occurred
 
            modified = _id in cls._MODIFIED_CARTS
 
            if modified and cls._BATCH_COUNT[_id] == 0 and success:
 
                ctrl._end_batch()
 
                cls._MODIFIED_CARTS.remove(_id)
 

	
 
    def _fail_if_cart_is_not_active(self):
 
        self.cart.refresh_from_db()
 
        if self.cart.status != commerce.Cart.STATUS_ACTIVE:
 
            raise ValidationError("You can only amend active carts.")
 

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

	
 
        reservations = [datetime.timedelta()]
 

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

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

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

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

	
 
    @_modifies_cart
 
    def end_batch(self):
 
    def _end_batch(self):
 
        ''' Performs operations that occur occur at the end of a batch of
 
        product changes/voucher applications etc.
 
        THIS SHOULD BE PRIVATE
 

	
 
        You need to call this after you've finished modifying the user's cart.
 
        This is normally done by wrapping a block of code using
 
        ``operations_batch``.
 

	
 
        '''
 

	
 
        self.recalculate_discounts()
 

	
 
        self.extend_reservation()
 
        self.cart.refresh_from_db()
 

	
 
        self._recalculate_discounts()
 

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

	
 
    @_modifies_cart
 
    @transaction.atomic
 
    def set_quantities(self, product_quantities):
 
        ''' Sets the quantities on each of the products on each of the
 
        products specified. Raises an exception (ValidationError) if a limit
 
        is violated. `product_quantities` is an iterable of (product, quantity)
 
        pairs. '''
 

	
 
        items_in_cart = commerce.ProductItem.objects.filter(cart=self.cart)
 
        items_in_cart = items_in_cart.select_related(
 
            "product",
 
            "product__category",
 
        )
 

	
...
 
@@ -131,26 +188,24 @@ class CartController(object):
 
                )
 
                product_item.quantity = quantity
 
                product_item.save()
 
            except ObjectDoesNotExist:
 
                commerce.ProductItem.objects.create(
 
                    cart=self.cart,
 
                    product=product,
 
                    quantity=quantity,
 
                )
 

	
 
        items_in_cart.filter(quantity=0).delete()
 

	
 
        self.end_batch()
 

	
 
    def _test_limits(self, product_quantities):
 
        ''' Tests that the quantity changes we intend to make do not violate
 
        the limits and flag conditions imposed on the products. '''
 

	
 
        errors = []
 

	
 
        # Test each product limit here
 
        for product, quantity in product_quantities:
 
            if quantity < 0:
 
                errors.append((product, "Value must be zero or greater."))
 

	
 
            prod = ProductController(product)
...
 
@@ -204,25 +259,24 @@ class CartController(object):
 

	
 
        # Try and find the voucher
 
        voucher = inventory.Voucher.objects.get(code=voucher_code.upper())
 

	
 
        # Re-applying vouchers should be idempotent
 
        if voucher in self.cart.vouchers.all():
 
            return
 

	
 
        self._test_voucher(voucher)
 

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

	
 
    def _test_voucher(self, voucher):
 
        ''' Tests whether this voucher is allowed to be applied to this cart.
 
        Raises ValidationError if not. '''
 

	
 
        # Is voucher exhausted?
 
        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)
 
        carts_with_voucher = carts_with_voucher.exclude(pk=self.cart.id)
 
        if carts_with_voucher.count() >= voucher.limit:
...
 
@@ -322,25 +376,24 @@ class CartController(object):
 
                pk=discount.pk)
 
            cond = ConditionController.for_condition(real_discount)
 

	
 
            if not cond.is_met(user):
 
                errors.append(
 
                    ValidationError("Discounts are no longer available")
 
                )
 

	
 
        if errors:
 
            raise ValidationError(errors)
 

	
 
    @_modifies_cart
 
    @transaction.atomic
 
    def fix_simple_errors(self):
 
        ''' This attempts to fix the easy errors raised by ValidationError.
 
        This includes removing items from the cart that are no longer
 
        available, recalculating all of the discounts, and removing voucher
 
        codes that are no longer available. '''
 

	
 
        # Fix vouchers first (this affects available discounts)
 
        to_remove = []
 
        for voucher in self.cart.vouchers.all():
 
            try:
 
                self._test_voucher(voucher)
 
            except ValidationError:
...
 
@@ -354,29 +407,27 @@ class CartController(object):
 
        items = items.select_related("product")
 
        products = set(i.product for i in items)
 
        available = set(ProductController.available_products(
 
            self.cart.user,
 
            products=products,
 
        ))
 

	
 
        not_available = products - available
 
        zeros = [(product, 0) for product in not_available]
 

	
 
        self.set_quantities(zeros)
 

	
 
    @_modifies_cart
 
    @transaction.atomic
 
    def recalculate_discounts(self):
 
        ''' Calculates all of the discounts available for this product.
 
        '''
 
    def _recalculate_discounts(self):
 
        ''' Calculates all of the discounts available for this product.'''
 

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

	
 
        product_items = self.cart.productitem_set.all().select_related(
 
            "product", "product__category", "product__price"
 
        )
 

	
 
        products = [i.product for i in product_items]
 
        discounts = DiscountController.available_discounts(
 
            self.cart.user,
 
            [],
registrasion/controllers/invoice.py
Show inline comments
...
 
@@ -20,24 +20,25 @@ class InvoiceController(ForId, object):
 

	
 
    def __init__(self, invoice):
 
        self.invoice = invoice
 
        self.update_status()
 
        self.update_validity()  # Make sure this invoice is up-to-date
 

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

	
 
        cart.refresh_from_db()
 
        try:
 
            invoice = commerce.Invoice.objects.exclude(
 
                status=commerce.Invoice.STATUS_VOID,
 
            ).get(
 
                cart=cart,
 
                cart_revision=cart.revision,
 
            )
 
        except ObjectDoesNotExist:
 
            cart_controller = CartController(cart)
 
            cart_controller.validate_cart()  # Raises ValidationError on fail.
 

	
 
            cls.void_all_invoices(cart)
...
 
@@ -65,24 +66,26 @@ class InvoiceController(ForId, object):
 
            )
 
        if condition.percentage is not None:
 
            value = item.product.price * (condition.percentage / 100)
 
        else:
 
            value = condition.price
 
        return value
 

	
 
    @classmethod
 
    @transaction.atomic
 
    def _generate(cls, cart):
 
        ''' Generates an invoice for the given cart. '''
 

	
 
        cart.refresh_from_db()
 

	
 
        issued = timezone.now()
 
        reservation_limit = cart.reservation_duration + cart.time_last_updated
 
        # Never generate a due time that is before the issue time
 
        due = max(issued, reservation_limit)
 

	
 
        # Get the invoice recipient
 
        profile = people.AttendeeProfileBase.objects.get_subclass(
 
            id=cart.user.attendee.attendeeprofilebase.id,
 
        )
 
        recipient = profile.invoice_recipient()
 
        invoice = commerce.Invoice.objects.create(
 
            user=cart.user,
...
 
@@ -242,24 +245,27 @@ class InvoiceController(ForId, object):
 
        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 = commerce.Invoice.STATUS_VOID
 
        self.invoice.save()
 

	
 
    def _invoice_matches_cart(self):
 
        ''' Returns true if there is no cart, or if the revision of this
 
        invoice matches the current revision of the cart. '''
 

	
 
        self._refresh()
 

	
 
        cart = self.invoice.cart
 
        if not cart:
 
            return True
 

	
 
        return cart.revision == self.invoice.cart_revision
 

	
 
    def update_validity(self):
 
        ''' Voids this invoice if the cart it is attached to has updated. '''
 
        if not self._invoice_matches_cart():
 
            self.void()
 

	
 
    def void(self):
0 comments (0 inline, 0 general)