Vendor registration

This commit is contained in:
Sachi King 2017-05-27 20:59:35 +10:00
commit c1abf4717d
55 changed files with 11034 additions and 0 deletions

201
vendor/registrasion/LICENSE vendored Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2016 Christopher Neugebauer <_@chrisjrn.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

3
vendor/registrasion/__init__.py vendored Normal file
View file

@ -0,0 +1,3 @@
__version__ = "0.2.0-dev"
default_app_config = "registrasion.apps.RegistrasionConfig"

230
vendor/registrasion/admin.py vendored Normal file
View file

@ -0,0 +1,230 @@
from django.contrib import admin
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_lazy as _
import nested_admin
from registrasion.models import conditions
from registrasion.models import inventory
class EffectsDisplayMixin(object):
def effects(self, obj):
return list(obj.effects())
# Inventory admin
class ProductInline(admin.TabularInline):
model = inventory.Product
@admin.register(inventory.Category)
class CategoryAdmin(admin.ModelAdmin):
model = inventory.Category
fields = ("name", "description", "required", "render_type",
"limit_per_user", "order",)
list_display = ("name", "description")
inlines = [
ProductInline,
]
@admin.register(inventory.Product)
class ProductAdmin(admin.ModelAdmin):
model = inventory.Product
list_display = ("name", "category", "description")
list_filter = ("category", )
# Discounts
class DiscountForProductInline(admin.TabularInline):
model = conditions.DiscountForProduct
verbose_name = _("Product included in discount")
verbose_name_plural = _("Products included in discount")
class DiscountForCategoryInline(admin.TabularInline):
model = conditions.DiscountForCategory
verbose_name = _("Category included in discount")
verbose_name_plural = _("Categories included in discount")
@admin.register(conditions.TimeOrStockLimitDiscount)
class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin):
list_display = (
"description",
"start_time",
"end_time",
"limit",
"effects",
)
ordering = ("start_time", "end_time", "limit")
inlines = [
DiscountForProductInline,
DiscountForCategoryInline,
]
@admin.register(conditions.IncludedProductDiscount)
class IncludedProductDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin):
def enablers(self, obj):
return list(obj.enabling_products.all())
list_display = ("description", "enablers", "effects")
inlines = [
DiscountForProductInline,
DiscountForCategoryInline,
]
@admin.register(conditions.SpeakerDiscount)
class SpeakerDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin):
fields = ("description", "is_presenter", "is_copresenter", "proposal_kind")
list_display = ("description", "is_presenter", "is_copresenter", "effects")
ordering = ("-is_presenter", "-is_copresenter")
inlines = [
DiscountForProductInline,
DiscountForCategoryInline,
]
@admin.register(conditions.GroupMemberDiscount)
class GroupMemberDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin):
fields = ("description", "group")
list_display = ("description", "effects")
inlines = [
DiscountForProductInline,
DiscountForCategoryInline,
]
# Vouchers
class VoucherDiscountInline(nested_admin.NestedStackedInline):
model = conditions.VoucherDiscount
verbose_name = _("Discount")
# TODO work out why we're allowed to add more than one?
max_num = 1
extra = 1
inlines = [
DiscountForProductInline,
DiscountForCategoryInline,
]
class VoucherFlagInline(nested_admin.NestedStackedInline):
model = conditions.VoucherFlag
verbose_name = _("Product and category enabled by voucher")
verbose_name_plural = _("Products and categories enabled by voucher")
# TODO work out why we're allowed to add more than one?
max_num = 1
extra = 1
@admin.register(inventory.Voucher)
class VoucherAdmin(nested_admin.NestedAdmin):
def effects(self, obj):
''' List the effects of the voucher in the admin. '''
out = []
try:
discount_effects = obj.voucherdiscount.effects()
except ObjectDoesNotExist:
discount_effects = None
try:
enabling_effects = obj.voucherflag.effects()
except ObjectDoesNotExist:
enabling_effects = None
if discount_effects:
out.append("Discounts: " + str(list(discount_effects)))
if enabling_effects:
out.append("Enables: " + str(list(enabling_effects)))
return "\n".join(out)
model = inventory.Voucher
list_display = ("recipient", "code", "effects")
inlines = [
VoucherDiscountInline,
VoucherFlagInline,
]
# Enabling conditions
@admin.register(conditions.ProductFlag)
class ProductFlagAdmin(
nested_admin.NestedAdmin,
EffectsDisplayMixin):
def enablers(self, obj):
return list(obj.enabling_products.all())
model = conditions.ProductFlag
fields = ("description", "enabling_products", "condition", "products",
"categories"),
list_display = ("description", "enablers", "effects")
# Enabling conditions
@admin.register(conditions.CategoryFlag)
class CategoryFlagAdmin(
nested_admin.NestedAdmin,
EffectsDisplayMixin):
model = conditions.CategoryFlag
fields = ("description", "enabling_category", "condition", "products",
"categories"),
list_display = ("description", "enabling_category", "effects")
ordering = ("enabling_category",)
@admin.register(conditions.SpeakerFlag)
class SpeakerFlagAdmin(nested_admin.NestedAdmin, EffectsDisplayMixin):
model = conditions.SpeakerFlag
fields = ("description", "is_presenter", "is_copresenter", "proposal_kind",
"products", "categories")
list_display = ("description", "is_presenter", "is_copresenter", "effects")
ordering = ("-is_presenter", "-is_copresenter")
@admin.register(conditions.GroupMemberFlag)
class GroupMemberFlagAdmin(admin.ModelAdmin, EffectsDisplayMixin):
fields = ("description", "group", "products", "categories")
list_display = ("description", "effects")
@admin.register(conditions.TimeOrStockLimitFlag)
class TimeOrStockLimitFlagAdmin(admin.ModelAdmin, EffectsDisplayMixin):
list_display = (
"description",
"start_time",
"end_time",
"limit",
"effects",
)
ordering = ("start_time", "end_time", "limit")

8
vendor/registrasion/apps.py vendored Normal file
View file

@ -0,0 +1,8 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class RegistrasionConfig(AppConfig):
name = "registrasion"
label = "registrasion"
verbose_name = "Registrasion"

View file

69
vendor/registrasion/contrib/mail.py vendored Normal file
View file

@ -0,0 +1,69 @@
import os
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.contrib.sites.models import Site
class Sender(object):
''' Class for sending e-mails under a templete prefix. '''
def __init__(self, template_prefix):
self.template_prefix = template_prefix
def send_email(self, to, kind, **kwargs):
''' Sends an e-mail to the given address.
to: The address
kind: the ID for an e-mail kind; it should point to a subdirectory of
self.template_prefix containing subject.txt and message.html, which
are django templates for the subject and HTML message respectively.
context: a context for rendering the e-mail.
'''
return __send_email__(self.template_prefix, to, kind, **kwargs)
send_email = Sender("registrasion/emails").send_email
def __send_email__(template_prefix, to, kind, **kwargs):
current_site = Site.objects.get_current()
ctx = {
"current_site": current_site,
"STATIC_URL": settings.STATIC_URL,
}
ctx.update(kwargs.get("context", {}))
subject_template = os.path.join(template_prefix, "%s/subject.txt" % kind)
message_template = os.path.join(template_prefix, "%s/message.html" % kind)
subject = "[%s] %s" % (
current_site.name,
render_to_string(subject_template, ctx).strip()
)
message_html = render_to_string(message_template, ctx)
message_plaintext = strip_tags(message_html)
from_email = settings.DEFAULT_FROM_EMAIL
try:
bcc_email = settings.ENVELOPE_BCC_LIST
except AttributeError:
bcc_email = None
email = EmailMultiAlternatives(
subject,
message_plaintext,
from_email,
to,
bcc=bcc_email,
)
email.attach_alternative(message_html, "text/html")
email.send()

View file

119
vendor/registrasion/controllers/batch.py vendored Normal file
View file

@ -0,0 +1,119 @@
import contextlib
import functools
from django.contrib.auth.models import User
class BatchController(object):
''' Batches are sets of operations where certain queries for users may be
repeated, but are also unlikely change within the boundaries of the batch.
Batches are keyed per-user. You can mark the edge of the batch with the
``batch`` context manager. If you nest calls to ``batch``, only the
outermost call will have the effect of ending the batch.
Batches store results for functions wrapped with ``memoise``. These results
for the user are flushed at the end of the batch.
If a return for a memoised function has a callable attribute called
``end_batch``, that attribute will be called at the end of the batch.
'''
_user_caches = {}
_NESTING_KEY = "nesting_count"
@classmethod
@contextlib.contextmanager
def batch(cls, user):
''' Marks the entry point for a batch for the given user. '''
cls._enter_batch_context(user)
try:
yield
finally:
# Make sure we clean up in case of errors.
cls._exit_batch_context(user)
@classmethod
def _enter_batch_context(cls, user):
if user not in cls._user_caches:
cls._user_caches[user] = cls._new_cache()
cache = cls._user_caches[user]
cache[cls._NESTING_KEY] += 1
@classmethod
def _exit_batch_context(cls, user):
cache = cls._user_caches[user]
cache[cls._NESTING_KEY] -= 1
if cache[cls._NESTING_KEY] == 0:
cls._call_end_batch_methods(user)
del cls._user_caches[user]
@classmethod
def _call_end_batch_methods(cls, user):
cache = cls._user_caches[user]
ended = set()
while True:
keys = set(cache.keys())
if ended == keys:
break
keys_to_end = keys - ended
for key in keys_to_end:
item = cache[key]
if hasattr(item, 'end_batch') and callable(item.end_batch):
item.end_batch()
ended = ended | keys_to_end
@classmethod
def memoise(cls, func):
''' Decorator that stores the result of the stored function in the
user's results cache until the batch completes. Keyword arguments are
not yet supported.
Arguments:
func (callable(*a)): The function whose results we want
to store. The positional arguments, ``a``, are used as cache
keys.
Returns:
callable(*a): The memosing version of ``func``.
'''
@functools.wraps(func)
def f(*a):
for arg in a:
if isinstance(arg, User):
user = arg
break
else:
raise ValueError("One position argument must be a User")
func_key = (func, tuple(a))
cache = cls.get_cache(user)
if func_key not in cache:
cache[func_key] = func(*a)
return cache[func_key]
return f
@classmethod
def get_cache(cls, user):
if user not in cls._user_caches:
# Return blank cache here, we'll just discard :)
return cls._new_cache()
return cls._user_caches[user]
@classmethod
def _new_cache(cls):
''' Returns a new cache dictionary. '''
cache = {}
cache[cls._NESTING_KEY] = 0
return cache

512
vendor/registrasion/controllers/cart.py vendored Normal file
View file

@ -0,0 +1,512 @@
from .batch import BatchController
from .category import CategoryController
from .discount import DiscountController
from .flag import FlagController
from .product import ProductController
import collections
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.db.models import Q
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
def _modifies_cart(func):
''' Decorator that makes the wrapped function raise ValidationError
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()
with transaction.atomic():
with BatchController.batch(self.cart.user):
# Mark the version of self in the batch cache as modified
memoised = self.for_user(self.cart.user)
memoised._modified_by_batch = True
return func(self, *a, **k)
return inner
class CartController(object):
def __init__(self, cart):
self.cart = cart
@classmethod
@BatchController.memoise
def for_user(cls, user):
''' Returns the user's current cart, or creates a new cart
if there isn't one ready yet. '''
try:
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)
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.")
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. '''
time = timezone.now()
# Calculate the residual of the _old_ reservation duration
# if it's greater than what's in the cart now, keep it.
time_elapsed_since_updated = (time - self.cart.time_last_updated)
residual = self.cart.reservation_duration - time_elapsed_since_updated
reservations = [datetime.timedelta(0), residual]
# 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 = time
self.cart.reservation_duration = max(reservations)
def end_batch(self):
''' Calls ``_end_batch`` if a modification has been performed in the
previous batch. '''
if hasattr(self, '_modified_by_batch'):
self._end_batch()
def _end_batch(self):
''' Performs operations that occur occur at the end of a batch of
product changes/voucher applications etc.
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.cart.refresh_from_db()
self._recalculate_discounts()
self._autoextend_reservation()
self.cart.revision += 1
self.cart.save()
def extend_reservation(self, timedelta):
''' Extends the reservation on this cart by the given timedelta.
This can only be done if the current state of the cart is valid (i.e
all items and discounts in the cart are still available.)
Arguments:
timedelta (timedelta): The amount of time to extend the cart by.
The resulting reservation_duration will be now() + timedelta,
unless the requested extension is *LESS* than the current
reservation deadline.
'''
self.validate_cart()
cart = self.cart
cart.refresh_from_db()
elapsed = (timezone.now() - cart.time_last_updated)
if cart.reservation_duration - elapsed > timedelta:
return
cart.time_last_updated = timezone.now()
cart.reservation_duration = timedelta
cart.save()
@_modifies_cart
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",
)
product_quantities = list(product_quantities)
# n.b need to add have the existing items first so that the new
# items override the old ones.
all_product_quantities = dict(itertools.chain(
((i.product, i.quantity) for i in items_in_cart.all()),
product_quantities,
)).items()
# Validate that the limits we're adding are OK
products = set(product for product, q in product_quantities)
try:
self._test_limits(all_product_quantities)
except CartValidationError as ve:
# Only raise errors for products that we're explicitly
# Manipulating here.
for ve_field in ve.error_list:
product, message = ve_field.message
if product in products:
raise ve
new_items = []
products = []
for product, quantity in product_quantities:
products.append(product)
if quantity == 0:
continue
item = commerce.ProductItem(
cart=self.cart,
product=product,
quantity=quantity,
)
new_items.append(item)
to_delete = (
Q(quantity=0) |
Q(product__in=products)
)
items_in_cart.filter(to_delete).delete()
commerce.ProductItem.objects.bulk_create(new_items)
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 = []
# Pre-annotate products
remainders = ProductController.user_remainders(self.cart.user)
# Test each product limit here
for product, quantity in product_quantities:
if quantity < 0:
errors.append((product, "Value must be zero or greater."))
limit = remainders[product.id]
if quantity > limit:
errors.append((
product,
"You may only have %d of product: %s" % (
limit, product,
)
))
# Collect by category
by_cat = collections.defaultdict(list)
for product, quantity in product_quantities:
by_cat[product.category].append((product, quantity))
# Pre-annotate categories
remainders = CategoryController.user_remainders(self.cart.user)
# Test each category limit here
for category in by_cat:
limit = remainders[category.id]
# Get the amount so far in the cart
to_add = sum(i[1] for i in by_cat[category])
if to_add > limit:
message_base = "You may only add %d items from category: %s"
message = message_base % (
limit, category.name,
)
for product, quantity in by_cat[category]:
errors.append((product, message))
# Test the flag conditions
errs = FlagController.test_flags(
self.cart.user,
product_quantities=product_quantities,
)
if errs:
for error in errs:
errors.append(error)
if errors:
raise CartValidationError(errors)
@_modifies_cart
def apply_voucher(self, voucher_code):
''' Applies the voucher with the given code to this cart. '''
# 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)
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:
raise ValidationError(
"Voucher %s is no longer available" % voucher.code)
# It's not valid for users to re-enter a voucher they already have
user_carts_with_voucher = carts_with_voucher.filter(
user=self.cart.user,
)
if user_carts_with_voucher.count() > 0:
raise ValidationError("You have already entered this voucher.")
def _test_vouchers(self, vouchers):
''' Tests each of the vouchers against self._test_voucher() and raises
the collective ValidationError.
Future work will refactor _test_voucher in terms of this, and save some
queries. '''
errors = []
for voucher in vouchers:
try:
self._test_voucher(voucher)
except ValidationError as ve:
errors.append(ve)
if errors:
raise(ValidationError(errors))
def _test_required_categories(self):
''' Makes sure that the owner of this cart has satisfied all of the
required category constraints in the inventory (be it in this cart
or others). '''
required = set(inventory.Category.objects.filter(required=True))
items = commerce.ProductItem.objects.filter(
product__category__required=True,
cart__user=self.cart.user,
).exclude(
cart__status=commerce.Cart.STATUS_RELEASED,
)
for item in items:
required.remove(item.product.category)
errors = []
for category in required:
msg = "You must have at least one item from: %s" % category
errors.append((None, msg))
if errors:
raise ValidationError(errors)
def _append_errors(self, errors, ve):
for error in ve.error_list:
errors.append(error.message[1])
def validate_cart(self):
''' Determines whether the status of the current cart is valid;
this is normally called before generating or paying an invoice '''
cart = self.cart
user = self.cart.user
errors = []
try:
self._test_vouchers(self.cart.vouchers.all())
except ValidationError as ve:
errors.append(ve)
items = commerce.ProductItem.objects.filter(cart=cart)
items = items.select_related("product", "product__category")
product_quantities = list((i.product, i.quantity) for i in items)
try:
self._test_limits(product_quantities)
except ValidationError as ve:
self._append_errors(errors, ve)
try:
self._test_required_categories()
except ValidationError as ve:
self._append_errors(errors, ve)
# Validate the discounts
# TODO: refactor in terms of available_discounts
# why aren't we doing that here?!
# def available_discounts(cls, user, categories, products):
products = [i.product for i in items]
discounts_with_quantity = DiscountController.available_discounts(
user,
[],
products,
)
discounts = set(i.discount.id for i in discounts_with_quantity)
discount_items = commerce.DiscountItem.objects.filter(cart=cart)
for discount_item in discount_items:
discount = discount_item.discount
if discount.id not in discounts:
errors.append(
ValidationError("Discounts are no longer available")
)
if errors:
raise ValidationError(errors)
@_modifies_cart
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:
to_remove.append(voucher)
for voucher in to_remove:
self.cart.vouchers.remove(voucher)
# Fix products and discounts
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(
self.cart.user,
products=products,
))
not_available = products - available
zeros = [(product, 0) for product in not_available]
self.set_quantities(zeros)
@transaction.atomic
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()
# Order the products such that the most expensive ones are
# processed first.
product_items = self.cart.productitem_set.all().select_related(
"product", "product__category"
).order_by("-product__price")
products = [i.product for i in product_items]
discounts = DiscountController.available_discounts(
self.cart.user,
[],
products,
)
# The highest-value discounts will apply to the highest-value
# products first, because of the order_by clause
for item in product_items:
self._add_discount(item.product, item.quantity, discounts)
def _add_discount(self, product, quantity, discounts):
''' Applies the best discounts on the given product, from the given
discounts.'''
def matches(discount):
''' Returns True if and only if the given discount apples to
our product. '''
if isinstance(discount.clause, conditions.DiscountForCategory):
return discount.clause.category == product.category
else:
return discount.clause.product == product
def value(discount):
''' Returns the value of this discount clause
as applied to this product '''
if discount.clause.percentage is not None:
return discount.clause.percentage * product.price
else:
return discount.clause.price
discounts = [i for i in discounts if matches(i)]
discounts.sort(key=value)
for candidate in reversed(discounts):
if quantity == 0:
break
elif candidate.quantity == 0:
# This discount clause has been exhausted by this cart
continue
# Get a provisional instance for this DiscountItem
# with the quantity set to as much as we have in the cart
discount_item = commerce.DiscountItem.objects.create(
product=product,
cart=self.cart,
discount=candidate.discount,
quantity=quantity,
)
# Truncate the quantity for this DiscountItem if we exceed quantity
ours = discount_item.quantity
allowed = candidate.quantity
if ours > allowed:
discount_item.quantity = allowed
discount_item.save()
# Update the remaining quantity.
quantity = ours - allowed
else:
quantity = 0
candidate.quantity -= discount_item.quantity

View file

@ -0,0 +1,78 @@
from registrasion.models import commerce
from registrasion.models import inventory
from django.db.models import Case
from django.db.models import F, Q
from django.db.models import Sum
from django.db.models import When
from django.db.models import Value
from .batch import BatchController
class AllProducts(object):
pass
class CategoryController(object):
def __init__(self, category):
self.category = category
@classmethod
def available_categories(cls, user, products=AllProducts):
''' Returns the categories available to the user. Specify `products` if
you want to restrict to just the categories that hold the specified
products, otherwise it'll do all. '''
# STOPGAP -- this needs to be elsewhere tbqh
from registrasion.controllers.product import ProductController
if products is AllProducts:
products = inventory.Product.objects.all().select_related(
"category",
)
available = ProductController.available_products(
user,
products=products,
)
return set(i.category for i in available)
@classmethod
@BatchController.memoise
def user_remainders(cls, user):
'''
Return:
Mapping[int->int]: A dictionary that maps the category ID to the
user's remainder for that category.
'''
categories = inventory.Category.objects.all()
cart_filter = (
Q(product__productitem__cart__user=user) &
Q(product__productitem__cart__status=commerce.Cart.STATUS_PAID)
)
quantity = When(
cart_filter,
then='product__productitem__quantity'
)
quantity_or_zero = Case(
quantity,
default=Value(0),
)
remainder = Case(
When(limit_per_user=None, then=Value(99999999)),
default=F('limit_per_user') - Sum(quantity_or_zero),
)
categories = categories.annotate(remainder=remainder)
return dict((cat.id, cat.remainder) for cat in categories)

View file

@ -0,0 +1,342 @@
from django.db.models import Case
from django.db.models import F, Q
from django.db.models import Sum
from django.db.models import Value
from django.db.models import When
from django.utils import timezone
from registrasion.models import commerce
from registrasion.models import conditions
_BIG_QUANTITY = 99999999 # A big quantity
class ConditionController(object):
''' Base class for testing conditions that activate Flag
or Discount objects. '''
def __init__(self, condition):
self.condition = condition
@staticmethod
def _controllers():
return {
conditions.CategoryFlag: CategoryConditionController,
conditions.GroupMemberDiscount: GroupMemberConditionController,
conditions.GroupMemberFlag: GroupMemberConditionController,
conditions.IncludedProductDiscount: ProductConditionController,
conditions.ProductFlag: ProductConditionController,
conditions.SpeakerFlag: SpeakerConditionController,
conditions.SpeakerDiscount: SpeakerConditionController,
conditions.TimeOrStockLimitDiscount:
TimeOrStockLimitDiscountController,
conditions.TimeOrStockLimitFlag:
TimeOrStockLimitFlagController,
conditions.VoucherDiscount: VoucherConditionController,
conditions.VoucherFlag: VoucherConditionController,
}
@staticmethod
def for_type(cls):
return ConditionController._controllers()[cls]
@staticmethod
def for_condition(condition):
try:
return ConditionController.for_type(type(condition))(condition)
except KeyError:
return ConditionController()
@classmethod
def pre_filter(cls, queryset, user):
''' Returns only the flag conditions that might be available for this
user. It should hopefully reduce the number of queries that need to be
executed to determine if a flag is met.
If this filtration implements the same query as is_met, then you should
be able to implement ``is_met()`` in terms of this.
Arguments:
queryset (Queryset[c]): The canditate conditions.
user (User): The user for whom we're testing these conditions.
Returns:
Queryset[c]: A subset of the conditions that pass the pre-filter
test for this user.
'''
# Default implementation does NOTHING.
return queryset
def passes_filter(self, user):
''' Returns true if the condition passes the filter '''
cls = type(self.condition)
qs = cls.objects.filter(pk=self.condition.id)
return self.condition in self.pre_filter(qs, user)
def user_quantity_remaining(self, user, filtered=False):
''' Returns the number of items covered by this flag condition the
user can add to the current cart. This default implementation returns
a big number if is_met() is true, otherwise 0.
Either this method, or is_met() must be overridden in subclasses.
'''
return _BIG_QUANTITY if self.is_met(user, filtered) else 0
def is_met(self, user, filtered=False):
''' Returns True if this flag condition is met, otherwise returns
False.
Either this method, or user_quantity_remaining() must be overridden
in subclasses.
Arguments:
user (User): The user for whom this test must be met.
filter (bool): If true, this condition was part of a queryset
returned by pre_filter() for this user.
'''
return self.user_quantity_remaining(user, filtered) > 0
class IsMetByFilter(object):
def is_met(self, user, filtered=False):
''' Returns True if this flag condition is met, otherwise returns
False. It determines if the condition is met by calling pre_filter
with a queryset containing only self.condition. '''
if filtered:
return True # Why query again?
return self.passes_filter(user)
class RemainderSetByFilter(object):
def user_quantity_remaining(self, user, filtered=True):
''' returns 0 if the date range is violated, otherwise, it will return
the quantity remaining under the stock limit.
The filter for this condition must add an annotation called "remainder"
in order for this to work.
'''
if filtered:
if hasattr(self.condition, "remainder"):
return self.condition.remainder
# Mark self.condition with a remainder
qs = type(self.condition).objects.filter(pk=self.condition.id)
qs = self.pre_filter(qs, user)
if len(qs) > 0:
return qs[0].remainder
else:
return 0
class CategoryConditionController(IsMetByFilter, ConditionController):
@classmethod
def pre_filter(self, queryset, user):
''' Returns all of the items from queryset where the user has a
product from a category invoking that item's condition in one of their
carts. '''
in_user_carts = Q(
enabling_category__product__productitem__cart__user=user
)
released = commerce.Cart.STATUS_RELEASED
in_released_carts = Q(
enabling_category__product__productitem__cart__status=released
)
queryset = queryset.filter(in_user_carts)
queryset = queryset.exclude(in_released_carts)
return queryset
class ProductConditionController(IsMetByFilter, ConditionController):
''' Condition tests for ProductFlag and
IncludedProductDiscount. '''
@classmethod
def pre_filter(self, queryset, user):
''' Returns all of the items from queryset where the user has a
product invoking that item's condition in one of their carts. '''
in_user_carts = Q(enabling_products__productitem__cart__user=user)
released = commerce.Cart.STATUS_RELEASED
paid = commerce.Cart.STATUS_PAID
active = commerce.Cart.STATUS_ACTIVE
in_released_carts = Q(
enabling_products__productitem__cart__status=released
)
not_in_paid_or_active_carts = ~(
Q(enabling_products__productitem__cart__status=paid) |
Q(enabling_products__productitem__cart__status=active)
)
queryset = queryset.filter(in_user_carts)
queryset = queryset.exclude(
in_released_carts & not_in_paid_or_active_carts
)
return queryset
class TimeOrStockLimitConditionController(
RemainderSetByFilter,
ConditionController,
):
''' Common condition tests for TimeOrStockLimit Flag and
Discount.'''
@classmethod
def pre_filter(self, queryset, user):
''' Returns all of the items from queryset where the date falls into
any specified range, but not yet where the stock limit is not yet
reached.'''
now = timezone.now()
# Keep items with no start time, or start time not yet met.
queryset = queryset.filter(Q(start_time=None) | Q(start_time__lte=now))
queryset = queryset.filter(Q(end_time=None) | Q(end_time__gte=now))
# Filter out items that have been reserved beyond the limits
quantity_or_zero = self._calculate_quantities(user)
remainder = Case(
When(limit=None, then=Value(_BIG_QUANTITY)),
default=F("limit") - Sum(quantity_or_zero),
)
queryset = queryset.annotate(remainder=remainder)
queryset = queryset.filter(remainder__gt=0)
return queryset
@classmethod
def _relevant_carts(cls, user):
reserved_carts = commerce.Cart.reserved_carts()
reserved_carts = reserved_carts.exclude(
user=user,
status=commerce.Cart.STATUS_ACTIVE,
)
return reserved_carts
class TimeOrStockLimitFlagController(
TimeOrStockLimitConditionController):
@classmethod
def _calculate_quantities(cls, user):
reserved_carts = cls._relevant_carts(user)
# Calculate category lines
item_cats = F('categories__product__productitem__product__category')
reserved_category_products = (
Q(categories=item_cats) &
Q(categories__product__productitem__cart__in=reserved_carts)
)
# Calculate product lines
reserved_products = (
Q(products=F('products__productitem__product')) &
Q(products__productitem__cart__in=reserved_carts)
)
category_quantity_in_reserved_carts = When(
reserved_category_products,
then="categories__product__productitem__quantity",
)
product_quantity_in_reserved_carts = When(
reserved_products,
then="products__productitem__quantity",
)
quantity_or_zero = Case(
category_quantity_in_reserved_carts,
product_quantity_in_reserved_carts,
default=Value(0),
)
return quantity_or_zero
class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController):
@classmethod
def _calculate_quantities(cls, user):
reserved_carts = cls._relevant_carts(user)
quantity_in_reserved_carts = When(
discountitem__cart__in=reserved_carts,
then="discountitem__quantity"
)
quantity_or_zero = Case(
quantity_in_reserved_carts,
default=Value(0)
)
return quantity_or_zero
class VoucherConditionController(IsMetByFilter, ConditionController):
''' Condition test for VoucherFlag and VoucherDiscount.'''
@classmethod
def pre_filter(self, queryset, user):
''' Returns all of the items from queryset where the user has entered
a voucher that invokes that item's condition in one of their carts. '''
return queryset.filter(voucher__cart__user=user)
class SpeakerConditionController(IsMetByFilter, ConditionController):
@classmethod
def pre_filter(self, queryset, user):
''' Returns all of the items from queryset which are enabled by a user
being a presenter or copresenter of a non-cancelled proposal. '''
# Filter out cancelled proposals
queryset = queryset.filter(
proposal_kind__proposalbase__presentation__cancelled=False
)
u = user
# User is a presenter
user_is_presenter = Q(
is_presenter=True,
proposal_kind__proposalbase__presentation__speaker__user=u,
)
# User is a copresenter
user_is_copresenter = Q(
is_copresenter=True,
proposal_kind__proposalbase__presentation__additional_speakers__user=u, # NOQA
)
return queryset.filter(user_is_presenter | user_is_copresenter)
class GroupMemberConditionController(IsMetByFilter, ConditionController):
@classmethod
def pre_filter(self, conditions, user):
''' Returns all of the items from conditions which are enabled by a
user being member of a Django Auth Group. '''
return conditions.filter(group__in=user.groups.all())

View file

@ -0,0 +1,83 @@
import datetime
from django.db import transaction
from registrasion.models import commerce
from registrasion.controllers.for_id import ForId
class CreditNoteController(ForId, object):
__MODEL__ = commerce.CreditNote
def __init__(self, credit_note):
self.credit_note = credit_note
@classmethod
def generate_from_invoice(cls, invoice, value):
''' Generates a credit note of the specified value and pays it against
the given invoice. You need to call InvoiceController.update_status()
to set the status correctly, if appropriate. '''
credit_note = commerce.CreditNote.objects.create(
invoice=invoice,
amount=0-value, # Credit notes start off as a payment against inv.
reference="ONE MOMENT",
)
credit_note.reference = "Generated credit note %d" % credit_note.id
credit_note.save()
return cls(credit_note)
@transaction.atomic
def apply_to_invoice(self, invoice):
''' Applies the total value of this credit note to the specified
invoice. If this credit note overpays the invoice, a new credit note
containing the residual value will be created.
Raises ValidationError if the given invoice is not allowed to be
paid.
'''
# Circular Import
from registrasion.controllers.invoice import InvoiceController
inv = InvoiceController(invoice)
inv.validate_allowed_to_pay()
# Apply payment to invoice
commerce.CreditNoteApplication.objects.create(
parent=self.credit_note,
invoice=invoice,
amount=self.credit_note.value,
reference="Applied credit note #%d" % self.credit_note.id,
)
inv.update_status()
# TODO: Add administration fee generator.
@transaction.atomic
def cancellation_fee(self, percentage):
''' Generates an invoice with a cancellation fee, and applies
credit to the invoice.
percentage (Decimal): The percentage of the credit note to turn into
a cancellation fee. Must be 0 <= percentage <= 100.
'''
# Circular Import
from registrasion.controllers.invoice import InvoiceController
assert(percentage >= 0 and percentage <= 100)
cancellation_fee = self.credit_note.value * percentage / 100
due = datetime.timedelta(days=1)
item = [("Cancellation fee", cancellation_fee)]
invoice = InvoiceController.manual_invoice(
self.credit_note.invoice.user, due, item
)
if not invoice.is_paid:
self.apply_to_invoice(invoice)
return InvoiceController(invoice)

View file

@ -0,0 +1,197 @@
import itertools
from .batch import BatchController
from .conditions import ConditionController
from registrasion.models import commerce
from registrasion.models import conditions
from django.db.models import Case
from django.db.models import F, Q
from django.db.models import Sum
from django.db.models import Value
from django.db.models import When
class DiscountAndQuantity(object):
''' Represents a discount that can be applied to a product or category
for a given user.
Attributes:
discount (conditions.DiscountBase): The discount object that the
clause arises from. A given DiscountBase can apply to multiple
clauses.
clause (conditions.DiscountForProduct|conditions.DiscountForCategory):
A clause describing which product or category this discount item
applies to. This casts to ``str()`` to produce a human-readable
version of the clause.
quantity (int): The number of times this discount item can be applied
for the given user.
'''
def __init__(self, discount, clause, quantity):
self.discount = discount
self.clause = clause
self.quantity = quantity
def __repr__(self):
return "(discount=%s, clause=%s, quantity=%d)" % (
self.discount, self.clause, self.quantity,
)
class DiscountController(object):
@classmethod
def available_discounts(cls, user, categories, products):
''' Returns all discounts available to this user for the given
categories and products. The discounts also list the available quantity
for this user, not including products that are pending purchase. '''
filtered_clauses = cls._filtered_clauses(user)
# clauses that match provided categories
categories = set(categories)
# clauses that match provided products
products = set(products)
# clauses that match categories for provided products
product_categories = set(product.category for product in products)
# (Not relevant: clauses that match products in provided categories)
all_categories = categories | product_categories
filtered_clauses = (
clause for clause in filtered_clauses
if hasattr(clause, 'product') and clause.product in products or
hasattr(clause, 'category') and clause.category in all_categories
)
discounts = []
# Markers so that we don't need to evaluate given conditions
# more than once
accepted_discounts = set()
failed_discounts = set()
for clause in filtered_clauses:
discount = clause.discount
cond = ConditionController.for_condition(discount)
past_use_count = clause.past_use_count
if past_use_count >= clause.quantity:
# This clause has exceeded its use count
pass
elif discount not in failed_discounts:
# This clause is still available
is_accepted = discount in accepted_discounts
if is_accepted or cond.is_met(user, filtered=True):
# This clause is valid for this user
discounts.append(DiscountAndQuantity(
discount=discount,
clause=clause,
quantity=clause.quantity - past_use_count,
))
accepted_discounts.add(discount)
else:
# This clause is not valid for this user
failed_discounts.add(discount)
return discounts
@classmethod
@BatchController.memoise
def _filtered_clauses(cls, user):
'''
Returns:
Sequence[DiscountForProduct | DiscountForCategory]: All clauses
that passed the filter function.
'''
types = list(ConditionController._controllers())
discounttypes = [
i for i in types if issubclass(i, conditions.DiscountBase)
]
product_clauses = conditions.DiscountForProduct.objects.all()
product_clauses = product_clauses.select_related(
"discount",
"product",
"product__category",
)
category_clauses = conditions.DiscountForCategory.objects.all()
category_clauses = category_clauses.select_related(
"category",
"discount",
)
all_subsets = []
for discounttype in discounttypes:
discounts = discounttype.objects.all()
ctrl = ConditionController.for_type(discounttype)
discounts = ctrl.pre_filter(discounts, user)
all_subsets.append(discounts)
filtered_discounts = list(itertools.chain(*all_subsets))
# Map from discount key to itself
# (contains annotations needed in the future)
from_filter = dict((i.id, i) for i in filtered_discounts)
clause_sets = (
product_clauses.filter(discount__in=filtered_discounts),
category_clauses.filter(discount__in=filtered_discounts),
)
clause_sets = (
cls._annotate_with_past_uses(i, user) for i in clause_sets
)
# The set of all potential discount clauses
discount_clauses = set(itertools.chain(*clause_sets))
# Replace discounts with the filtered ones
# These are the correct subclasses (saves query later on), and have
# correct annotations from filters if necessary.
for clause in discount_clauses:
clause.discount = from_filter[clause.discount.id]
return discount_clauses
@classmethod
def _annotate_with_past_uses(cls, queryset, user):
''' Annotates the queryset with a usage count for that discount claus
by the given user. '''
if queryset.model == conditions.DiscountForCategory:
matches = (
Q(category=F('discount__discountitem__product__category'))
)
elif queryset.model == conditions.DiscountForProduct:
matches = (
Q(product=F('discount__discountitem__product'))
)
in_carts = (
Q(discount__discountitem__cart__user=user) &
Q(discount__discountitem__cart__status=commerce.Cart.STATUS_PAID)
)
past_use_quantity = When(
in_carts & matches,
then="discount__discountitem__quantity",
)
past_use_quantity_or_zero = Case(
past_use_quantity,
default=Value(0),
)
queryset = queryset.annotate(
past_use_count=Sum(past_use_quantity_or_zero)
)
return queryset

258
vendor/registrasion/controllers/flag.py vendored Normal file
View file

@ -0,0 +1,258 @@
import itertools
from collections import defaultdict
from collections import namedtuple
from django.db.models import Count
from django.db.models import Q
from .batch import BatchController
from .conditions import ConditionController
from registrasion.models import conditions
from registrasion.models import inventory
class FlagController(object):
@classmethod
def test_flags(
cls, user, products=None, product_quantities=None):
''' Evaluates all of the flag conditions on the given products.
If `product_quantities` is supplied, the condition is only met if it
will permit the sum of the product quantities for all of the products
it covers. Otherwise, it will be met if at least one item can be
accepted.
If all flag conditions pass, an empty list is returned, otherwise
a list is returned containing all of the products that are *not
enabled*. '''
if products is not None and product_quantities is not None:
raise ValueError("Please specify only products or "
"product_quantities")
elif products is None:
products = set(i[0] for i in product_quantities)
quantities = dict((product, quantity)
for product, quantity in product_quantities)
elif product_quantities is None:
products = set(products)
quantities = {}
if products:
# Simplify the query.
all_conditions = cls._filtered_flags(user)
else:
all_conditions = []
# All disable-if-false conditions on a product need to be met
do_not_disable = defaultdict(lambda: True)
# At least one enable-if-true condition on a product must be met
do_enable = defaultdict(lambda: False)
# (if either sort of condition is present)
# Count the number of conditions for a product
dif_count = defaultdict(int)
eit_count = defaultdict(int)
messages = {}
for condition in all_conditions:
cond = ConditionController.for_condition(condition)
remainder = cond.user_quantity_remaining(user, filtered=True)
# Get all products covered by this condition, and the products
# from the categories covered by this condition
ids = [product.id for product in products]
# TODO: This is re-evaluated a lot.
all_products = inventory.Product.objects.filter(id__in=ids)
cond = (
Q(flagbase_set=condition) |
Q(category__in=condition.categories.all())
)
all_products = all_products.filter(cond)
all_products = all_products.select_related("category")
if quantities:
consumed = sum(quantities[i] for i in all_products)
else:
consumed = 1
met = consumed <= remainder
if not met:
message = cls._error_message(all_products, remainder)
for product in all_products:
if condition.is_disable_if_false:
do_not_disable[product] &= met
dif_count[product] += 1
else:
do_enable[product] |= met
eit_count[product] += 1
if not met and product not in messages:
messages[product] = message
total_flags = FlagCounter.count(user)
valid = {}
# the problem is that now, not every condition falls into
# do_not_disable or do_enable '''
# You should look into this, chris :)
for product in products:
if quantities:
if quantities[product] == 0:
continue
f = total_flags.get(product)
if f.dif > 0 and f.dif != dif_count[product]:
do_not_disable[product] = False
if product not in messages:
messages[product] = cls._error_message([product], 0)
if f.eit > 0 and product not in do_enable:
do_enable[product] = False
if product not in messages:
messages[product] = cls._error_message([product], 0)
for product in itertools.chain(do_not_disable, do_enable):
f = total_flags.get(product)
if product in do_enable:
# If there's an enable-if-true, we need need of those met too.
# (do_not_disable will default to true otherwise)
valid[product] = do_not_disable[product] and do_enable[product]
elif product in do_not_disable:
# If there's a disable-if-false condition, all must be met
valid[product] = do_not_disable[product]
error_fields = [
(product, messages[product])
for product in valid if not valid[product]
]
return error_fields
SINGLE = True
PLURAL = False
NONE = True
SOME = False
MESSAGE = {
NONE: {
SINGLE:
"%(items)s is no longer available to you",
PLURAL:
"%(items)s are no longer available to you",
},
SOME: {
SINGLE:
"Only %(remainder)d of the following item remains: %(items)s",
PLURAL:
"Only %(remainder)d of the following items remain: %(items)s"
},
}
@classmethod
def _error_message(cls, affected, remainder):
product_strings = (str(product) for product in affected)
items = ", ".join(product_strings)
base = cls.MESSAGE[remainder == 0][len(affected) == 1]
message = base % {"items": items, "remainder": remainder}
return message
@classmethod
@BatchController.memoise
def _filtered_flags(cls, user):
'''
Returns:
Sequence[flagbase]: All flags that passed the filter function.
'''
types = list(ConditionController._controllers())
flagtypes = [i for i in types if issubclass(i, conditions.FlagBase)]
all_subsets = []
for flagtype in flagtypes:
flags = flagtype.objects.all()
ctrl = ConditionController.for_type(flagtype)
flags = ctrl.pre_filter(flags, user)
all_subsets.append(flags)
return list(itertools.chain(*all_subsets))
ConditionAndRemainder = namedtuple(
"ConditionAndRemainder",
(
"condition",
"remainder",
),
)
_FlagCounter = namedtuple(
"_FlagCounter",
(
"products",
"categories",
),
)
_ConditionsCount = namedtuple(
"ConditionsCount",
(
"dif",
"eit",
),
)
class FlagCounter(_FlagCounter):
@classmethod
@BatchController.memoise
def count(cls, user):
# Get the count of how many conditions should exist per product
flagbases = conditions.FlagBase.objects
types = (
conditions.FlagBase.ENABLE_IF_TRUE,
conditions.FlagBase.DISABLE_IF_FALSE,
)
keys = ("eit", "dif")
flags = [
flagbases.filter(
condition=condition_type
).values(
'products', 'categories'
).annotate(
count=Count('id')
)
for condition_type in types
]
cats = defaultdict(lambda: defaultdict(int))
prod = defaultdict(lambda: defaultdict(int))
for key, flagcounts in zip(keys, flags):
for row in flagcounts:
if row["products"] is not None:
prod[row["products"]][key] = row["count"]
if row["categories"] is not None:
cats[row["categories"]][key] = row["count"]
return cls(products=prod, categories=cats)
def get(self, product):
p = self.products[product.id]
c = self.categories[product.category.id]
eit = p["eit"] + c["eit"]
dif = p["dif"] + c["dif"]
return _ConditionsCount(dif=dif, eit=eit)

View file

@ -0,0 +1,24 @@
from django.core.exceptions import ObjectDoesNotExist
from django.http import Http404
class ForId(object):
''' Mixin class that gives you new classmethods: for_id for_id_or_404.
These let you retrieve an instance of the class by specifying the model ID.
Your subclass must define __MODEL__ as a class attribute. This will be the
model class that we wrap. There must also be a constructor that takes a
single argument: the instance of the model that we are controlling. '''
@classmethod
def for_id(cls, id_):
id_ = int(id_)
obj = cls.__MODEL__.objects.get(pk=id_)
return cls(obj)
@classmethod
def for_id_or_404(cls, id_):
try:
return cls.for_id(id_)
except ObjectDoesNotExist:
raise Http404()

View file

@ -0,0 +1,453 @@
from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from registrasion.contrib.mail import send_email
from registrasion.models import commerce
from registrasion.models import conditions
from registrasion.models import people
from registrasion.controllers.cart import CartController
from registrasion.controllers.credit_note import CreditNoteController
from registrasion.controllers.for_id import ForId
class InvoiceController(ForId, object):
__MODEL__ = commerce.Invoice
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.update_old_invoices(cart)
invoice = cls._generate_from_cart(cart)
return cls(invoice)
@classmethod
def update_old_invoices(cls, cart):
invoices = commerce.Invoice.objects.filter(cart=cart).all()
for invoice in invoices:
cls(invoice).update_status()
@classmethod
def resolve_discount_value(cls, item):
try:
condition = conditions.DiscountForProduct.objects.get(
discount=item.discount,
product=item.product
)
except ObjectDoesNotExist:
condition = conditions.DiscountForCategory.objects.get(
discount=item.discount,
category=item.product.category
)
if condition.percentage is not None:
value = item.product.price * (condition.percentage / 100)
else:
value = condition.price
return value
@classmethod
@transaction.atomic
def manual_invoice(cls, user, due_delta, description_price_pairs):
''' Generates an invoice for arbitrary items, not held in a user's
cart.
Arguments:
user (User): The user the invoice is being generated for.
due_delta (datetime.timedelta): The length until the invoice is
due.
description_price_pairs ([(str, long or Decimal), ...]): A list of
pairs. Each pair consists of the description for each line item
and the price for that line item. The price will be cast to
Decimal.
Returns:
an Invoice.
'''
line_items = []
for description, price in description_price_pairs:
line_item = commerce.LineItem(
description=description,
quantity=1,
price=Decimal(price),
product=None,
)
line_items.append(line_item)
min_due_time = timezone.now() + due_delta
return cls._generate(user, None, min_due_time, line_items)
@classmethod
@transaction.atomic
def _generate_from_cart(cls, cart):
''' Generates an invoice for the given cart. '''
cart.refresh_from_db()
# Generate the line items from the cart.
product_items = commerce.ProductItem.objects.filter(cart=cart)
product_items = product_items.select_related(
"product",
"product__category",
)
product_items = product_items.order_by(
"product__category__order", "product__order"
)
if len(product_items) == 0:
raise ValidationError("Your cart is empty.")
discount_items = commerce.DiscountItem.objects.filter(cart=cart)
discount_items = discount_items.select_related(
"discount",
"product",
"product__category",
)
def format_product(product):
return "%s - %s" % (product.category.name, product.name)
def format_discount(discount, product):
description = discount.description
return "%s (%s)" % (description, format_product(product))
line_items = []
for item in product_items:
product = item.product
line_item = commerce.LineItem(
description=format_product(product),
quantity=item.quantity,
price=product.price,
product=product,
)
line_items.append(line_item)
for item in discount_items:
line_item = commerce.LineItem(
description=format_discount(item.discount, item.product),
quantity=item.quantity,
price=cls.resolve_discount_value(item) * -1,
product=item.product,
)
line_items.append(line_item)
# Generate the invoice
min_due_time = cart.reservation_duration + cart.time_last_updated
return cls._generate(cart.user, cart, min_due_time, line_items)
@classmethod
@transaction.atomic
def _generate(cls, user, cart, min_due_time, line_items):
# Never generate a due time that is before the issue time
issued = timezone.now()
due = max(issued, min_due_time)
# Get the invoice recipient
profile = people.AttendeeProfileBase.objects.get_subclass(
id=user.attendee.attendeeprofilebase.id,
)
recipient = profile.invoice_recipient()
invoice_value = sum(item.quantity * item.price for item in line_items)
invoice = commerce.Invoice.objects.create(
user=user,
cart=cart,
cart_revision=cart.revision if cart else None,
status=commerce.Invoice.STATUS_UNPAID,
value=invoice_value,
issue_time=issued,
due_time=due,
recipient=recipient,
)
# Associate the line items with the invoice
for line_item in line_items:
line_item.invoice = invoice
commerce.LineItem.objects.bulk_create(line_items)
cls._apply_credit_notes(invoice)
cls.email_on_invoice_creation(invoice)
return invoice
@classmethod
def _apply_credit_notes(cls, invoice):
''' Applies the user's credit notes to the given invoice on creation.
'''
# We only automatically apply credit notes if this is the *only*
# unpaid invoice for this user.
invoices = commerce.Invoice.objects.filter(
user=invoice.user,
status=commerce.Invoice.STATUS_UNPAID,
)
if invoices.count() > 1:
return
notes = commerce.CreditNote.unclaimed().filter(
invoice__user=invoice.user
)
for note in notes:
try:
CreditNoteController(note).apply_to_invoice(invoice)
except ValidationError:
# ValidationError will get raised once we're overpaying.
break
invoice.refresh_from_db()
def can_view(self, user=None, access_code=None):
''' Returns true if the accessing user is allowed to view this invoice,
or if the given access code matches this invoice's user's access code.
'''
if user == self.invoice.user:
return True
if user.is_staff:
return True
if self.invoice.user.attendee.access_code == access_code:
return True
return False
def _refresh(self):
''' Refreshes the underlying invoice and cart objects. '''
self.invoice.refresh_from_db()
if self.invoice.cart:
self.invoice.cart.refresh_from_db()
def validate_allowed_to_pay(self):
''' Passes cleanly if we're allowed to pay, otherwise raise
a ValidationError. '''
self._refresh()
if not self.invoice.is_unpaid:
raise ValidationError("You can only pay for unpaid invoices.")
if not self.invoice.cart:
return
if not self._invoice_matches_cart():
raise ValidationError("The registration has been amended since "
"generating this invoice.")
CartController(self.invoice.cart).validate_cart()
def update_status(self):
''' Updates the status of this invoice based upon the total
payments.'''
old_status = self.invoice.status
total_paid = self.invoice.total_payments()
num_payments = commerce.PaymentBase.objects.filter(
invoice=self.invoice,
).count()
remainder = self.invoice.value - total_paid
if old_status == commerce.Invoice.STATUS_UNPAID:
# Invoice had an amount owing
if remainder <= 0:
# Invoice no longer has amount owing
self._mark_paid()
elif total_paid == 0 and num_payments > 0:
# Invoice has multiple payments totalling zero
self._mark_void()
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 == commerce.Invoice.STATUS_REFUNDED:
# Should not ever change from here
pass
elif old_status == commerce.Invoice.STATUS_VOID:
# Should not ever change from here
pass
# Generate credit notes from residual payments
residual = 0
if self.invoice.is_paid:
if remainder < 0:
residual = 0 - remainder
elif self.invoice.is_void or self.invoice.is_refunded:
residual = total_paid
if residual != 0:
CreditNoteController.generate_from_invoice(self.invoice, residual)
self.email_on_invoice_change(
self.invoice,
old_status,
self.invoice.status,
)
def _mark_paid(self):
''' Marks the invoice as paid, and updates the attached cart if
necessary. '''
cart = self.invoice.cart
if cart:
cart.status = commerce.Cart.STATUS_PAID
cart.save()
self.invoice.status = commerce.Invoice.STATUS_PAID
self.invoice.save()
def _mark_refunded(self):
''' Marks the invoice as refunded, and updates the attached cart if
necessary. '''
self._release_cart()
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 _release_cart(self):
cart = self.invoice.cart
if cart:
cart.status = commerce.Cart.STATUS_RELEASED
cart.save()
def update_validity(self):
''' Voids this invoice if the attached cart is no longer valid because
the cart revision has changed, or the reservations have expired. '''
is_valid = self._invoice_matches_cart()
cart = self.invoice.cart
if self.invoice.is_unpaid and is_valid and cart:
try:
CartController(cart).validate_cart()
except ValidationError:
is_valid = False
if not is_valid:
if self.invoice.total_payments() > 0:
# Free up the payments made to this invoice
self.refund()
else:
self.void()
def void(self):
''' Voids the invoice if it is valid to do so. '''
if self.invoice.total_payments() > 0:
raise ValidationError("Invoices with payments must be refunded.")
elif self.invoice.is_refunded:
raise ValidationError("Refunded invoices may not be voided.")
if self.invoice.is_paid:
self._release_cart()
self._mark_void()
@transaction.atomic
def refund(self):
''' Refunds the invoice by generating a CreditNote for the value of
all of the payments against the cart.
The invoice is marked as refunded, and the underlying cart is marked
as released.
'''
if self.invoice.is_void:
raise ValidationError("Void invoices cannot be refunded")
# Raises a credit note fot the value of the invoice.
amount = self.invoice.total_payments()
if amount == 0:
self.void()
return
CreditNoteController.generate_from_invoice(self.invoice, amount)
self.update_status()
@classmethod
def email(cls, invoice, kind):
''' Sends out an e-mail notifying the user about something to do
with that invoice. '''
context = {
"invoice": invoice,
}
send_email([invoice.user.email], kind, context=context)
@classmethod
def email_on_invoice_creation(cls, invoice):
''' Sends out an e-mail notifying the user that an invoice has been
created. '''
cls.email(invoice, "invoice_created")
@classmethod
def email_on_invoice_change(cls, invoice, old_status, new_status):
''' Sends out all of the necessary notifications that the status of the
invoice has changed to:
- Invoice is now paid
- Invoice is now refunded
'''
# The statuses that we don't care about.
silent_status = [
commerce.Invoice.STATUS_VOID,
commerce.Invoice.STATUS_UNPAID,
]
if old_status == new_status:
return
if False and new_status in silent_status:
pass
cls.email(invoice, "invoice_updated")

126
vendor/registrasion/controllers/item.py vendored Normal file
View file

@ -0,0 +1,126 @@
''' NEEDS TESTS '''
import operator
from functools import reduce
from registrasion.models import commerce
from registrasion.models import inventory
from collections import Iterable
from collections import namedtuple
from django.db.models import Case
from django.db.models import Q
from django.db.models import Sum
from django.db.models import When
from django.db.models import Value
_ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"])
class ProductAndQuantity(_ProductAndQuantity):
''' Class that holds a product and a quantity.
Attributes:
product (models.inventory.Product)
quantity (int)
'''
pass
class ItemController(object):
def __init__(self, user):
self.user = user
def _items(self, cart_status, category=None):
''' Aggregates the items that this user has purchased.
Arguments:
cart_status (int or Iterable(int)): etc
category (Optional[models.inventory.Category]): the category
of items to restrict to.
Returns:
[ProductAndQuantity, ...]: A list of product-quantity pairs,
aggregating like products from across multiple invoices.
'''
if not isinstance(cart_status, Iterable):
cart_status = [cart_status]
status_query = (
Q(productitem__cart__status=status) for status in cart_status
)
in_cart = Q(productitem__cart__user=self.user)
in_cart = in_cart & reduce(operator.__or__, status_query)
quantities_in_cart = When(
in_cart,
then="productitem__quantity",
)
quantities_or_zero = Case(
quantities_in_cart,
default=Value(0),
)
products = inventory.Product.objects
if category:
products = products.filter(category=category)
products = products.select_related("category")
products = products.annotate(quantity=Sum(quantities_or_zero))
products = products.filter(quantity__gt=0)
out = []
for prod in products:
out.append(ProductAndQuantity(prod, prod.quantity))
return out
def items_pending_or_purchased(self):
''' Returns the items that this user has purchased or has pending. '''
status = [commerce.Cart.STATUS_PAID, commerce.Cart.STATUS_ACTIVE]
return self._items(status)
def items_purchased(self, category=None):
''' Aggregates the items that this user has purchased.
Arguments:
category (Optional[models.inventory.Category]): the category
of items to restrict to.
Returns:
[ProductAndQuantity, ...]: A list of product-quantity pairs,
aggregating like products from across multiple invoices.
'''
return self._items(commerce.Cart.STATUS_PAID, category=category)
def items_pending(self):
''' Gets all of the items that the user has reserved, but has not yet
paid for.
Returns:
[ProductAndQuantity, ...]: A list of product-quantity pairs for the
items that the user has not yet paid for.
'''
return self._items(commerce.Cart.STATUS_ACTIVE)
def items_released(self):
''' Gets all of the items that the user previously paid for, but has
since refunded.
Returns:
[ProductAndQuantity, ...]: A list of product-quantity pairs for the
items that the user has not yet paid for.
'''
return self._items(commerce.Cart.STATUS_RELEASED)

View file

@ -0,0 +1,92 @@
import itertools
from django.db.models import Case
from django.db.models import F, Q
from django.db.models import Sum
from django.db.models import When
from django.db.models import Value
from registrasion.models import commerce
from registrasion.models import inventory
from .batch import BatchController
from .category import CategoryController
from .flag import FlagController
class ProductController(object):
def __init__(self, product):
self.product = product
@classmethod
def available_products(cls, user, category=None, products=None):
''' Returns a list of all of the products that are available per
flag conditions from the given categories. '''
if category is None and products is None:
raise ValueError("You must provide products or a category")
if category is not None:
all_products = inventory.Product.objects.filter(category=category)
all_products = all_products.select_related("category")
else:
all_products = []
if products is not None:
all_products = set(itertools.chain(all_products, products))
category_remainders = CategoryController.user_remainders(user)
product_remainders = ProductController.user_remainders(user)
passed_limits = set(
product
for product in all_products
if category_remainders[product.category.id] > 0
if product_remainders[product.id] > 0
)
failed_and_messages = FlagController.test_flags(
user, products=passed_limits
)
failed_conditions = set(i[0] for i in failed_and_messages)
out = list(passed_limits - failed_conditions)
out.sort(key=lambda product: product.order)
return out
@classmethod
@BatchController.memoise
def user_remainders(cls, user):
'''
Return:
Mapping[int->int]: A dictionary that maps the product ID to the
user's remainder for that product.
'''
products = inventory.Product.objects.all()
cart_filter = (
Q(productitem__cart__user=user) &
Q(productitem__cart__status=commerce.Cart.STATUS_PAID)
)
quantity = When(
cart_filter,
then='productitem__quantity'
)
quantity_or_zero = Case(
quantity,
default=Value(0),
)
remainder = Case(
When(limit_per_user=None, then=Value(99999999)),
default=F('limit_per_user') - Sum(quantity_or_zero),
)
products = products.annotate(remainder=remainder)
return dict((product.id, product.remainder) for product in products)

5
vendor/registrasion/exceptions.py vendored Normal file
View file

@ -0,0 +1,5 @@
from django.core.exceptions import ValidationError
class CartValidationError(ValidationError):
pass

493
vendor/registrasion/forms.py vendored Normal file
View file

@ -0,0 +1,493 @@
from registrasion.controllers.product import ProductController
from registrasion.models import commerce
from registrasion.models import inventory
from django import forms
from django.db.models import Q
class ApplyCreditNoteForm(forms.Form):
required_css_class = 'label-required'
def __init__(self, user, *a, **k):
''' User: The user whose invoices should be made available as
choices. '''
self.user = user
super(ApplyCreditNoteForm, self).__init__(*a, **k)
self.fields["invoice"].choices = self._unpaid_invoices
def _unpaid_invoices(self):
invoices = commerce.Invoice.objects.filter(
status=commerce.Invoice.STATUS_UNPAID,
).select_related("user")
invoices_annotated = [invoice.__dict__ for invoice in invoices]
users = dict((inv.user.id, inv.user) for inv in invoices)
for invoice in invoices_annotated:
invoice.update({
"user_id": users[invoice["user_id"]].id,
"user_email": users[invoice["user_id"]].email,
})
key = lambda inv: (0 - (inv["user_id"] == self.user.id), inv["id"]) # noqa
invoices_annotated.sort(key=key)
template = ('Invoice %(id)d - user: %(user_email)s (%(user_id)d) '
'- $%(value)d')
return [
(invoice["id"], template % invoice)
for invoice in invoices_annotated
]
invoice = forms.ChoiceField(
required=True,
)
verify = forms.BooleanField(
required=True,
help_text="Have you verified that this is the correct invoice?",
)
class CancellationFeeForm(forms.Form):
required_css_class = 'label-required'
percentage = forms.DecimalField(
required=True,
min_value=0,
max_value=100,
)
class ManualCreditNoteRefundForm(forms.ModelForm):
required_css_class = 'label-required'
class Meta:
model = commerce.ManualCreditNoteRefund
fields = ["reference"]
class ManualPaymentForm(forms.ModelForm):
required_css_class = 'label-required'
class Meta:
model = commerce.ManualPayment
fields = ["reference", "amount"]
# Products forms -- none of these have any fields: they are to be subclassed
# and the fields added as needs be. ProductsForm (the function) is responsible
# for the subclassing.
def ProductsForm(category, products):
''' Produces an appropriate _ProductsForm subclass for the given render
type. '''
# Each Category.RENDER_TYPE value has a subclass here.
cat = inventory.Category
RENDER_TYPES = {
cat.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm,
cat.RENDER_TYPE_RADIO: _RadioButtonProductsForm,
cat.RENDER_TYPE_ITEM_QUANTITY: _ItemQuantityProductsForm,
}
# Produce a subclass of _ProductsForm which we can alter the base_fields on
class ProductsForm(RENDER_TYPES[category.render_type]):
pass
ProductsForm.set_fields(category, products)
if category.render_type == inventory.Category.RENDER_TYPE_ITEM_QUANTITY:
ProductsForm = forms.formset_factory(
ProductsForm,
formset=_ItemQuantityProductsFormSet,
)
return ProductsForm
class _HasProductsFields(object):
PRODUCT_PREFIX = "product_"
''' Base class for product entry forms. '''
def __init__(self, *a, **k):
if "product_quantities" in k:
initial = self.initial_data(k["product_quantities"])
k["initial"] = initial
del k["product_quantities"]
super(_HasProductsFields, self).__init__(*a, **k)
@classmethod
def field_name(cls, product):
return cls.PRODUCT_PREFIX + ("%d" % product.id)
@classmethod
def set_fields(cls, category, products):
''' Sets the base_fields on this _ProductsForm to allow selecting
from the provided products. '''
pass
@classmethod
def initial_data(cls, product_quantites):
''' Prepares initial data for an instance of this form.
product_quantities is a sequence of (product,quantity) tuples '''
return {}
def product_quantities(self):
''' Yields a sequence of (product, quantity) tuples from the
cleaned form data. '''
return iter([])
def add_product_error(self, product, error):
''' Adds an error to the given product's field '''
''' if product in field_names:
field = field_names[product]
elif isinstance(product, inventory.Product):
return
else:
field = None '''
self.add_error(self.field_name(product), error)
class _ProductsForm(_HasProductsFields, forms.Form):
required_css_class = 'label-required'
pass
class _QuantityBoxProductsForm(_ProductsForm):
''' Products entry form that allows users to enter quantities
of desired products. '''
@classmethod
def set_fields(cls, category, products):
for product in products:
if product.description:
help_text = "$%d each -- %s" % (
product.price,
product.description,
)
else:
help_text = "$%d each" % product.price
field = forms.IntegerField(
label=product.name,
help_text=help_text,
min_value=0,
max_value=500, # Issue #19. We should figure out real limit.
)
cls.base_fields[cls.field_name(product)] = field
@classmethod
def initial_data(cls, product_quantities):
initial = {}
for product, quantity in product_quantities:
initial[cls.field_name(product)] = quantity
return initial
def product_quantities(self):
for name, value in self.cleaned_data.items():
if name.startswith(self.PRODUCT_PREFIX):
product_id = int(name[len(self.PRODUCT_PREFIX):])
yield (product_id, value)
class _RadioButtonProductsForm(_ProductsForm):
''' Products entry form that allows users to enter quantities
of desired products. '''
FIELD = "chosen_product"
@classmethod
def set_fields(cls, category, products):
choices = []
for product in products:
choice_text = "%s -- $%d" % (product.name, product.price)
choices.append((product.id, choice_text))
if not category.required:
choices.append((0, "No selection"))
cls.base_fields[cls.FIELD] = forms.TypedChoiceField(
label=category.name,
widget=forms.RadioSelect,
choices=choices,
empty_value=0,
coerce=int,
)
@classmethod
def initial_data(cls, product_quantities):
initial = {}
for product, quantity in product_quantities:
if quantity > 0:
initial[cls.FIELD] = product.id
break
return initial
def product_quantities(self):
ours = self.cleaned_data[self.FIELD]
choices = self.fields[self.FIELD].choices
for choice_value, choice_display in choices:
if choice_value == 0:
continue
yield (
choice_value,
1 if ours == choice_value else 0,
)
def add_product_error(self, product, error):
self.add_error(self.FIELD, error)
class _ItemQuantityProductsForm(_ProductsForm):
''' Products entry form that allows users to select a product type, and
enter a quantity of that product. This version _only_ allows a single
product type to be purchased. This form is usually used in concert with
the _ItemQuantityProductsFormSet to allow selection of multiple
products.'''
CHOICE_FIELD = "choice"
QUANTITY_FIELD = "quantity"
@classmethod
def set_fields(cls, category, products):
choices = []
if not category.required:
choices.append((0, "---"))
for product in products:
choice_text = "%s -- $%d each" % (product.name, product.price)
choices.append((product.id, choice_text))
cls.base_fields[cls.CHOICE_FIELD] = forms.TypedChoiceField(
label=category.name,
widget=forms.Select,
choices=choices,
initial=0,
empty_value=0,
coerce=int,
)
cls.base_fields[cls.QUANTITY_FIELD] = forms.IntegerField(
label="Quantity", # TODO: internationalise
min_value=0,
max_value=500, # Issue #19. We should figure out real limit.
)
@classmethod
def initial_data(cls, product_quantities):
initial = {}
for product, quantity in product_quantities:
if quantity > 0:
initial[cls.CHOICE_FIELD] = product.id
initial[cls.QUANTITY_FIELD] = quantity
break
return initial
def product_quantities(self):
our_choice = self.cleaned_data[self.CHOICE_FIELD]
our_quantity = self.cleaned_data[self.QUANTITY_FIELD]
choices = self.fields[self.CHOICE_FIELD].choices
for choice_value, choice_display in choices:
if choice_value == 0:
continue
yield (
choice_value,
our_quantity if our_choice == choice_value else 0,
)
def add_product_error(self, product, error):
if self.CHOICE_FIELD not in self.cleaned_data:
return
if product.id == self.cleaned_data[self.CHOICE_FIELD]:
self.add_error(self.CHOICE_FIELD, error)
self.add_error(self.QUANTITY_FIELD, error)
class _ItemQuantityProductsFormSet(_HasProductsFields, forms.BaseFormSet):
required_css_class = 'label-required'
@classmethod
def set_fields(cls, category, products):
raise ValueError("set_fields must be called on the underlying Form")
@classmethod
def initial_data(cls, product_quantities):
''' Prepares initial data for an instance of this form.
product_quantities is a sequence of (product,quantity) tuples '''
f = [
{
_ItemQuantityProductsForm.CHOICE_FIELD: product.id,
_ItemQuantityProductsForm.QUANTITY_FIELD: quantity,
}
for product, quantity in product_quantities
if quantity > 0
]
return f
def product_quantities(self):
''' Yields a sequence of (product, quantity) tuples from the
cleaned form data. '''
products = set()
# Track everything so that we can yield some zeroes
all_products = set()
for form in self:
if form.empty_permitted and not form.cleaned_data:
# This is the magical empty form at the end of the list.
continue
for product, quantity in form.product_quantities():
all_products.add(product)
if quantity == 0:
continue
if product in products:
form.add_error(
_ItemQuantityProductsForm.CHOICE_FIELD,
"You may only choose each product type once.",
)
form.add_error(
_ItemQuantityProductsForm.QUANTITY_FIELD,
"You may only choose each product type once.",
)
products.add(product)
yield product, quantity
for product in (all_products - products):
yield product, 0
def add_product_error(self, product, error):
for form in self.forms:
form.add_product_error(product, error)
@property
def errors(self):
_errors = super(_ItemQuantityProductsFormSet, self).errors
if False not in [not form.errors for form in self.forms]:
return []
else:
return _errors
class VoucherForm(forms.Form):
required_css_class = 'label-required'
voucher = forms.CharField(
label="Voucher code",
help_text="If you have a voucher code, enter it here",
required=False,
)
def staff_products_form_factory(user):
''' Creates a StaffProductsForm that restricts the available products to
those that are available to a user. '''
products = inventory.Product.objects.all()
products = ProductController.available_products(user, products=products)
product_ids = [product.id for product in products]
product_set = inventory.Product.objects.filter(id__in=product_ids)
class StaffProductsForm(forms.Form):
''' Form for allowing staff to add an item to a user's cart. '''
product = forms.ModelChoiceField(
widget=forms.Select,
queryset=product_set,
)
quantity = forms.IntegerField(
min_value=0,
)
return StaffProductsForm
def staff_products_formset_factory(user):
''' Creates a formset of StaffProductsForm for the given user. '''
form_type = staff_products_form_factory(user)
return forms.formset_factory(form_type)
class InvoicesWithProductAndStatusForm(forms.Form):
required_css_class = 'label-required'
invoice = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
queryset=commerce.Invoice.objects.all(),
)
def __init__(self, *a, **k):
category = k.pop('category', None) or []
product = k.pop('product', None) or []
status = int(k.pop('status', None) or 0)
category = [int(i) for i in category]
product = [int(i) for i in product]
super(InvoicesWithProductAndStatusForm, self).__init__(*a, **k)
print(status)
qs = commerce.Invoice.objects.filter(
status=status or commerce.Invoice.STATUS_UNPAID,
).filter(
Q(lineitem__product__category__in=category) |
Q(lineitem__product__in=product)
)
# Uniqify
qs = commerce.Invoice.objects.filter(
id__in=qs,
)
qs = qs.select_related("user__attendee__attendeeprofilebase")
qs = qs.order_by("id")
self.fields['invoice'].queryset = qs
# self.fields['invoice'].initial = [i.id for i in qs] # UNDO THIS LATER
class InvoiceEmailForm(InvoicesWithProductAndStatusForm):
ACTION_PREVIEW = 1
ACTION_SEND = 2
ACTION_CHOICES = (
(ACTION_PREVIEW, "Preview"),
(ACTION_SEND, "Send emails"),
)
from_email = forms.CharField()
subject = forms.CharField()
body = forms.CharField(
widget=forms.Textarea,
)
action = forms.TypedChoiceField(
widget=forms.RadioSelect,
coerce=int,
choices=ACTION_CHOICES,
initial=ACTION_PREVIEW,
)

View file

@ -0,0 +1,383 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-04-25 08:30
from __future__ import unicode_literals
import datetime
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import registrasion.models.commerce
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Attendee',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_code', models.CharField(db_index=True, max_length=6, unique=True)),
('completed_registration', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='AttendeeProfileBase',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attendee', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Attendee')),
],
),
migrations.CreateModel(
name='Cart',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time_last_updated', models.DateTimeField(db_index=True)),
('reservation_duration', models.DurationField()),
('revision', models.PositiveIntegerField(default=1)),
('status', models.IntegerField(choices=[(1, 'Active'), (2, 'Paid'), (3, 'Released')], db_index=True, default=1)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=65, verbose_name='Name')),
('description', models.CharField(max_length=255, verbose_name='Description')),
('limit_per_user', models.PositiveIntegerField(blank=True, help_text='The total number of items from this category one attendee may purchase.', null=True, verbose_name='Limit per user')),
('required', models.BooleanField(help_text='If enabled, a user must select an item from this category.')),
('order', models.PositiveIntegerField(db_index=True, verbose_name=b'Display order')),
('render_type', models.IntegerField(choices=[(1, 'Radio button'), (2, 'Quantity boxes')], help_text='The registration form will render this category in this style.', verbose_name='Render type')),
],
options={
'ordering': ('order',),
'verbose_name': 'inventory - category',
'verbose_name_plural': 'inventory - categories',
},
),
migrations.CreateModel(
name='CreditNoteRefund',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(default=django.utils.timezone.now)),
('reference', models.CharField(max_length=255)),
],
bases=(registrasion.models.commerce.CleanOnSave, models.Model),
),
migrations.CreateModel(
name='DiscountBase',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(help_text='A description of this discount. This will be included on invoices where this discount is applied.', max_length=255, verbose_name='Description')),
],
),
migrations.CreateModel(
name='DiscountForCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('percentage', models.DecimalField(decimal_places=1, max_digits=4)),
('quantity', models.PositiveIntegerField()),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Category')),
],
),
migrations.CreateModel(
name='DiscountForProduct',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('percentage', models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True)),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('quantity', models.PositiveIntegerField()),
],
),
migrations.CreateModel(
name='DiscountItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField()),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Cart')),
],
options={
'ordering': ('product',),
},
),
migrations.CreateModel(
name='FlagBase',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255)),
('condition', models.IntegerField(choices=[(1, 'Disable if false'), (2, 'Enable if true')], default=2, 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.")),
],
),
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cart_revision', models.IntegerField(db_index=True, null=True)),
('status', models.IntegerField(choices=[(1, 'Unpaid'), (2, 'Paid'), (3, 'Refunded'), (4, 'VOID')], db_index=True)),
('recipient', models.CharField(max_length=1024)),
('issue_time', models.DateTimeField()),
('due_time', models.DateTimeField()),
('value', models.DecimalField(decimal_places=2, max_digits=8)),
('cart', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='registrasion.Cart')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='LineItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255)),
('quantity', models.PositiveIntegerField()),
('price', models.DecimalField(decimal_places=2, max_digits=8)),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Invoice')),
],
options={
'ordering': ('id',),
},
),
migrations.CreateModel(
name='PaymentBase',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(default=django.utils.timezone.now)),
('reference', models.CharField(max_length=255)),
('amount', models.DecimalField(decimal_places=2, max_digits=8)),
],
options={
'ordering': ('time',),
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=65, verbose_name='Name')),
('description', models.CharField(blank=True, max_length=255, null=True, verbose_name='Description')),
('price', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='Price')),
('limit_per_user', models.PositiveIntegerField(blank=True, null=True, verbose_name='Limit per user')),
('reservation_duration', models.DurationField(default=datetime.timedelta(0, 3600), help_text='The length of time this product will be reserved before it is released for someone else to purchase.', verbose_name='Reservation duration')),
('order', models.PositiveIntegerField(db_index=True, verbose_name=b'Display order')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Category', verbose_name='Product category')),
],
options={
'ordering': ('category__order', 'order'),
'verbose_name': 'inventory - product',
},
),
migrations.CreateModel(
name='ProductItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(db_index=True)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Product')),
],
options={
'ordering': ('product',),
},
),
migrations.CreateModel(
name='Voucher',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
],
),
migrations.CreateModel(
name='CategoryFlag',
fields=[
('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')),
('enabling_category', models.ForeignKey(help_text='If a product from this category is purchased, this condition is met.', on_delete=django.db.models.deletion.CASCADE, to='registrasion.Category')),
],
options={
'verbose_name': 'flag (dependency on product from category)',
'verbose_name_plural': 'flags (dependency on product from category)',
},
bases=('registrasion.flagbase',),
),
migrations.CreateModel(
name='CreditNote',
fields=[
('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')),
],
bases=('registrasion.paymentbase',),
),
migrations.CreateModel(
name='CreditNoteApplication',
fields=[
('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')),
('parent', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.CreditNote')),
],
bases=(registrasion.models.commerce.CleanOnSave, 'registrasion.paymentbase'),
),
migrations.CreateModel(
name='IncludedProductDiscount',
fields=[
('discountbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
('enabling_products', models.ManyToManyField(help_text='If one of these products are purchased, the discounts below will be enabled.', to='registrasion.Product', verbose_name='Including product')),
],
options={
'verbose_name': 'discount (product inclusions)',
'verbose_name_plural': 'discounts (product inclusions)',
},
bases=('registrasion.discountbase',),
),
migrations.CreateModel(
name='ManualCreditNoteRefund',
fields=[
('creditnoterefund_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund')),
('entered_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
bases=('registrasion.creditnoterefund',),
),
migrations.CreateModel(
name='ManualPayment',
fields=[
('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')),
('entered_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
bases=('registrasion.paymentbase',),
),
migrations.CreateModel(
name='ProductFlag',
fields=[
('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')),
('enabling_products', models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product')),
],
options={
'verbose_name': 'flag (dependency on product)',
'verbose_name_plural': 'flags (dependency on product)',
},
bases=('registrasion.flagbase',),
),
migrations.CreateModel(
name='TimeOrStockLimitDiscount',
fields=[
('discountbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
('start_time', models.DateTimeField(blank=True, help_text='This discount will only be available after this time.', null=True, verbose_name='Start time')),
('end_time', models.DateTimeField(blank=True, help_text='This discount will only be available before this time.', null=True, verbose_name='End time')),
('limit', models.PositiveIntegerField(blank=True, help_text='This discount may only be applied this many times.', null=True, verbose_name='Limit')),
],
options={
'verbose_name': 'discount (time/stock limit)',
'verbose_name_plural': 'discounts (time/stock limit)',
},
bases=('registrasion.discountbase',),
),
migrations.CreateModel(
name='TimeOrStockLimitFlag',
fields=[
('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')),
('start_time', models.DateTimeField(blank=True, help_text='Products included in this condition will only be available after this time.', null=True)),
('end_time', models.DateTimeField(blank=True, help_text='Products included in this condition will only be available before this time.', null=True)),
('limit', models.PositiveIntegerField(blank=True, help_text='The number of items under this grouping that can be purchased.', null=True)),
],
options={
'verbose_name': 'flag (time/stock limit)',
'verbose_name_plural': 'flags (time/stock limit)',
},
bases=('registrasion.flagbase',),
),
migrations.CreateModel(
name='VoucherDiscount',
fields=[
('discountbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
('voucher', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Voucher', verbose_name='Voucher')),
],
options={
'verbose_name': 'discount (enabled by voucher)',
'verbose_name_plural': 'discounts (enabled by voucher)',
},
bases=('registrasion.discountbase',),
),
migrations.CreateModel(
name='VoucherFlag',
fields=[
('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')),
('voucher', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Voucher')),
],
options={
'verbose_name': 'flag (dependency on voucher)',
'verbose_name_plural': 'flags (dependency on voucher)',
},
bases=('registrasion.flagbase',),
),
migrations.AddField(
model_name='paymentbase',
name='invoice',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Invoice'),
),
migrations.AddField(
model_name='lineitem',
name='product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='registrasion.Product'),
),
migrations.AddField(
model_name='flagbase',
name='categories',
field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", related_name='flagbase_set', to='registrasion.Category'),
),
migrations.AddField(
model_name='flagbase',
name='products',
field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", related_name='flagbase_set', to='registrasion.Product'),
),
migrations.AddField(
model_name='discountitem',
name='discount',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.DiscountBase'),
),
migrations.AddField(
model_name='discountitem',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Product'),
),
migrations.AddField(
model_name='discountforproduct',
name='discount',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.DiscountBase'),
),
migrations.AddField(
model_name='discountforproduct',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Product'),
),
migrations.AddField(
model_name='discountforcategory',
name='discount',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.DiscountBase'),
),
migrations.AddField(
model_name='cart',
name='vouchers',
field=models.ManyToManyField(blank=True, to='registrasion.Voucher'),
),
migrations.AddField(
model_name='attendee',
name='guided_categories_complete',
field=models.ManyToManyField(to='registrasion.Category'),
),
migrations.AddField(
model_name='attendee',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='creditnoterefund',
name='parent',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.CreditNote'),
),
migrations.AlterIndexTogether(
name='cart',
index_together=set([('status', 'user'), ('status', 'time_last_updated')]),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-08-22 00:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='category',
name='render_type',
field=models.IntegerField(choices=[(1, 'Radio button'), (2, 'Quantity boxes'), (3, 'Product selector and quantity box')], help_text='The registration form will render this category in this style.', verbose_name='Render type'),
),
]

View file

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-09-04 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('symposion_proposals', '0001_initial'),
('registrasion', '0002_auto_20160822_0034'),
]
operations = [
migrations.CreateModel(
name='SpeakerDiscount',
fields=[
('discountbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
('is_presenter', models.BooleanField(help_text='This condition is met if the user is the primary presenter of a presentation.')),
('is_copresenter', models.BooleanField(help_text='This condition is met if the user is a copresenter of a presentation.')),
('proposal_kind', models.ManyToManyField(help_text='The types of proposals that these users may be presenters of.', to='symposion_proposals.ProposalKind')),
],
options={
'verbose_name': 'discount (speaker)',
'verbose_name_plural': 'discounts (speaker)',
},
bases=('registrasion.discountbase', models.Model),
),
migrations.CreateModel(
name='SpeakerFlag',
fields=[
('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')),
('is_presenter', models.BooleanField(help_text='This condition is met if the user is the primary presenter of a presentation.')),
('is_copresenter', models.BooleanField(help_text='This condition is met if the user is a copresenter of a presentation.')),
('proposal_kind', models.ManyToManyField(help_text='The types of proposals that these users may be presenters of.', to='symposion_proposals.ProposalKind')),
],
options={
'verbose_name': 'flag (speaker)',
'verbose_name_plural': 'flags (speaker)',
},
bases=('registrasion.flagbase', models.Model),
),
migrations.AlterField(
model_name='includedproductdiscount',
name='enabling_products',
field=models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product', verbose_name='Including product'),
),
migrations.AlterField(
model_name='productflag',
name='enabling_products',
field=models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product', verbose_name='Including product'),
),
migrations.AlterField(
model_name='timeorstocklimitdiscount',
name='end_time',
field=models.DateTimeField(blank=True, help_text='When the condition should stop being true.', null=True, verbose_name='End time'),
),
migrations.AlterField(
model_name='timeorstocklimitdiscount',
name='limit',
field=models.PositiveIntegerField(blank=True, help_text='How many times this condition may be applied for all users.', null=True, verbose_name='Limit'),
),
migrations.AlterField(
model_name='timeorstocklimitdiscount',
name='start_time',
field=models.DateTimeField(blank=True, help_text='When the condition should start being true', null=True, verbose_name='Start time'),
),
migrations.AlterField(
model_name='timeorstocklimitflag',
name='end_time',
field=models.DateTimeField(blank=True, help_text='When the condition should stop being true.', null=True, verbose_name='End time'),
),
migrations.AlterField(
model_name='timeorstocklimitflag',
name='limit',
field=models.PositiveIntegerField(blank=True, help_text='How many times this condition may be applied for all users.', null=True, verbose_name='Limit'),
),
migrations.AlterField(
model_name='timeorstocklimitflag',
name='start_time',
field=models.DateTimeField(blank=True, help_text='When the condition should start being true', null=True, verbose_name='Start time'),
),
migrations.AlterField(
model_name='voucherflag',
name='voucher',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Voucher', verbose_name='Voucher'),
),
]

View file

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-09-04 23:59
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('auth', '0007_alter_validators_add_error_messages'),
('registrasion', '0003_auto_20160904_0235'),
]
operations = [
migrations.CreateModel(
name='GroupMemberDiscount',
fields=[
('discountbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')),
('group', models.ManyToManyField(help_text='The groups a user needs to be a member of for thiscondition to be met.', to='auth.Group')),
],
options={
'verbose_name': 'discount (group member)',
'verbose_name_plural': 'discounts (group member)',
},
bases=('registrasion.discountbase', models.Model),
),
migrations.CreateModel(
name='GroupMemberFlag',
fields=[
('flagbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.FlagBase')),
('group', models.ManyToManyField(help_text='The groups a user needs to be a member of for thiscondition to be met.', to='auth.Group')),
],
options={
'verbose_name': 'flag (group member)',
'verbose_name_plural': 'flags (group member)',
},
bases=('registrasion.flagbase', models.Model),
),
]

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-09-05 09:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registrasion', '0004_groupmemberdiscount_groupmemberflag'),
]
operations = [
migrations.AlterField(
model_name='category',
name='description',
field=models.TextField(verbose_name='Description'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='product',
name='description',
field=models.TextField(blank=True, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='product',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
]

View file

View file

@ -0,0 +1,4 @@
from registrasion.models.commerce import * # NOQA
from registrasion.models.conditions import * # NOQA
from registrasion.models.inventory import * # NOQA
from registrasion.models.people import * # NOQA

406
vendor/registrasion/models/commerce.py vendored Normal file
View file

@ -0,0 +1,406 @@
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, Sum
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 = [
("status", "time_last_updated"),
("status", "user"),
]
def __str__(self):
return "%d rev #%d" % (self.id, self.revision)
STATUS_ACTIVE = 1
STATUS_PAID = 2
STATUS_RELEASED = 3
STATUS_TYPES = [
(STATUS_ACTIVE, _("Active")),
(STATUS_PAID, _("Paid")),
(STATUS_RELEASED, _("Released")),
]
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)
status = models.IntegerField(
choices=STATUS_TYPES,
db_index=True,
default=STATUS_ACTIVE,
)
@classmethod
def reserved_carts(cls):
''' Gets all carts that are 'reserved' '''
return Cart.objects.filter(
(Q(status=Cart.STATUS_ACTIVE) &
Q(time_last_updated__gt=(
timezone.now()-F('reservation_duration')
))) |
Q(status=Cart.STATUS_PAID)
)
@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.
Attributes:
user (User): The owner of this invoice.
cart (commerce.Cart): The cart that was used to generate this invoice.
cart_revision (int): The value of ``cart.revision`` at the time of this
invoice's creation. If a change is made to the underlying cart,
this invoice is automatically void -- this change is detected
when ``cart.revision != cart_revision``.
status (int): One of ``STATUS_UNPAID``, ``STATUS_PAID``,
``STATUS_REFUNDED``, OR ``STATUS_VOID``. Call
``get_status_display`` for a human-readable representation.
recipient (str): A rendered representation of the invoice's recipient.
issue_time (datetime): When the invoice was issued.
due_time (datetime): When the invoice is due.
value (Decimal): The total value of the line items attached to the
invoice.
lineitem_set (Queryset[LineItem]): The set of line items that comprise
this invoice.
paymentbase_set(Queryset[PaymentBase]): The set of PaymentBase objects
that have been applied to this invoice.
'''
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 (to: %s, due: %s, value: %s)" % (
self.id, self.user.email, self.due_time, self.value
)
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
def total_payments(self):
''' Returns the total amount paid towards this invoice. '''
payments = PaymentBase.objects.filter(invoice=self)
total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0
return total_paid
def balance_due(self):
''' Returns the total balance remaining towards this invoice. '''
return self.value - self.total_payments()
# 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.
Attributes:
invoice (commerce.Invoice): The invoice to which this LineItem is
attached.
description (str): A human-readable description of the line item.
quantity (int): The quantity of items represented by this line.
price (Decimal): The per-unit price for this line item.
product (Optional[inventory.Product]): The product that this LineItem
applies to. This allows you to do reports on sales and applied
discounts to individual products.
'''
class Meta:
app_label = "registrasion"
ordering = ("id", )
def __str__(self):
return "Line: %s * %d @ %s" % (
self.description, self.quantity, self.price)
@property
def total_price(self):
''' price * quantity '''
return self.price * self.quantity
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.
Attributes:
invoice (commerce.Invoice): The invoice that this payment applies to.
time (datetime): The time that this payment was generated. Note that
this will default to the current time when the model is created.
reference (str): A human-readable reference for the payment, this will
be displayed alongside the invoice.
amount (Decimal): The amount the payment is for.
'''
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"
entered_by = models.ForeignKey(User)
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,
)
@classmethod
def refunded(cls):
return cls.objects.exclude(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.
Attributes:
parent (commerce.CreditNote): The CreditNote that this refund
corresponds to.
time (datetime): The time that this refund was generated.
reference (str): A human-readable reference for the refund, this should
allow the user to identify the refund in their records.
'''
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)

559
vendor/registrasion/models/conditions.py vendored Normal file
View file

@ -0,0 +1,559 @@
import itertools
from . import inventory
from django.contrib.auth.models import Group
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
from symposion import proposals
# Condition Types
class TimeOrStockLimitCondition(models.Model):
''' Attributes for a condition that is limited by timespan or a count of
purchased or reserved items.
Attributes:
start_time (Optional[datetime]): When the condition should start being
true.
end_time (Optional[datetime]): When the condition should stop being
true.
limit (Optional[int]): How many items may fall under the condition
the condition until it stops being false -- for all users.
'''
class Meta:
abstract = True
start_time = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Start time"),
help_text=_("When the condition should start being true"),
)
end_time = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("End time"),
help_text=_("When the condition should stop being true."),
)
limit = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_("Limit"),
help_text=_(
"How many times this condition may be applied for all users."
),
)
class VoucherCondition(models.Model):
''' A condition is met when a voucher code is in the current cart. '''
class Meta:
abstract = True
voucher = models.OneToOneField(
inventory.Voucher,
on_delete=models.CASCADE,
verbose_name=_("Voucher"),
db_index=True,
)
class IncludedProductCondition(models.Model):
class Meta:
abstract = True
enabling_products = models.ManyToManyField(
inventory.Product,
verbose_name=_("Including product"),
help_text=_("If one of these products are purchased, this condition "
"is met."),
)
class SpeakerCondition(models.Model):
''' Conditions that are met if a user is a presenter, or copresenter,
of a specific kind of presentation. '''
class Meta:
abstract = True
is_presenter = models.BooleanField(
blank=True,
help_text=_("This condition is met if the user is the primary "
"presenter of a presentation."),
)
is_copresenter = models.BooleanField(
blank=True,
help_text=_("This condition is met if the user is a copresenter of a "
"presentation."),
)
proposal_kind = models.ManyToManyField(
proposals.models.ProposalKind,
help_text=_("The types of proposals that these users may be "
"presenters of."),
)
class GroupMemberCondition(models.Model):
''' Conditions that are met if a user is a member (not declined or
rejected) of a specific django auth group. '''
class Meta:
abstract = True
group = models.ManyToManyField(
Group,
help_text=_("The groups a user needs to be a member of for this"
"condition to be met."),
)
# Discounts
@python_2_unicode_compatible
class DiscountBase(models.Model):
''' Base class for discounts. This class is subclassed with special
attributes which are used to determine whether or not the given discount
is available to be added to the current cart.
Attributes:
description (str): Display text that appears on the attendee's Invoice
when the discount is applied to a Product on that invoice.
'''
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.
Attributes:
product (inventory.Product): The product that this discount line will
apply to.
percentage (Decimal): The percentage discount that will be *taken off*
this product if this discount applies.
price (Decimal): The currency value that will be *taken off* this
product if this discount applies.
quantity (int): The number of times that each user may apply this
discount line. This applies across every valid Invoice that
the user has.
'''
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.
Attributes:
category (inventory.Category): The category whose products that this
discount line will apply to.
percentage (Decimal): The percentage discount that will be *taken off*
a product if this discount applies.
quantity (int): The number of times that each user may apply this
discount line. This applies across every valid Invoice that the
user has.
'''
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(TimeOrStockLimitCondition, DiscountBase):
''' Discounts that are generally available, but are limited by timespan or
usage count. This is for e.g. Early Bird discounts.
Attributes:
start_time (Optional[datetime]): When the discount should start being
offered.
end_time (Optional[datetime]): When the discount should stop being
offered.
limit (Optional[int]): How many times the discount is allowed to be
applied -- to all users.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("discount (time/stock limit)")
verbose_name_plural = _("discounts (time/stock limit)")
class VoucherDiscount(VoucherCondition, DiscountBase):
''' Discounts that are enabled when a voucher code is in the current
cart. These are normally configured in the Admin page at the same time as
creating a Voucher object.
Attributes:
voucher (inventory.Voucher): The voucher that enables this discount.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("discount (enabled by voucher)")
verbose_name_plural = _("discounts (enabled by voucher)")
class IncludedProductDiscount(IncludedProductCondition, DiscountBase):
''' Discounts that are enabled because another product has been purchased.
e.g. A conference ticket includes a free t-shirt.
Attributes:
enabling_products ([inventory.Product, ...]): The products that enable
the discount.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("discount (product inclusions)")
verbose_name_plural = _("discounts (product inclusions)")
class SpeakerDiscount(SpeakerCondition, DiscountBase):
''' Discounts that are enabled because the user is a presenter or
co-presenter of a kind of presentation.
Attributes:
is_presenter (bool): The condition should be met if the user is a
presenter of a presentation.
is_copresenter (bool): The condition should be met if the user is a
copresenter of a presentation.
proposal_kind ([symposion.proposals.models.ProposalKind, ...]): The
kinds of proposals that the user may be a presenter or
copresenter of for this condition to be met.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("discount (speaker)")
verbose_name_plural = _("discounts (speaker)")
class GroupMemberDiscount(GroupMemberCondition, DiscountBase):
''' Discounts that are enabled because the user is a member of a specific
django auth Group.
Attributes:
group ([Group, ...]): The condition should be met if the user is a
member of one of these groups.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("discount (group member)")
verbose_name_plural = _("discounts (group member)")
# Flags
@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.
Attributes:
description (str): A human-readable description that is used to
identify the flag to staff in the admin interface. It's not seen
anywhere else in Registrasion.
condition (int): This determines the effect of this flag's condition
being met. There are two types of condition:
``ENABLE_IF_TRUE`` conditions switch on the products and
categories included under this flag if *any* such condition is met.
``DISABLE_IF_FALSE`` conditions *switch off* the products and
categories included under this flag is any such condition
*is not* met.
If you have both types of conditions attached to a Product, every
``DISABLE_IF_FALSE`` condition must be met, along with one
``ENABLE_IF_TRUE`` condition.
products ([inventory.Product, ...]):
The Products affected by this flag.
categories ([inventory.Category, ...]):
The Categories whose Products are affected by this flag.
'''
objects = InheritanceManager()
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 TimeOrStockLimitFlag(TimeOrStockLimitCondition, FlagBase):
''' Product groupings that can be used to enable a product during a
specific date range, or when fewer than a limit of products have been
sold.
Attributes:
start_time (Optional[datetime]): This condition is only met after this
time.
end_time (Optional[datetime]): This condition is only met before this
time.
limit (Optional[int]): The number of products that *all users* can
purchase under this limit, regardless of their per-user limits.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("flag (time/stock limit)")
verbose_name_plural = _("flags (time/stock limit)")
@python_2_unicode_compatible
class ProductFlag(IncludedProductCondition, FlagBase):
''' The condition is met because a specific product is purchased.
Attributes:
enabling_products ([inventory.Product, ...]): The products that cause
this condition to be met.
'''
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())
@python_2_unicode_compatible
class CategoryFlag(FlagBase):
''' The condition is met because a product in a particular product is
purchased.
Attributes:
enabling_category (inventory.Category): The category that causes this
condition to be met.
'''
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(VoucherCondition, FlagBase):
''' 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
class SpeakerFlag(SpeakerCondition, FlagBase):
''' Conditions that are enabled because the user is a presenter or
co-presenter of a kind of presentation.
Attributes:
is_presenter (bool): The condition should be met if the user is a
presenter of a presentation.
is_copresenter (bool): The condition should be met if the user is a
copresenter of a presentation.
proposal_kind ([symposion.proposals.models.ProposalKind, ...]): The
kinds of proposals that the user may be a presenter or
copresenter of for this condition to be met.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("flag (speaker)")
verbose_name_plural = _("flags (speaker)")
class GroupMemberFlag(GroupMemberCondition, FlagBase):
''' Flag whose conditions are metbecause the user is a member of a specific
django auth Group.
Attributes:
group ([Group, ...]): The condition should be met if the user is a
member of one of these groups.
'''
class Meta:
app_label = "registrasion"
verbose_name = _("flag (group member)")
verbose_name_plural = _("flags (group member)")

219
vendor/registrasion/models/inventory.py vendored Normal file
View file

@ -0,0 +1,219 @@
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.
``RENDER_TYPE_ITEM_QUANTITY`` shows a select menu to select a
Product type, and an input field, where the user can specify the
quantity for that Product type. This is useful for categories that
have a lot of options, from which the user is not going to select
all of the options.
limit_per_user (Optional[int]): This restricts the number of items
from this Category that each attendee may claim. This extends
across multiple Invoices.
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
RENDER_TYPE_ITEM_QUANTITY = 3
CATEGORY_RENDER_TYPES = [
(RENDER_TYPE_RADIO, _("Radio button")),
(RENDER_TYPE_QUANTITY, _("Quantity boxes")),
(RENDER_TYPE_ITEM_QUANTITY, _("Product selector and quantity box")),
]
name = models.CharField(
max_length=255,
verbose_name=_("Name"),
)
description = models.TextField(
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):
''' Products make up the conference inventory.
Attributes:
name (str): The display name for the product.
description (str): Some descriptive text that will help the user to
understand the product when they're at the registration form.
category (Category): The Category that this product will be grouped
under.
price (Decimal): The price that 1 unit of this product will sell for.
Note that this should be the full price, before any discounts are
applied.
limit_per_user (Optional[int]): This restricts the number of this
Product that each attendee may claim. This extends across multiple
Invoices.
reservation_duration (datetime): When a Product is added to the user's
tentative registration, it is marked as unavailable for a period of
time. This allows the user to build up their registration and then
pay for it. This reservation duration determines how long an item
should be allowed to be reserved whilst being unpaid.
order (int): An ascending order for displaying the Products
within each Category.
'''
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=255,
verbose_name=_("Name"),
)
description = models.TextField(
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):
''' Vouchers are used to enable Discounts or Flags for the people who hold
the voucher code.
Attributes:
recipient (str): A display string used to identify the holder of the
voucher on the admin page.
code (str): The string that is used to prove that the attendee holds
this voucher.
limit (int): The number of attendees who are permitted to hold this
voucher.
'''
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"))

95
vendor/registrasion/models/people.py vendored Normal file
View file

@ -0,0 +1,95 @@
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):
'''
Returns:
The name of a field that stores the attendee's name. This is used
to pre-fill the attendee's name from their Speaker profile, if they
have one.
'''
return None
def attendee_name(self):
if type(self) == AttendeeProfileBase:
real = AttendeeProfileBase.objects.get_subclass(id=self.id)
else:
real = self
return getattr(real, real.name_field())
def invoice_recipient(self):
'''
Returns:
A representation of this attendee profile for the purpose
of rendering to an invoice. This should include any information
that you'd usually include on 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)

View file

97
vendor/registrasion/reporting/forms.py vendored Normal file
View file

@ -0,0 +1,97 @@
from registrasion.models import conditions
from registrasion.models import inventory
from symposion.proposals import models as proposals_models
from django import forms
# Reporting forms.
def mix_form(*a):
''' Creates a new form class out of all the supplied forms '''
bases = tuple(a)
return type("MixForm", bases, {})
class DiscountForm(forms.Form):
required_css_class = 'label-required'
discount = forms.ModelMultipleChoiceField(
queryset=conditions.DiscountBase.objects.all(),
required=False,
)
class ProductAndCategoryForm(forms.Form):
required_css_class = 'label-required'
product = forms.ModelMultipleChoiceField(
queryset=inventory.Product.objects.select_related("category"),
required=False,
)
category = forms.ModelMultipleChoiceField(
queryset=inventory.Category.objects.all(),
required=False,
)
class UserIdForm(forms.Form):
required_css_class = 'label-required'
user = forms.IntegerField(
label="User ID",
required=False,
)
class ProposalKindForm(forms.Form):
required_css_class = 'label-required'
kind = forms.ModelMultipleChoiceField(
queryset=proposals_models.ProposalKind.objects.all(),
required=False,
)
class GroupByForm(forms.Form):
required_css_class = 'label-required'
GROUP_BY_CATEGORY = "category"
GROUP_BY_PRODUCT = "product"
choices = (
(GROUP_BY_CATEGORY, "Category"),
(GROUP_BY_PRODUCT, "Product"),
)
group_by = forms.ChoiceField(
label="Group by",
choices=choices,
required=False,
)
def model_fields_form_factory(model):
''' Creates a form for specifying fields from a model to display. '''
fields = model._meta.get_fields()
choices = []
for field in fields:
if hasattr(field, "verbose_name"):
choices.append((field.name, field.verbose_name))
class ModelFieldsForm(forms.Form):
fields = forms.MultipleChoiceField(
choices=choices,
required=False,
)
return ModelFieldsForm

354
vendor/registrasion/reporting/reports.py vendored Normal file
View file

@ -0,0 +1,354 @@
import csv
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render
from django.core.urlresolvers import reverse
from django.http import HttpResponse
from functools import wraps
from registrasion import views
''' A list of report views objects that can be used to load a list of
reports. '''
_all_report_views = []
class Report(object):
def __init__(self):
pass
def title():
raise NotImplementedError
def headings():
''' Returns the headings for the report. '''
raise NotImplementedError
def rows(content_type):
'''
Arguments:
content_type (str): The content-type for the output format of this
report.
Returns:
An iterator, which yields each row of the data. Each row should
be an iterable containing the cells, rendered appropriately for
content_type.
'''
raise NotImplementedError
def _linked_text(self, content_type, address, text):
'''
Returns:
an HTML linked version of text, if the content_type for this report
is HTMLish, otherwise, the text.
'''
if content_type == "text/html":
return Report._html_link(address, text)
else:
return text
@staticmethod
def _html_link(address, text):
return '<a href="%s">%s</a>' % (address, text)
class _ReportTemplateWrapper(object):
''' Used internally to pass `Report` objects to templates. They effectively
are used to specify the content_type for a report. '''
def __init__(self, content_type, report):
self.content_type = content_type
self.report = report
def title(self):
return self.report.title()
def headings(self):
return self.report.headings()
def rows(self):
return self.report.rows(self.content_type)
class BasicReport(Report):
def __init__(self, title, headings, link_view=None):
super(BasicReport, self).__init__()
self._title = title
self._headings = headings
self._link_view = link_view
def title(self):
''' Returns the title for this report. '''
return self._title
def headings(self):
''' Returns the headings for the table. '''
return self._headings
def cell_text(self, content_type, index, text):
if index > 0 or not self._link_view:
return text
else:
address = self.get_link(text)
return self._linked_text(content_type, address, text)
def get_link(self, argument):
return reverse(self._link_view, args=[argument])
class ListReport(BasicReport):
def __init__(self, title, headings, data, link_view=None):
super(ListReport, self).__init__(title, headings, link_view=link_view)
self._data = data
def rows(self, content_type):
''' Returns the data rows for the table. '''
for row in self._data:
yield [
self.cell_text(content_type, i, cell)
for i, cell in enumerate(row)
]
class QuerysetReport(BasicReport):
def __init__(self, title, attributes, queryset, headings=None,
link_view=None):
super(QuerysetReport, self).__init__(
title, headings, link_view=link_view
)
self._attributes = attributes
self._queryset = queryset
def headings(self):
if self._headings is not None:
return self._headings
return [
" ".join(i.split("_")).capitalize() for i in self._attributes
]
def rows(self, content_type):
def rgetattr(item, attr):
for i in attr.split("__"):
item = getattr(item, i)
if callable(item):
try:
return item()
except TypeError:
pass
return item
for row in self._queryset:
yield [
self.cell_text(content_type, i, rgetattr(row, attribute))
for i, attribute in enumerate(self._attributes)
]
class Links(Report):
def __init__(self, title, links):
'''
Arguments:
links ([tuple, ...]): a list of 2-tuples:
(url, link_text)
'''
self._title = title
self._links = links
def title(self):
return self._title
def headings(self):
return []
def rows(self, content_type):
print(self._links)
for url, link_text in self._links:
yield [
self._linked_text(content_type, url, link_text)
]
def report_view(title, form_type=None):
''' Decorator that converts a report view function into something that
displays a Report.
Arguments:
title (str):
The title of the report.
form_type (Optional[forms.Form]):
A form class that can make this report display things. If not
supplied, no form will be displayed.
'''
# Create & return view
def _report(view):
report_view = ReportView(view, title, form_type)
report_view = user_passes_test(views._staff_only)(report_view)
report_view = wraps(view)(report_view)
# Add this report to the list of reports.
_all_report_views.append(report_view)
return report_view
return _report
class ReportView(object):
''' View objects that can render report data into HTML or CSV. '''
def __init__(self, inner_view, title, form_type):
'''
Arguments:
inner_view: Callable that returns either a Report or a sequence of
Report objects.
title: The title that appears at the top of all of the reports.
form_type: A Form class that can be used to query the report.
'''
# Consolidate form_type so it has content type and section
self.inner_view = inner_view
self.title = title
self.form_type = form_type
def __call__(self, request, *a, **k):
data = ReportViewRequestData(self, request, *a, **k)
return self.render(data)
def get_form(self, request):
''' Creates an instance of self.form_type using request.GET '''
# Create a form instance
if self.form_type is not None:
form = self.form_type(request.GET)
# Pre-validate it
form.is_valid()
else:
form = None
return form
@classmethod
def wrap_reports(cls, reports, content_type):
''' Wraps the reports in a _ReportTemplateWrapper for the given
content_type -- this allows data to be returned as HTML links, for
instance. '''
reports = [
_ReportTemplateWrapper(content_type, report)
for report in reports
]
return reports
def render(self, data):
''' Renders the reports based on data.content_type's value.
Arguments:
data (ReportViewRequestData): The report data. data.content_type
is used to determine how the reports are rendered.
Returns:
HTTPResponse: The rendered version of the report.
'''
renderers = {
"text/csv": self._render_as_csv,
"text/html": self._render_as_html,
None: self._render_as_html,
}
render = renderers[data.content_type]
return render(data)
def _render_as_html(self, data):
ctx = {
"title": self.title,
"form": data.form,
"reports": data.reports,
}
return render(data.request, "registrasion/report.html", ctx)
def _render_as_csv(self, data):
report = data.reports[data.section]
# Create the HttpResponse object with the appropriate CSV header.
response = HttpResponse(content_type='text/csv')
writer = csv.writer(response)
writer.writerow(report.headings())
for row in report.rows():
writer.writerow(row)
return response
class ReportViewRequestData(object):
'''
Attributes:
form (Form): form based on request
reports ([Report, ...]): The reports rendered from the request
Arguments:
report_view (ReportView): The ReportView to call back to.
request (HTTPRequest): A django HTTP request
'''
def __init__(self, report_view, request, *a, **k):
self.report_view = report_view
self.request = request
# Calculate other data
self.form = report_view.get_form(request)
# Content type and section come from request.GET
self.content_type = request.GET.get("content_type")
self.section = request.GET.get("section")
self.section = int(self.section) if self.section else None
if self.content_type is None:
self.content_type = "text/html"
# Reports come from calling the inner view
reports = report_view.inner_view(request, self.form, *a, **k)
# Normalise to a list
if isinstance(reports, Report):
reports = [reports]
# Wrap them in appropriate format
reports = ReportView.wrap_reports(reports, self.content_type)
self.reports = reports
def get_all_reports():
''' Returns all the views that have been registered with @report '''
return list(_all_report_views)

867
vendor/registrasion/reporting/views.py vendored Normal file
View file

@ -0,0 +1,867 @@
from registrasion.reporting import forms
import collections
import datetime
import itertools
from django.conf import settings
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import models
from django.db.models import F, Q
from django.db.models import Count, Max, Sum
from django.db.models import Case, When, Value
from django.db.models.fields.related import RelatedField
from django.shortcuts import render
from registrasion.controllers.cart import CartController
from registrasion.controllers.item import ItemController
from registrasion.models import commerce
from registrasion.models import people
from registrasion import util
from registrasion import views
from symposion.schedule import models as schedule_models
from registrasion.reporting.reports import get_all_reports
from registrasion.reporting.reports import Links
from registrasion.reporting.reports import ListReport
from registrasion.reporting.reports import QuerysetReport
from registrasion.reporting.reports import report_view
def CURRENCY():
return models.DecimalField(decimal_places=2)
AttendeeProfile = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL)
@user_passes_test(views._staff_only)
def reports_list(request):
''' Lists all of the reports currently available. '''
reports = []
for report in get_all_reports():
reports.append({
"name": report.__name__,
"url": reverse(report),
"description": report.__doc__,
})
reports.sort(key=lambda report: report["name"])
ctx = {
"reports": reports,
}
return render(request, "registrasion/reports_list.html", ctx)
# Report functions
@report_view("Reconcilitation")
def reconciliation(request, form):
''' Shows the summary of sales, and the full history of payments and
refunds into the system. '''
return [
sales_payment_summary(),
items_sold(),
payments(),
credit_note_refunds(),
]
def items_sold():
''' Summarises the items sold and discounts granted for a given set of
products, or products from categories. '''
data = None
headings = None
line_items = commerce.LineItem.objects.filter(
invoice__status=commerce.Invoice.STATUS_PAID,
).select_related("invoice")
line_items = line_items.order_by(
# sqlite requires an order_by for .values() to work
"-price", "description",
).values(
"price", "description",
).annotate(
total_quantity=Sum("quantity"),
)
print(line_items)
headings = ["Description", "Quantity", "Price", "Total"]
data = []
total_income = 0
for line in line_items:
cost = line["total_quantity"] * line["price"]
data.append([
line["description"], line["total_quantity"],
line["price"], cost,
])
total_income += cost
data.append([
"(TOTAL)", "--", "--", total_income,
])
return ListReport("Items sold", headings, data)
def sales_payment_summary():
''' Summarises paid items and payments. '''
def value_or_zero(aggregate, key):
return aggregate[key] or 0
def sum_amount(payment_set):
a = payment_set.values("amount").aggregate(total=Sum("amount"))
return value_or_zero(a, "total")
headings = ["Category", "Total"]
data = []
# Summarise all sales made (= income.)
sales = commerce.LineItem.objects.filter(
invoice__status=commerce.Invoice.STATUS_PAID,
).values(
"price", "quantity"
).aggregate(
total=Sum(F("price") * F("quantity"), output_field=CURRENCY()),
)
sales = value_or_zero(sales, "total")
all_payments = sum_amount(commerce.PaymentBase.objects.all())
# Manual payments
# Credit notes generated (total)
# Payments made by credit note
# Claimed credit notes
all_credit_notes = 0 - sum_amount(commerce.CreditNote.objects.all())
unclaimed_credit_notes = 0 - sum_amount(commerce.CreditNote.unclaimed())
claimed_credit_notes = sum_amount(
commerce.CreditNoteApplication.objects.all()
)
refunded_credit_notes = 0 - sum_amount(commerce.CreditNote.refunded())
data.append(["Items on paid invoices", sales])
data.append(["All payments", all_payments])
data.append(["Sales - Payments ", sales - all_payments])
data.append(["All credit notes", all_credit_notes])
data.append(["Credit notes paid on invoices", claimed_credit_notes])
data.append(["Credit notes refunded", refunded_credit_notes])
data.append(["Unclaimed credit notes", unclaimed_credit_notes])
data.append([
"Credit notes - (claimed credit notes + unclaimed credit notes)",
all_credit_notes - claimed_credit_notes -
refunded_credit_notes - unclaimed_credit_notes
])
return ListReport("Sales and Payments Summary", headings, data)
def payments():
''' Shows the history of payments into the system '''
payments = commerce.PaymentBase.objects.all()
return QuerysetReport(
"Payments",
["invoice__id", "id", "reference", "amount"],
payments,
link_view=views.invoice,
)
def credit_note_refunds():
''' Shows all of the credit notes that have been generated. '''
notes_refunded = commerce.CreditNote.refunded()
return QuerysetReport(
"Credit note refunds",
["id", "creditnoterefund__reference", "amount"],
notes_refunded,
link_view=views.credit_note,
)
def group_by_cart_status(queryset, order, values):
queryset = queryset.annotate(
is_reserved=Case(
When(cart__in=commerce.Cart.reserved_carts(), then=Value(True)),
default=Value(False),
output_field=models.BooleanField(),
),
)
values = queryset.order_by(*order).values(*values)
values = values.annotate(
total_paid=Sum(Case(
When(
cart__status=commerce.Cart.STATUS_PAID,
then=F("quantity"),
),
default=Value(0),
)),
total_refunded=Sum(Case(
When(
cart__status=commerce.Cart.STATUS_RELEASED,
then=F("quantity"),
),
default=Value(0),
)),
total_unreserved=Sum(Case(
When(
(
Q(cart__status=commerce.Cart.STATUS_ACTIVE) &
Q(is_reserved=False)
),
then=F("quantity"),
),
default=Value(0),
)),
total_reserved=Sum(Case(
When(
(
Q(cart__status=commerce.Cart.STATUS_ACTIVE) &
Q(is_reserved=True)
),
then=F("quantity"),
),
default=Value(0),
)),
)
return values
@report_view("Product status", form_type=forms.ProductAndCategoryForm)
def product_status(request, form):
''' Summarises the inventory status of the given items, grouping by
invoice status. '''
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
items = commerce.ProductItem.objects.filter(
Q(product__in=products) | Q(product__category__in=categories),
).select_related("cart", "product")
items = group_by_cart_status(
items,
["product__category__order", "product__order"],
["product", "product__category__name", "product__name"],
)
headings = [
"Product", "Paid", "Reserved", "Unreserved", "Refunded",
]
data = []
for item in items:
data.append([
"%s - %s" % (
item["product__category__name"], item["product__name"]
),
item["total_paid"],
item["total_reserved"],
item["total_unreserved"],
item["total_refunded"],
])
return ListReport("Inventory", headings, data)
@report_view("Product status", form_type=forms.DiscountForm)
def discount_status(request, form):
''' Summarises the usage of a given discount. '''
discounts = form.cleaned_data["discount"]
items = commerce.DiscountItem.objects.filter(
Q(discount__in=discounts),
).select_related("cart", "product", "product__category")
items = group_by_cart_status(
items,
["discount"],
["discount", "discount__description"],
)
headings = [
"Discount", "Paid", "Reserved", "Unreserved", "Refunded",
]
data = []
for item in items:
data.append([
item["discount__description"],
item["total_paid"],
item["total_reserved"],
item["total_unreserved"],
item["total_refunded"],
])
return ListReport("Usage by item", headings, data)
@report_view("Paid invoices by date", form_type=forms.ProductAndCategoryForm)
def paid_invoices_by_date(request, form):
''' Shows the number of paid invoices containing given products or
categories per day. '''
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
invoices = commerce.Invoice.objects.filter(
(
Q(lineitem__product__in=products) |
Q(lineitem__product__category__in=categories)
),
status=commerce.Invoice.STATUS_PAID,
)
# Invoices with payments will be paid at the time of their latest payment
payments = commerce.PaymentBase.objects.all()
payments = payments.filter(
invoice__in=invoices,
)
payments = payments.order_by("invoice")
invoice_max_time = payments.values("invoice").annotate(
max_time=Max("time")
)
# Zero-value invoices will have no payments, so they're paid at issue time
zero_value_invoices = invoices.filter(value=0)
times = itertools.chain(
(line["max_time"] for line in invoice_max_time),
(invoice.issue_time for invoice in zero_value_invoices),
)
by_date = collections.defaultdict(int)
for time in times:
date = datetime.datetime(
year=time.year, month=time.month, day=time.day
)
by_date[date] += 1
data = [(date, count) for date, count in sorted(by_date.items())]
data = [(date.strftime("%Y-%m-%d"), count) for date, count in data]
return ListReport(
"Paid Invoices By Date",
["date", "count"],
data,
)
@report_view("Credit notes")
def credit_notes(request, form):
''' Shows all of the credit notes in the system. '''
notes = commerce.CreditNote.objects.all().select_related(
"creditnoterefund",
"creditnoteapplication",
"invoice",
"invoice__user__attendee__attendeeprofilebase",
)
return QuerysetReport(
"Credit Notes",
["id",
"invoice__user__attendee__attendeeprofilebase__invoice_recipient",
"status", "value"],
notes,
headings=["id", "Owner", "Status", "Value"],
link_view=views.credit_note,
)
@report_view("Invoices")
def invoices(request, form):
''' Shows all of the invoices in the system. '''
invoices = commerce.Invoice.objects.all().order_by("status", "id")
return QuerysetReport(
"Invoices",
["id", "recipient", "value", "get_status_display"],
invoices,
headings=["id", "Recipient", "Value", "Status"],
link_view=views.invoice,
)
class AttendeeListReport(ListReport):
def get_link(self, argument):
return reverse(self._link_view) + "?user=%d" % int(argument)
@report_view("Attendee", form_type=forms.UserIdForm)
def attendee(request, form, user_id=None):
''' Returns a list of all manifested attendees if no attendee is specified,
else displays the attendee manifest. '''
if user_id is None and form.cleaned_data["user"] is not None:
user_id = form.cleaned_data["user"]
if user_id is None:
return attendee_list(request)
print(user_id)
attendee = people.Attendee.objects.get(user__id=user_id)
name = attendee.attendeeprofilebase.attendee_name()
reports = []
profile_data = []
try:
profile = people.AttendeeProfileBase.objects.get_subclass(
attendee=attendee
)
fields = profile._meta.get_fields()
except people.AttendeeProfileBase.DoesNotExist:
fields = []
exclude = set(["attendeeprofilebase_ptr", "id"])
for field in fields:
if field.name in exclude:
# Not actually important
continue
if not hasattr(field, "verbose_name"):
continue # Not a publicly visible field
value = getattr(profile, field.name)
if isinstance(field, models.ManyToManyField):
value = ", ".join(str(i) for i in value.all())
profile_data.append((field.verbose_name, value))
cart = CartController.for_user(attendee.user)
reservation = cart.cart.reservation_duration + cart.cart.time_last_updated
profile_data.append(("Current cart reserved until", reservation))
reports.append(ListReport("Profile", ["", ""], profile_data))
links = []
links.append((
reverse(views.badge, args=[user_id]),
"View badge",
))
links.append((
reverse(views.amend_registration, args=[user_id]),
"Amend current cart",
))
links.append((
reverse(views.extend_reservation, args=[user_id]),
"Extend reservation",
))
reports.append(Links("Actions for " + name, links))
# Paid and pending products
ic = ItemController(attendee.user)
reports.append(ListReport(
"Paid Products",
["Product", "Quantity"],
[(pq.product, pq.quantity) for pq in ic.items_purchased()],
))
reports.append(ListReport(
"Unpaid Products",
["Product", "Quantity"],
[(pq.product, pq.quantity) for pq in ic.items_pending()],
))
# Invoices
invoices = commerce.Invoice.objects.filter(
user=attendee.user,
)
reports.append(QuerysetReport(
"Invoices",
["id", "get_status_display", "value"],
invoices,
headings=["Invoice ID", "Status", "Value"],
link_view=views.invoice,
))
# Credit Notes
credit_notes = commerce.CreditNote.objects.filter(
invoice__user=attendee.user,
).select_related("invoice", "creditnoteapplication", "creditnoterefund")
reports.append(QuerysetReport(
"Credit Notes",
["id", "status", "value"],
credit_notes,
link_view=views.credit_note,
))
# All payments
payments = commerce.PaymentBase.objects.filter(
invoice__user=attendee.user,
).select_related("invoice")
reports.append(QuerysetReport(
"Payments",
["invoice__id", "id", "reference", "amount"],
payments,
link_view=views.invoice,
))
return reports
def attendee_list(request):
''' Returns a list of all attendees. '''
attendees = people.Attendee.objects.select_related(
"attendeeprofilebase",
"user",
)
profiles = AttendeeProfile.objects.filter(
attendee__in=attendees
).select_related(
"attendee", "attendee__user",
)
profiles_by_attendee = dict((i.attendee, i) for i in profiles)
attendees = attendees.annotate(
has_registered=Count(
Q(user__invoice__status=commerce.Invoice.STATUS_PAID)
),
)
headings = [
"User ID", "Name", "Email", "Has registered",
]
data = []
for a in attendees:
data.append([
a.user.id,
(profiles_by_attendee[a].attendee_name()
if a in profiles_by_attendee else ""),
a.user.email,
a.has_registered > 0,
])
# Sort by whether they've registered, then ID.
data.sort(key=lambda a: (-a[3], a[0]))
return AttendeeListReport("Attendees", headings, data, link_view=attendee)
ProfileForm = forms.model_fields_form_factory(AttendeeProfile)
@report_view(
"Attendees By Product/Category",
form_type=forms.mix_form(
forms.ProductAndCategoryForm, ProfileForm, forms.GroupByForm
),
)
def attendee_data(request, form, user_id=None):
''' Lists attendees for a given product/category selection along with
profile data.'''
status_display = {
commerce.Cart.STATUS_ACTIVE: "Unpaid",
commerce.Cart.STATUS_PAID: "Paid",
commerce.Cart.STATUS_RELEASED: "Refunded",
}
output = []
by_category = (
form.cleaned_data["group_by"] == forms.GroupByForm.GROUP_BY_CATEGORY)
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
fields = form.cleaned_data["fields"]
name_field = AttendeeProfile.name_field()
items = commerce.ProductItem.objects.filter(
Q(product__in=products) | Q(product__category__in=categories),
).exclude(
cart__status=commerce.Cart.STATUS_RELEASED
).select_related(
"cart", "cart__user", "product", "product__category",
).order_by("cart__status")
# Add invoice nag link
links = []
invoice_mailout = reverse(views.invoice_mailout, args=[])
invoice_mailout += "?" + request.META["QUERY_STRING"]
links += [
(invoice_mailout + "&status=1", "Send invoice reminders",),
(invoice_mailout + "&status=2", "Send mail for paid invoices",),
]
if items.count() > 0:
output.append(Links("Actions", links))
# Make sure we select all of the related fields
related_fields = set(
field for field in fields
if isinstance(AttendeeProfile._meta.get_field(field), RelatedField)
)
# Get all of the relevant attendee profiles in one hit.
profiles = AttendeeProfile.objects.filter(
attendee__user__cart__productitem__in=items
).select_related("attendee__user").prefetch_related(*related_fields)
by_user = {}
for profile in profiles:
by_user[profile.attendee.user] = profile
cart = "attendee__user__cart"
cart_status = cart + "__status" # noqa
product = cart + "__productitem__product"
product_name = product + "__name"
category = product + "__category"
category_name = category + "__name"
if by_category:
grouping_fields = (category, category_name)
order_by = (category, )
first_column = "Category"
group_name = lambda i: "%s" % (i[category_name], ) # noqa
else:
grouping_fields = (product, product_name, category_name)
order_by = (category, )
first_column = "Product"
group_name = lambda i: "%s - %s" % (i[category_name], i[product_name]) # noqa
# Group the responses per-field.
for field in fields:
concrete_field = AttendeeProfile._meta.get_field(field)
field_verbose = concrete_field.verbose_name
# Render the correct values for related fields
if field in related_fields:
# Get all of the IDs that will appear
all_ids = profiles.order_by(field).values(field)
all_ids = [i[field] for i in all_ids if i[field] is not None]
# Get all of the concrete objects for those IDs
model = concrete_field.related_model
all_objects = model.objects.filter(id__in=all_ids)
all_objects_by_id = dict((i.id, i) for i in all_objects)
# Define a function to render those IDs.
def display_field(value):
if value in all_objects_by_id:
return all_objects_by_id[value]
else:
return None
else:
def display_field(value):
return value
status_count = lambda status: Case(When( # noqa
attendee__user__cart__status=status,
then=Value(1),
),
default=Value(0),
output_field=models.fields.IntegerField(),
)
paid_count = status_count(commerce.Cart.STATUS_PAID)
unpaid_count = status_count(commerce.Cart.STATUS_ACTIVE)
groups = profiles.order_by(
*(order_by + (field, ))
).values(
*(grouping_fields + (field, ))
).annotate(
paid_count=Sum(paid_count),
unpaid_count=Sum(unpaid_count),
)
output.append(ListReport(
"Grouped by %s" % field_verbose,
[first_column, field_verbose, "paid", "unpaid"],
[
(
group_name(group),
display_field(group[field]),
group["paid_count"] or 0,
group["unpaid_count"] or 0,
)
for group in groups
],
))
# DO the report for individual attendees
field_names = [
AttendeeProfile._meta.get_field(field).verbose_name for field in fields
]
def display_field(profile, field):
field_type = AttendeeProfile._meta.get_field(field)
attr = getattr(profile, field)
if isinstance(field_type, models.ManyToManyField):
return [str(i) for i in attr.all()] or ""
else:
return attr
headings = ["User ID", "Name", "Email", "Product", "Item Status"]
headings.extend(field_names)
data = []
for item in items:
profile = by_user[item.cart.user]
line = [
item.cart.user.id,
getattr(profile, name_field),
profile.attendee.user.email,
item.product,
status_display[item.cart.status],
] + [
display_field(profile, field) for field in fields
]
data.append(line)
output.append(AttendeeListReport(
"Attendees by item with profile data", headings, data,
link_view=attendee
))
return output
@report_view(
"Speaker Registration Status",
form_type=forms.ProposalKindForm,
)
def speaker_registrations(request, form):
''' Shows registration status for speakers with a given proposal kind. '''
kinds = form.cleaned_data["kind"]
presentations = schedule_models.Presentation.objects.filter(
proposal_base__kind__in=kinds,
).exclude(
cancelled=True,
)
users = User.objects.filter(
Q(speaker_profile__presentations__in=presentations) |
Q(speaker_profile__copresentations__in=presentations)
)
paid_carts = commerce.Cart.objects.filter(status=commerce.Cart.STATUS_PAID)
paid_carts = Case(
When(cart__in=paid_carts, then=Value(1)),
default=Value(0),
output_field=models.IntegerField(),
)
users = users.annotate(paid_carts=Sum(paid_carts))
users = users.order_by("paid_carts")
return QuerysetReport(
"Speaker Registration Status",
["id", "speaker_profile__name", "email", "paid_carts"],
users,
link_view=attendee,
)
return []
@report_view(
"Manifest",
forms.ProductAndCategoryForm,
)
def manifest(request, form):
'''
Produces the registration manifest for people with the given product
type.
'''
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
line_items = (
Q(lineitem__product__in=products) |
Q(lineitem__product__category__in=categories)
)
invoices = commerce.Invoice.objects.filter(
line_items,
status=commerce.Invoice.STATUS_PAID,
).select_related(
"cart",
"user",
"user__attendee",
"user__attendee__attendeeprofilebase"
)
users = set(i.user for i in invoices)
carts = commerce.Cart.objects.filter(
user__in=users
)
items = commerce.ProductItem.objects.filter(
cart__in=carts
).select_related(
"product",
"product__category",
"cart",
"cart__user",
"cart__user__attendee",
"cart__user__attendee__attendeeprofilebase"
).order_by("product__category__order", "product__order")
users = {}
for item in items:
cart = item.cart
if cart.user not in users:
users[cart.user] = {"unpaid": [], "paid": [], "refunded": []}
items = users[cart.user]
if cart.status == commerce.Cart.STATUS_ACTIVE:
items["unpaid"].append(item)
elif cart.status == commerce.Cart.STATUS_PAID:
items["paid"].append(item)
elif cart.status == commerce.Cart.STATUS_RELEASED:
items["refunded"].append(item)
users_by_name = list(users.keys())
users_by_name.sort(key=(
lambda i: i.attendee.attendeeprofilebase.attendee_name().lower()
))
headings = ["User ID", "Name", "Paid", "Unpaid", "Refunded"]
def format_items(item_list):
strings = []
for item in item_list:
strings.append('%d x %s' % (item.quantity, str(item.product)))
return ", \n".join(strings)
output = []
for user in users_by_name:
items = users[user]
output.append([
user.id,
user.attendee.attendeeprofilebase.attendee_name(),
format_items(items["paid"]),
format_items(items["unpaid"]),
format_items(items["refunded"]),
])
return ListReport("Manifest", headings, output)
# attendeeprofilebase.attendee_name()

View file

View file

@ -0,0 +1,119 @@
from registrasion.models import commerce
from registrasion.controllers.category import CategoryController
from registrasion.controllers.item import ItemController
from django import template
from django.db.models import Sum
from urllib.parse import urlencode
register = template.Library()
def user_for_context(context):
''' Returns either context.user or context.request.user if the former is
not defined. '''
try:
return context["user"]
except KeyError:
return context.request.user
@register.assignment_tag(takes_context=True)
def available_categories(context):
''' Gets all of the currently available products.
Returns:
[models.inventory.Category, ...]: A list of all of the categories that
have Products that the current user can reserve.
'''
return CategoryController.available_categories(user_for_context(context))
@register.assignment_tag(takes_context=True)
def missing_categories(context):
''' Adds the categories that the user does not currently have. '''
user = user_for_context(context)
categories_available = set(CategoryController.available_categories(user))
items = ItemController(user).items_pending_or_purchased()
categories_held = set()
for product, quantity in items:
categories_held.add(product.category)
return categories_available - categories_held
@register.assignment_tag(takes_context=True)
def available_credit(context):
''' Calculates the sum of unclaimed credit from this user's credit notes.
Returns:
Decimal: the sum of the values of unclaimed credit notes for the
current user.
'''
notes = commerce.CreditNote.unclaimed().filter(
invoice__user=user_for_context(context),
)
ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0
return 0 - ret
@register.assignment_tag(takes_context=True)
def invoices(context):
'''
Returns:
[models.commerce.Invoice, ...]: All of the current user's invoices. '''
return commerce.Invoice.objects.filter(user=user_for_context(context))
@register.assignment_tag(takes_context=True)
def items_pending(context):
''' Gets all of the items that the user from this context has reserved.
The user will be either `context.user`, and `context.request.user` if
the former is not defined.
'''
return ItemController(user_for_context(context)).items_pending()
@register.assignment_tag(takes_context=True)
def items_purchased(context, category=None):
''' Returns the items purchased for this user.
The user will be either `context.user`, and `context.request.user` if
the former is not defined.
'''
return ItemController(user_for_context(context)).items_purchased(
category=category
)
@register.assignment_tag(takes_context=True)
def total_items_purchased(context, category=None):
''' Returns the number of items purchased for this user (sum of quantities).
The user will be either `context.user`, and `context.request.user` if
the former is not defined.
'''
return sum(i.quantity for i in items_purchased(context, category))
@register.assignment_tag(takes_context=True)
def report_as_csv(context, section):
old_query = context.request.META["QUERY_STRING"]
query = dict([("section", section), ("content_type", "text/csv")])
querystring = urlencode(query)
if old_query:
querystring = old_query + "&" + querystring
return context.request.path + "?" + querystring

1
vendor/registrasion/tests/__init__.py vendored Normal file
View file

@ -0,0 +1 @@
default_app_config = "registrasion.apps.RegistrationConfig"

View file

@ -0,0 +1,63 @@
from registrasion.controllers.cart import CartController
from registrasion.controllers.credit_note import CreditNoteController
from registrasion.controllers.invoice import InvoiceController
from registrasion.models import commerce
from django.core.exceptions import ObjectDoesNotExist
class TestingCartController(CartController):
def set_quantity(self, product, quantity, batched=False):
''' Sets the _quantity_ of the given _product_ in the cart to the given
_quantity_. '''
self.set_quantities(((product, quantity),))
def add_to_cart(self, product, quantity):
''' Adds _quantity_ of the given _product_ to the cart. Raises
ValidationError if constraints are violated.'''
try:
product_item = commerce.ProductItem.objects.get(
cart=self.cart,
product=product)
old_quantity = product_item.quantity
except ObjectDoesNotExist:
old_quantity = 0
self.set_quantity(product, old_quantity + quantity)
def next_cart(self):
if self.cart.status == commerce.Cart.STATUS_ACTIVE:
self.cart.status = commerce.Cart.STATUS_PAID
self.cart.save()
class TestingInvoiceController(InvoiceController):
def pay(self, reference, amount, pre_validate=True):
''' Testing method for simulating an invoice paymenht by the given
amount. '''
if pre_validate:
# Manual payments don't pre-validate; we should test that things
# still work if we do silly things.
self.validate_allowed_to_pay()
''' Adds a payment '''
commerce.PaymentBase.objects.create(
invoice=self.invoice,
reference=reference,
amount=amount,
)
self.update_status()
class TestingCreditNoteController(CreditNoteController):
def refund(self):
commerce.CreditNoteRefund.objects.create(
parent=self.credit_note,
reference="Whoops."
)

50
vendor/registrasion/tests/patches.py vendored Normal file
View file

@ -0,0 +1,50 @@
from django.utils import timezone
from registrasion.contrib import mail
class SetTimeMixin(object):
''' Patches timezone.now() for the duration of a test case. Allows us to
test time-based conditions (ceilings etc) relatively easily. '''
def setUp(self):
super(SetTimeMixin, self).setUp()
self._old_timezone_now = timezone.now
self.now = timezone.now()
timezone.now = self.new_timezone_now
def tearDown(self):
timezone.now = self._old_timezone_now
super(SetTimeMixin, self).tearDown()
def set_time(self, time):
self.now = time
def add_timedelta(self, delta):
self.now += delta
def new_timezone_now(self):
return self.now
class SendEmailMixin(object):
def setUp(self):
super(SendEmailMixin, self).setUp()
self._old_sender = mail.__send_email__
mail.__send_email__ = self._send_email
self.emails = []
def _send_email(self, template_prefix, to, kind, **kwargs):
args = {"to": to, "kind": kind}
args.update(kwargs)
self.emails.append(args)
def tearDown(self):
mail.__send_email__ = self._old_sender
super(SendEmailMixin, self).tearDown()
class MixInPatches(SetTimeMixin, SendEmailMixin):
pass

137
vendor/registrasion/tests/test_batch.py vendored Normal file
View file

@ -0,0 +1,137 @@
import pytz
from registrasion.tests.test_cart import RegistrationCartTestCase
from registrasion.controllers.batch import BatchController
UTC = pytz.timezone('UTC')
class BatchTestCase(RegistrationCartTestCase):
def test_no_caches_outside_of_batches(self):
cache_1 = BatchController.get_cache(self.USER_1)
cache_2 = BatchController.get_cache(self.USER_2)
# Identity testing is important here
self.assertIsNot(cache_1, cache_2)
def test_cache_clears_at_batch_exit(self):
with BatchController.batch(self.USER_1):
cache_1 = BatchController.get_cache(self.USER_1)
cache_2 = BatchController.get_cache(self.USER_1)
self.assertIsNot(cache_1, cache_2)
def test_caches_identical_within_nestings(self):
with BatchController.batch(self.USER_1):
cache_1 = BatchController.get_cache(self.USER_1)
with BatchController.batch(self.USER_2):
cache_2 = BatchController.get_cache(self.USER_1)
cache_3 = BatchController.get_cache(self.USER_1)
self.assertIs(cache_1, cache_2)
self.assertIs(cache_2, cache_3)
def test_caches_are_independent_for_different_users(self):
with BatchController.batch(self.USER_1):
cache_1 = BatchController.get_cache(self.USER_1)
with BatchController.batch(self.USER_2):
cache_2 = BatchController.get_cache(self.USER_2)
self.assertIsNot(cache_1, cache_2)
def test_cache_clears_are_independent_for_different_users(self):
with BatchController.batch(self.USER_1):
cache_1 = BatchController.get_cache(self.USER_1)
with BatchController.batch(self.USER_2):
cache_2 = BatchController.get_cache(self.USER_2)
with BatchController.batch(self.USER_2):
cache_3 = BatchController.get_cache(self.USER_2)
cache_4 = BatchController.get_cache(self.USER_1)
self.assertIs(cache_1, cache_4)
self.assertIsNot(cache_1, cache_2)
self.assertIsNot(cache_2, cache_3)
def test_new_caches_for_new_batches(self):
with BatchController.batch(self.USER_1):
cache_1 = BatchController.get_cache(self.USER_1)
with BatchController.batch(self.USER_1):
cache_2 = BatchController.get_cache(self.USER_1)
with BatchController.batch(self.USER_1):
cache_3 = BatchController.get_cache(self.USER_1)
self.assertIs(cache_2, cache_3)
self.assertIsNot(cache_1, cache_2)
def test_memoisation_happens_in_batch_context(self):
with BatchController.batch(self.USER_1):
output_1 = self._memoiseme(self.USER_1)
with BatchController.batch(self.USER_1):
output_2 = self._memoiseme(self.USER_1)
self.assertIs(output_1, output_2)
def test_memoisaion_does_not_happen_outside_batch_context(self):
output_1 = self._memoiseme(self.USER_1)
output_2 = self._memoiseme(self.USER_1)
self.assertIsNot(output_1, output_2)
def test_memoisation_is_user_independent(self):
with BatchController.batch(self.USER_1):
output_1 = self._memoiseme(self.USER_1)
with BatchController.batch(self.USER_2):
output_2 = self._memoiseme(self.USER_2)
output_3 = self._memoiseme(self.USER_1)
self.assertIsNot(output_1, output_2)
self.assertIs(output_1, output_3)
def test_memoisation_clears_outside_batches(self):
with BatchController.batch(self.USER_1):
output_1 = self._memoiseme(self.USER_1)
with BatchController.batch(self.USER_1):
output_2 = self._memoiseme(self.USER_1)
self.assertIsNot(output_1, output_2)
@classmethod
@BatchController.memoise
def _memoiseme(self, user):
return object()
def test_batch_end_functionality_is_called(self):
class Ender(object):
end_count = 0
def end_batch(self):
self.end_count += 1
@BatchController.memoise
def get_ender(user):
return Ender()
# end_batch should get called once on exiting the batch
with BatchController.batch(self.USER_1):
ender = get_ender(self.USER_1)
self.assertEquals(1, ender.end_count)
# end_batch should get called once on exiting the batch
# no matter how deep the object gets cached
with BatchController.batch(self.USER_1):
with BatchController.batch(self.USER_1):
ender = get_ender(self.USER_1)
self.assertEquals(1, ender.end_count)

551
vendor/registrasion/tests/test_cart.py vendored Normal file
View file

@ -0,0 +1,551 @@
import datetime
import pytz
from decimal import Decimal
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.test import TestCase
from registrasion.models import commerce
from registrasion.models import conditions
from registrasion.models import inventory
from registrasion.models import people
from registrasion.controllers.batch import BatchController
from registrasion.controllers.product import ProductController
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.patches import MixInPatches
UTC = pytz.timezone('UTC')
class RegistrationCartTestCase(MixInPatches, TestCase):
def setUp(self):
super(RegistrationCartTestCase, self).setUp()
def tearDown(self):
if True:
# If you're seeing segfaults in tests, enable this.
call_command(
'flush',
verbosity=0,
interactive=False,
reset_sequences=False,
allow_cascade=False,
inhibit_post_migrate=False
)
super(RegistrationCartTestCase, self).tearDown()
@classmethod
def setUpTestData(cls):
super(RegistrationCartTestCase, cls).setUpTestData()
cls.USER_1 = User.objects.create_user(
username='testuser',
email='test@example.com',
password='top_secret')
cls.USER_2 = User.objects.create_user(
username='testuser2',
email='test2@example.com',
password='top_secret')
attendee1 = people.Attendee.get_instance(cls.USER_1)
people.AttendeeProfileBase.objects.create(
attendee=attendee1,
)
attendee2 = people.Attendee.get_instance(cls.USER_2)
people.AttendeeProfileBase.objects.create(
attendee=attendee2,
)
cls.RESERVATION = datetime.timedelta(hours=1)
cls.categories = []
for i in range(2):
cat = inventory.Category.objects.create(
name="Category " + str(i + 1),
description="This is a test category",
order=i,
render_type=inventory.Category.RENDER_TYPE_RADIO,
required=False,
)
cls.categories.append(cat)
cls.CAT_1 = cls.categories[0]
cls.CAT_2 = cls.categories[1]
cls.products = []
for i in range(4):
prod = inventory.Product.objects.create(
name="Product " + str(i + 1),
description="This is a test product.",
category=cls.categories[int(i / 2)], # 2 products per category
price=Decimal("10.00"),
reservation_duration=cls.RESERVATION,
limit_per_user=10,
order=1,
)
cls.products.append(prod)
cls.PROD_1 = cls.products[0]
cls.PROD_2 = cls.products[1]
cls.PROD_3 = cls.products[2]
cls.PROD_4 = cls.products[3]
cls.PROD_4.price = Decimal("5.00")
cls.PROD_4.save()
# Burn through some carts -- this made some past flag tests fail
current_cart = TestingCartController.for_user(cls.USER_1)
current_cart.next_cart()
current_cart = TestingCartController.for_user(cls.USER_2)
current_cart.next_cart()
@classmethod
def make_ceiling(cls, name, limit=None, start_time=None, end_time=None):
limit_ceiling = conditions.TimeOrStockLimitFlag.objects.create(
description=name,
condition=conditions.FlagBase.DISABLE_IF_FALSE,
limit=limit,
start_time=start_time,
end_time=end_time
)
limit_ceiling.products.add(cls.PROD_1, cls.PROD_2)
@classmethod
def make_category_ceiling(
cls, name, limit=None, start_time=None, end_time=None):
limit_ceiling = conditions.TimeOrStockLimitFlag.objects.create(
description=name,
condition=conditions.FlagBase.DISABLE_IF_FALSE,
limit=limit,
start_time=start_time,
end_time=end_time
)
limit_ceiling.categories.add(cls.CAT_1)
@classmethod
def make_discount_ceiling(
cls, name, limit=None, start_time=None, end_time=None,
percentage=100):
limit_ceiling = conditions.TimeOrStockLimitDiscount.objects.create(
description=name,
start_time=start_time,
end_time=end_time,
limit=limit,
)
conditions.DiscountForProduct.objects.create(
discount=limit_ceiling,
product=cls.PROD_1,
percentage=percentage,
quantity=10,
)
@classmethod
def new_voucher(self, code="VOUCHER", limit=1):
voucher = inventory.Voucher.objects.create(
recipient="Voucher recipient",
code=code,
limit=limit,
)
return voucher
@classmethod
def reget(cls, object):
return type(object).objects.get(id=object.id)
class BasicCartTests(RegistrationCartTestCase):
def test_get_cart(self):
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.next_cart()
old_cart = current_cart
current_cart = TestingCartController.for_user(self.USER_1)
self.assertNotEqual(old_cart.cart, current_cart.cart)
current_cart2 = TestingCartController.for_user(self.USER_1)
self.assertEqual(current_cart.cart, current_cart2.cart)
def test_add_to_cart_collapses_product_items(self):
current_cart = TestingCartController.for_user(self.USER_1)
# Add a product twice
current_cart.add_to_cart(self.PROD_1, 1)
current_cart.add_to_cart(self.PROD_1, 1)
# Count of products for a given user should be collapsed.
items = commerce.ProductItem.objects.filter(
cart=current_cart.cart,
product=self.PROD_1)
self.assertEqual(1, len(items))
item = items[0]
self.assertEquals(2, item.quantity)
def test_set_quantity(self):
current_cart = TestingCartController.for_user(self.USER_1)
def get_item():
return commerce.ProductItem.objects.get(
cart=current_cart.cart,
product=self.PROD_1)
current_cart.set_quantity(self.PROD_1, 1)
self.assertEqual(1, get_item().quantity)
# Setting the quantity to zero should remove the entry from the cart.
current_cart.set_quantity(self.PROD_1, 0)
with self.assertRaises(ObjectDoesNotExist):
get_item()
current_cart.set_quantity(self.PROD_1, 9)
self.assertEqual(9, get_item().quantity)
with self.assertRaises(ValidationError):
current_cart.set_quantity(self.PROD_1, 11)
self.assertEqual(9, get_item().quantity)
with self.assertRaises(ValidationError):
current_cart.set_quantity(self.PROD_1, -1)
self.assertEqual(9, get_item().quantity)
current_cart.set_quantity(self.PROD_1, 2)
self.assertEqual(2, get_item().quantity)
def test_add_to_cart_product_per_user_limit(self):
current_cart = TestingCartController.for_user(self.USER_1)
# User should be able to add 1 of PROD_1 to the current cart.
current_cart.add_to_cart(self.PROD_1, 1)
# User should be able to add 1 of PROD_1 to the current cart.
current_cart.add_to_cart(self.PROD_1, 1)
# User should not be able to add 10 of PROD_1 to the current cart now,
# because they have a limit of 10.
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 10)
current_cart.next_cart()
current_cart = TestingCartController.for_user(self.USER_1)
# User should not be able to add 10 of PROD_1 to the current cart now,
# even though it's a new cart.
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 10)
# Second user should not be affected by first user's limits
second_user_cart = TestingCartController.for_user(self.USER_2)
second_user_cart.add_to_cart(self.PROD_1, 10)
def set_limits(self):
self.CAT_2.limit_per_user = 10
self.PROD_2.limit_per_user = None
self.PROD_3.limit_per_user = None
self.PROD_4.limit_per_user = 6
self.CAT_2.save()
self.PROD_2.save()
self.PROD_3.save()
self.PROD_4.save()
def test_per_user_product_limit_ignored_if_blank(self):
self.set_limits()
current_cart = TestingCartController.for_user(self.USER_1)
# There is no product limit on PROD_2, and there is no cat limit
current_cart.add_to_cart(self.PROD_2, 1)
# There is no product limit on PROD_3, but there is a cat limit
current_cart.add_to_cart(self.PROD_3, 1)
def test_per_user_category_limit_ignored_if_blank(self):
self.set_limits()
current_cart = TestingCartController.for_user(self.USER_1)
# There is no product limit on PROD_2, and there is no cat limit
current_cart.add_to_cart(self.PROD_2, 1)
# There is no cat limit on PROD_1, but there is a prod limit
current_cart.add_to_cart(self.PROD_1, 1)
def test_per_user_category_limit_only(self):
self.set_limits()
current_cart = TestingCartController.for_user(self.USER_1)
# Cannot add to cart if category limit is filled by one product.
current_cart.set_quantity(self.PROD_3, 10)
with self.assertRaises(ValidationError):
current_cart.set_quantity(self.PROD_4, 1)
# Can add to cart if category limit is not filled by one product
current_cart.set_quantity(self.PROD_3, 5)
current_cart.set_quantity(self.PROD_4, 5)
# Cannot add to cart if category limit is filled by two products
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_3, 1)
current_cart.next_cart()
current_cart = TestingCartController.for_user(self.USER_1)
# The category limit should extend across carts
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_3, 10)
def test_per_user_category_and_product_limits(self):
self.set_limits()
current_cart = TestingCartController.for_user(self.USER_1)
# Hit both the product and category edges:
current_cart.set_quantity(self.PROD_3, 4)
current_cart.set_quantity(self.PROD_4, 6)
with self.assertRaises(ValidationError):
# There's unlimited PROD_3, but limited in the category
current_cart.add_to_cart(self.PROD_3, 1)
current_cart.set_quantity(self.PROD_3, 0)
with self.assertRaises(ValidationError):
# There's only 6 allowed of PROD_4
current_cart.add_to_cart(self.PROD_4, 1)
# The limits should extend across carts...
current_cart.next_cart()
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.set_quantity(self.PROD_3, 4)
with self.assertRaises(ValidationError):
current_cart.set_quantity(self.PROD_3, 5)
with self.assertRaises(ValidationError):
current_cart.set_quantity(self.PROD_4, 1)
def __available_products_test(self, item, quantity):
self.set_limits()
def get_prods():
return ProductController.available_products(
self.USER_1,
products=[self.PROD_2, self.PROD_3, self.PROD_4],
)
current_cart = TestingCartController.for_user(self.USER_1)
prods = get_prods()
self.assertTrue(item in prods)
current_cart.add_to_cart(item, quantity)
self.assertTrue(item in prods)
current_cart.next_cart()
current_cart = TestingCartController.for_user(self.USER_1)
prods = get_prods()
self.assertTrue(item not in prods)
def test_available_products_respects_category_limits(self):
self.__available_products_test(self.PROD_3, 10)
def test_available_products_respects_product_limits(self):
self.__available_products_test(self.PROD_4, 6)
def test_cart_controller_for_user_is_memoised(self):
# - that for_user is memoised
with BatchController.batch(self.USER_1):
cart = TestingCartController.for_user(self.USER_1)
cart_2 = TestingCartController.for_user(self.USER_1)
self.assertIs(cart, cart_2)
def test_cart_revision_does_not_increment_if_not_modified(self):
cart = TestingCartController.for_user(self.USER_1)
rev_0 = cart.cart.revision
with BatchController.batch(self.USER_1):
# Memoise the cart
TestingCartController.for_user(self.USER_1)
# Do nothing on exit
rev_1 = self.reget(cart.cart).revision
self.assertEqual(rev_0, rev_1)
def test_cart_revision_only_increments_at_end_of_batches(self):
cart = TestingCartController.for_user(self.USER_1)
rev_0 = cart.cart.revision
with BatchController.batch(self.USER_1):
# Memoise the cart
same_cart = TestingCartController.for_user(self.USER_1)
same_cart.add_to_cart(self.PROD_1, 1)
rev_1 = self.reget(same_cart.cart).revision
rev_2 = self.reget(cart.cart).revision
self.assertEqual(rev_0, rev_1)
self.assertNotEqual(rev_0, rev_2)
def test_cart_discounts_only_calculated_at_end_of_batches(self):
def count_discounts(cart):
return cart.cart.discountitem_set.count()
cart = TestingCartController.for_user(self.USER_1)
self.make_discount_ceiling("FLOOZLE")
count_0 = count_discounts(cart)
with BatchController.batch(self.USER_1):
# Memoise the cart
same_cart = TestingCartController.for_user(self.USER_1)
with BatchController.batch(self.USER_1):
# Memoise the cart
same_cart_2 = TestingCartController.for_user(self.USER_1)
same_cart_2.add_to_cart(self.PROD_1, 1)
count_1 = count_discounts(same_cart_2)
count_2 = count_discounts(same_cart)
count_3 = count_discounts(cart)
self.assertEqual(0, count_0)
self.assertEqual(0, count_1)
self.assertEqual(0, count_2)
self.assertEqual(1, count_3)
def test_reservation_duration_forwards(self):
''' Reservation duration should be the maximum of the durations (small)
'''
new_res = self.RESERVATION * 2
self.PROD_2.reservation_duration = new_res
self.PROD_2.save()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, self.RESERVATION)
cart.add_to_cart(self.PROD_2, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res)
def test_reservation_duration_backwards(self):
''' Reservation duration should be the maximum of the durations (big)
'''
new_res = self.RESERVATION * 2
self.PROD_2.reservation_duration = new_res
self.PROD_2.save()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res)
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res)
def test_reservation_duration_removals(self):
''' Reservation duration should update with removals
'''
new_res = self.RESERVATION * 2
self.PROD_2.reservation_duration = new_res
self.PROD_2.save()
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
cart = TestingCartController.for_user(self.USER_1)
one_third = new_res / 3
cart.add_to_cart(self.PROD_2, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res)
# Reservation duration should not decrease if time hasn't decreased
cart.set_quantity(self.PROD_2, 0)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res)
# Adding a new product should not reset the reservation duration below
# the old one
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res)
self.add_timedelta(one_third)
# The old reservation duration is still longer than PROD_1's
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, new_res - one_third)
self.add_timedelta(one_third)
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, self.RESERVATION)
def test_reservation_extension_less_than_current(self):
''' Reservation extension should have no effect if it's too small
'''
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, self.RESERVATION)
cart.extend_reservation(datetime.timedelta(minutes=30))
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, self.RESERVATION)
def test_reservation_extension(self):
''' Test various reservation extension bits.
'''
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, self.RESERVATION)
hours = datetime.timedelta(hours=1)
cart.extend_reservation(24 * hours)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, 24 * hours)
self.add_timedelta(1 * hours)
# PROD_1's reservation is less than what we've added to the cart
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(cart.cart.reservation_duration, 23 * hours)
# Now the extension should only have 59 minutes remaining
# so the autoextend behaviour should kick in
self.add_timedelta(datetime.timedelta(hours=22, minutes=1))
cart.add_to_cart(self.PROD_1, 1)
cart.cart.refresh_from_db()
self.assertEqual(
cart.cart.reservation_duration,
self.PROD_1.reservation_duration,
)

View file

@ -0,0 +1,223 @@
import datetime
import pytz
from django.core.exceptions import ValidationError
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.test_cart import RegistrationCartTestCase
from registrasion.controllers.discount import DiscountController
from registrasion.controllers.product import ProductController
from registrasion.models import commerce
from registrasion.models import conditions
UTC = pytz.timezone('UTC')
class CeilingsTestCases(RegistrationCartTestCase):
def test_add_to_cart_ceiling_limit(self):
self.make_ceiling("Limit ceiling", limit=9)
self.__add_to_cart_test()
def test_add_to_cart_ceiling_category_limit(self):
self.make_category_ceiling("Limit ceiling", limit=9)
self.__add_to_cart_test()
def __add_to_cart_test(self):
current_cart = TestingCartController.for_user(self.USER_1)
# User should not be able to add 10 of PROD_1 to the current cart
# because it is affected by limit_ceiling
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_2, 10)
# User should be able to add 5 of PROD_1 to the current cart
current_cart.add_to_cart(self.PROD_1, 5)
# User should not be able to add 6 of PROD_2 to the current cart
# because it is affected by CEIL_1
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_2, 6)
# User should be able to add 5 of PROD_2 to the current cart
current_cart.add_to_cart(self.PROD_2, 4)
def test_add_to_cart_ceiling_date_range(self):
self.make_ceiling(
"date range ceiling",
start_time=datetime.datetime(2015, 1, 1, tzinfo=UTC),
end_time=datetime.datetime(2015, 2, 1, tzinfo=UTC))
current_cart = TestingCartController.for_user(self.USER_1)
# User should not be able to add whilst we're before start_time
self.set_time(datetime.datetime(2014, 1, 1, tzinfo=UTC))
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 1)
# User should be able to add whilst we're during date range
# On edge of start
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
current_cart.add_to_cart(self.PROD_1, 1)
# In middle
self.set_time(datetime.datetime(2015, 1, 15, tzinfo=UTC))
current_cart.add_to_cart(self.PROD_1, 1)
# On edge of end
self.set_time(datetime.datetime(2015, 2, 1, tzinfo=UTC))
current_cart.add_to_cart(self.PROD_1, 1)
# User should not be able to add whilst we're after date range
self.set_time(datetime.datetime(2014, 1, 1, minute=1, tzinfo=UTC))
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 1)
def test_add_to_cart_ceiling_limit_reserved_carts(self):
self.make_ceiling("Limit ceiling", limit=1)
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
first_cart = TestingCartController.for_user(self.USER_1)
second_cart = TestingCartController.for_user(self.USER_2)
first_cart.add_to_cart(self.PROD_1, 1)
# User 2 should not be able to add item to their cart
# because user 1 has item reserved, exhausting the ceiling
with self.assertRaises(ValidationError):
second_cart.add_to_cart(self.PROD_1, 1)
# User 2 should be able to add item to their cart once the
# reservation duration is elapsed
self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
second_cart.add_to_cart(self.PROD_1, 1)
# User 2 pays for their cart
second_cart.next_cart()
# User 1 should not be able to add item to their cart
# because user 2 has paid for their reserved item, exhausting
# the ceiling, regardless of the reservation time.
self.add_timedelta(self.RESERVATION * 20)
with self.assertRaises(ValidationError):
first_cart.add_to_cart(self.PROD_1, 1)
def test_validate_cart_fails_product_ceilings(self):
self.make_ceiling("Limit ceiling", limit=1)
self.__validation_test()
def test_validate_cart_fails_product_discount_ceilings(self):
self.make_discount_ceiling("Limit ceiling", limit=1)
self.__validation_test()
def __validation_test(self):
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
first_cart = TestingCartController.for_user(self.USER_1)
second_cart = TestingCartController.for_user(self.USER_2)
# Adding a valid product should validate.
first_cart.add_to_cart(self.PROD_1, 1)
first_cart.validate_cart()
# Cart should become invalid if lapsed carts are claimed.
self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
# Unpaid cart within reservation window
second_cart.add_to_cart(self.PROD_1, 1)
with self.assertRaises(ValidationError):
first_cart.validate_cart()
# Paid cart outside the reservation window
second_cart.next_cart()
self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1))
with self.assertRaises(ValidationError):
first_cart.validate_cart()
def test_discount_ceiling_aggregates_products(self):
# Create two carts, add 1xprod_1 to each. Ceiling should disappear
# after second.
self.make_discount_ceiling(
"Multi-product limit discount ceiling",
limit=2,
)
for i in range(2):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.next_cart()
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_1],
)
self.assertEqual(0, len(discounts))
def test_flag_ceiling_aggregates_products(self):
# Create two carts, add 1xprod_1 to each. Ceiling should disappear
# after second.
self.make_ceiling("Multi-product limit ceiling", limit=2)
for i in range(2):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.next_cart()
products = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertEqual(0, len(products))
def test_items_released_from_ceiling_by_refund(self):
self.make_ceiling("Limit ceiling", limit=1)
first_cart = TestingCartController.for_user(self.USER_1)
first_cart.add_to_cart(self.PROD_1, 1)
first_cart.next_cart()
second_cart = TestingCartController.for_user(self.USER_2)
with self.assertRaises(ValidationError):
second_cart.add_to_cart(self.PROD_1, 1)
first_cart.cart.status = commerce.Cart.STATUS_RELEASED
first_cart.cart.save()
second_cart.add_to_cart(self.PROD_1, 1)
def test_discount_ceiling_only_counts_items_covered_by_ceiling(self):
self.make_discount_ceiling("Limit ceiling", limit=1, percentage=50)
voucher = self.new_voucher(code="VOUCHER")
discount = conditions.VoucherDiscount.objects.create(
description="VOUCHER RECIPIENT",
voucher=voucher,
)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=self.PROD_1,
percentage=100,
quantity=1
)
# Buy two of PROD_1, in separate carts:
cart = TestingCartController.for_user(self.USER_1)
# the 100% discount from the voucher should apply to the first item
# and not the ceiling discount.
cart.apply_voucher("VOUCHER")
cart.add_to_cart(self.PROD_1, 1)
self.assertEqual(1, cart.cart.discountitem_set.count())
cart.next_cart()
# The second cart has no voucher attached, so should apply the
# ceiling discount
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
self.assertEqual(1, cart.cart.discountitem_set.count())

View file

@ -0,0 +1,467 @@
import datetime
import pytz
from django.core.exceptions import ValidationError
from registrasion.models import commerce
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.controller_helpers import TestingInvoiceController
from registrasion.tests.test_helpers import TestHelperMixin
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
HOURS = datetime.timedelta(hours=1)
class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase):
def test_overpaid_invoice_results_in_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
# Invoice is overpaid by 1 unit
to_pay = invoice.invoice.value + 1
invoice.pay("Reference", to_pay)
# The total paid should be equal to the value of the invoice only
self.assertEqual(
invoice.invoice.value, invoice.invoice.total_payments()
)
self.assertTrue(invoice.invoice.is_paid)
# There should be a credit note generated out of the 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)
def test_full_paid_invoice_does_not_generate_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
# Invoice is paid evenly
invoice.pay("Reference", invoice.invoice.value)
# The total paid should be equal to the value of the invoice only
self.assertEqual(
invoice.invoice.value, invoice.invoice.total_payments()
)
self.assertTrue(invoice.invoice.is_paid)
# There should be no credit notes
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):
invoice = self._invoice_containing_prod_1(1)
# Invoice is underpaid by 1 unit
to_pay = invoice.invoice.value - 1
invoice.pay("Reference", to_pay)
invoice.refund()
# The total paid should be zero
self.assertEqual(0, invoice.invoice.total_payments())
self.assertTrue(invoice.invoice.is_void)
# There should be a credit note generated out of the invoice.
credit_notes = commerce.CreditNote.objects.filter(
invoice=invoice.invoice,
)
self.assertEqual(1, credit_notes.count())
self.assertEqual(to_pay, credit_notes[0].value)
def test_refund_fully_paid_invoice_generates_correct_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
# The total paid should be zero
self.assertEqual(0, invoice.invoice.total_payments())
self.assertTrue(invoice.invoice.is_refunded)
# There should be a credit note generated out of the invoice.
credit_notes = commerce.CreditNote.objects.filter(
invoice=invoice.invoice,
)
self.assertEqual(1, credit_notes.count())
self.assertEqual(to_pay, credit_notes[0].value)
def test_apply_credit_note_pays_invoice(self):
# Create a manual invoice (stops credit notes from being auto-applied)
self._manual_invoice(1)
# Begin the test
invoice = self._invoice_containing_prod_1(1)
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
# There should be one credit note generated out of the invoice.
cn = self._credit_note_for_invoice(invoice.invoice)
# That credit note should be in the unclaimed pile
self.assertEquals(1, commerce.CreditNote.unclaimed().count())
# Create a new (identical) cart with invoice
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
cn.apply_to_invoice(invoice2.invoice)
self.assertTrue(invoice2.invoice.is_paid)
# That invoice should not show up as unclaimed any more
self.assertEquals(0, commerce.CreditNote.unclaimed().count())
def test_apply_credit_note_generates_new_credit_note_if_overpaying(self):
# Create and refund an invoice, generating a credit note.
invoice = self._invoice_containing_prod_1(2)
invoice.pay("Reference", invoice.invoice.value)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
# There should be one credit note generated out of the invoice.
cn = self._credit_note_for_invoice(invoice.invoice) # noqa
self.assertEquals(1, commerce.CreditNote.unclaimed().count())
# Create a new invoice for a cart of half value of inv 1
invoice2 = self._invoice_containing_prod_1(1)
# Credit note is automatically applied by generating the new invoice
self.assertTrue(invoice2.invoice.is_paid)
# We generated a new credit note, and spent the old one,
# unclaimed should still be 1.
self.assertEquals(1, commerce.CreditNote.unclaimed().count())
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.
self.assertEquals(
invoice.invoice.value - invoice2.invoice.value,
credit_note2.value,
)
def test_cannot_apply_credit_note_on_invalid_invoices(self):
# Disable auto-application of invoices.
self._manual_invoice(1)
# And now start the actual test.
invoice = self._invoice_containing_prod_1(1)
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
# There should be one credit note generated out of the invoice.
cn = self._credit_note_for_invoice(invoice.invoice)
# Create a new cart with invoice, pay it
invoice_2 = self._invoice_containing_prod_1(1)
invoice_2.pay("LOL", invoice_2.invoice.value)
# Cannot pay paid invoice
with self.assertRaises(ValidationError):
cn.apply_to_invoice(invoice_2.invoice)
invoice_2.refund()
# Cannot pay refunded invoice
with self.assertRaises(ValidationError):
cn.apply_to_invoice(invoice_2.invoice)
# Create a new cart with invoice
invoice_2 = self._invoice_containing_prod_1(1)
invoice_2.void()
# Cannot pay void invoice
with self.assertRaises(ValidationError):
cn.apply_to_invoice(invoice_2.invoice)
def test_cannot_apply_a_refunded_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
self.assertEquals(1, commerce.CreditNote.unclaimed().count())
cn = self._credit_note_for_invoice(invoice.invoice)
cn.refund()
# Refunding a credit note should mark it as claimed
self.assertEquals(0, commerce.CreditNote.unclaimed().count())
# Create a new cart with invoice
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
# Cannot pay with this credit note.
with self.assertRaises(ValidationError):
cn.apply_to_invoice(invoice_2.invoice)
def test_cannot_refund_an_applied_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
to_pay = invoice.invoice.value
invoice.pay("Reference", to_pay)
self.assertTrue(invoice.invoice.is_paid)
invoice.refund()
self.assertEquals(1, commerce.CreditNote.unclaimed().count())
cn = self._credit_note_for_invoice(invoice.invoice)
# Create a new cart with invoice
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart))
with self.assertRaises(ValidationError):
# Creating `invoice_2` will automatically apply `cn`.
cn.apply_to_invoice(invoice_2.invoice)
self.assertEquals(0, commerce.CreditNote.unclaimed().count())
# Cannot refund this credit note as it is already applied.
with self.assertRaises(ValidationError):
cn.refund()
def test_money_into_void_invoice_generates_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
invoice.void()
val = invoice.invoice.value
invoice.pay("Paying into the void.", val, pre_validate=False)
cn = self._credit_note_for_invoice(invoice.invoice)
self.assertEqual(val, cn.credit_note.value)
def test_money_into_refunded_invoice_generates_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
val = invoice.invoice.value
invoice.pay("Paying the first time.", val)
invoice.refund()
cnval = val - 1
invoice.pay("Paying into the void.", cnval, pre_validate=False)
notes = commerce.CreditNote.objects.filter(invoice=invoice.invoice)
notes = sorted(notes, key=lambda note: note.value)
self.assertEqual(cnval, notes[0].value)
self.assertEqual(val, notes[1].value)
def test_money_into_paid_invoice_generates_credit_note(self):
invoice = self._invoice_containing_prod_1(1)
val = invoice.invoice.value
invoice.pay("Paying the first time.", val)
invoice.pay("Paying into the void.", val, pre_validate=False)
cn = self._credit_note_for_invoice(invoice.invoice)
self.assertEqual(val, cn.credit_note.value)
def test_invoice_with_credit_note_applied_is_refunded(self):
''' Invoices with partial payments should void when cart is updated.
Test for issue #64 -- applying a credit note to an invoice
means that invoice cannot be voided, and new invoices cannot be
created. '''
invoice = self._invoice_containing_prod_1(1)
# Now get a credit note
invoice.pay("Lol", invoice.invoice.value)
invoice.refund()
cn = self._credit_note_for_invoice(invoice.invoice)
# Create a cart of higher value than the credit note
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 2)
# Create a current invoice
# This will automatically apply `cn` to the invoice
invoice = TestingInvoiceController.for_cart(cart.cart)
# Adding to cart will mean that the old invoice for this cart
# will be invalidated. A new invoice should be generated.
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_id(invoice.invoice.id)
invoice2 = TestingInvoiceController.for_cart(cart.cart) # noqa
cn2 = self._credit_note_for_invoice(invoice.invoice)
invoice._refresh()
# The first invoice should be refunded
self.assertEquals(
commerce.Invoice.STATUS_VOID,
invoice.invoice.status,
)
# Both credit notes should be for the same amount
self.assertEquals(
cn.credit_note.value,
cn2.credit_note.value,
)
def test_creating_invoice_automatically_applies_credit_note(self):
''' Single credit note is automatically applied to new invoices. '''
invoice = self._invoice_containing_prod_1(1)
invoice.pay("boop", invoice.invoice.value)
invoice.refund()
# Generate a new invoice to the same value as first invoice
# Should be paid, because we're applying credit notes automatically
invoice2 = self._invoice_containing_prod_1(1)
self.assertTrue(invoice2.invoice.is_paid)
def _generate_multiple_credit_notes(self):
invoice1 = self._manual_invoice(11)
invoice2 = self._manual_invoice(11)
invoice1.pay("Pay", invoice1.invoice.value)
invoice1.refund()
invoice2.pay("Pay", invoice2.invoice.value)
invoice2.refund()
return invoice1.invoice.value + invoice2.invoice.value
def test_mutiple_credit_notes_are_applied_when_generating_invoice_1(self):
''' Tests (1) that multiple credit notes are applied to new invoice.
Sum of credit note values will be *LESS* than the new invoice.
'''
notes_value = self._generate_multiple_credit_notes()
invoice = self._manual_invoice(notes_value + 1)
self.assertEqual(notes_value, invoice.invoice.total_payments())
self.assertTrue(invoice.invoice.is_unpaid)
user_unclaimed = commerce.CreditNote.unclaimed()
user_unclaimed = user_unclaimed.filter(invoice__user=self.USER_1)
self.assertEqual(0, user_unclaimed.count())
def test_mutiple_credit_notes_are_applied_when_generating_invoice_2(self):
''' Tests (2) that multiple credit notes are applied to new invoice.
Sum of credit note values will be *GREATER* than the new invoice.
'''
notes_value = self._generate_multiple_credit_notes()
invoice = self._manual_invoice(notes_value - 1)
self.assertEqual(notes_value - 1, invoice.invoice.total_payments())
self.assertTrue(invoice.invoice.is_paid)
user_unclaimed = commerce.CreditNote.unclaimed().filter(
invoice__user=self.USER_1
)
self.assertEqual(1, user_unclaimed.count())
excess = self._credit_note_for_invoice(invoice.invoice)
self.assertEqual(excess.credit_note.value, 1)
def test_credit_notes_are_left_over_if_not_all_are_needed(self):
''' Tests that excess credit notes are untouched if they're not needed
'''
notes_value = self._generate_multiple_credit_notes() # noqa
notes_old = commerce.CreditNote.unclaimed().filter(
invoice__user=self.USER_1
)
# Create a manual invoice whose value is smaller than any of the
# credit notes we created
invoice = self._manual_invoice(1) # noqa
notes_new = commerce.CreditNote.unclaimed().filter(
invoice__user=self.USER_1
)
# Item is True if the note was't consumed when generating invoice.
note_was_unused = [(i in notes_old) for i in notes_new]
self.assertIn(True, note_was_unused)
def test_credit_notes_are_not_applied_if_user_has_multiple_invoices(self):
# Have an invoice pending with no credit notes; no payment will be made
invoice1 = self._invoice_containing_prod_1(1) # noqa
# Create some credit notes.
self._generate_multiple_credit_notes()
invoice = self._manual_invoice(2)
# Because there's already an invoice open for this user
# The credit notes are not automatically applied.
self.assertEqual(0, invoice.invoice.total_payments())
self.assertTrue(invoice.invoice.is_unpaid)
def test_credit_notes_are_applied_even_if_some_notes_are_claimed(self):
for i in range(10):
# Generate credit note
invoice1 = self._manual_invoice(1)
invoice1.pay("Pay", invoice1.invoice.value)
invoice1.refund()
# Generate invoice that should be automatically paid
invoice2 = self._manual_invoice(1)
self.assertTrue(invoice2.invoice.is_paid)
def test_cancellation_fee_is_applied(self):
invoice1 = self._manual_invoice(1)
invoice1.pay("Pay", invoice1.invoice.value)
invoice1.refund()
percentage = 15
cn = self._credit_note_for_invoice(invoice1.invoice)
canc = cn.cancellation_fee(15)
# Cancellation fee exceeds the amount for the invoice.
self.assertTrue(canc.invoice.is_paid)
# Cancellation fee is equal to 15% of credit note's value
self.assertEqual(
canc.invoice.value,
cn.credit_note.value * percentage / 100
)
def test_cancellation_fee_is_applied_when_another_invoice_is_unpaid(self):
extra_invoice = self._manual_invoice(23) # noqa
self.test_cancellation_fee_is_applied()

View file

@ -0,0 +1,480 @@
import pytz
from decimal import Decimal
from registrasion.models import commerce
from registrasion.models import conditions
from registrasion.controllers.discount import DiscountController
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class DiscountTestCase(RegistrationCartTestCase):
@classmethod
def add_discount_prod_1_includes_prod_2(
cls,
amount=Decimal(100),
quantity=2,
):
discount = conditions.IncludedProductDiscount.objects.create(
description="PROD_1 includes PROD_2 " + str(amount) + "%",
)
discount.enabling_products.add(cls.PROD_1)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=cls.PROD_2,
percentage=amount,
quantity=quantity,
)
return discount
@classmethod
def add_discount_prod_1_includes_cat_2(
cls,
amount=Decimal(100),
quantity=2,
):
discount = conditions.IncludedProductDiscount.objects.create(
description="PROD_1 includes CAT_2 " + str(amount) + "%",
)
discount.enabling_products.add(cls.PROD_1)
conditions.DiscountForCategory.objects.create(
discount=discount,
category=cls.CAT_2,
percentage=amount,
quantity=quantity,
)
return discount
@classmethod
def add_discount_prod_1_includes_prod_3_and_prod_4(
cls,
amount=Decimal(100),
quantity=2,
):
discount = conditions.IncludedProductDiscount.objects.create(
description="PROD_1 includes PROD_3 and PROD_4 " +
str(amount) + "%",
)
discount.enabling_products.add(cls.PROD_1)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=cls.PROD_3,
percentage=amount,
quantity=quantity,
)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=cls.PROD_4,
percentage=amount,
quantity=quantity,
)
return discount
def test_discount_is_applied(self):
self.add_discount_prod_1_includes_prod_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.add_to_cart(self.PROD_2, 1)
# Discounts should be applied at this point...
self.assertEqual(1, len(cart.cart.discountitem_set.all()))
def test_discount_is_applied_for_category(self):
self.add_discount_prod_1_includes_cat_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.add_to_cart(self.PROD_3, 1)
# Discounts should be applied at this point...
self.assertEqual(1, len(cart.cart.discountitem_set.all()))
def test_discount_does_not_apply_if_not_met(self):
self.add_discount_prod_1_includes_prod_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
# No discount should be applied as the condition is not met
self.assertEqual(0, len(cart.cart.discountitem_set.all()))
def test_discount_applied_out_of_order(self):
self.add_discount_prod_1_includes_prod_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
cart.add_to_cart(self.PROD_1, 1)
# No discount should be applied as the condition is not met
self.assertEqual(1, len(cart.cart.discountitem_set.all()))
def test_discounts_collapse(self):
self.add_discount_prod_1_includes_prod_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.add_to_cart(self.PROD_2, 1)
cart.add_to_cart(self.PROD_2, 1)
# Discounts should be applied and collapsed at this point...
self.assertEqual(1, len(cart.cart.discountitem_set.all()))
def test_discounts_respect_quantity(self):
self.add_discount_prod_1_includes_prod_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.add_to_cart(self.PROD_2, 3)
# There should be three items in the cart, but only two should
# attract a discount.
discount_items = list(cart.cart.discountitem_set.all())
self.assertEqual(2, discount_items[0].quantity)
def test_multiple_discounts_apply_in_order(self):
discount_full = self.add_discount_prod_1_includes_prod_2()
discount_half = self.add_discount_prod_1_includes_prod_2(Decimal(50))
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.add_to_cart(self.PROD_2, 3)
# There should be two discounts
discount_items = list(cart.cart.discountitem_set.all())
discount_items.sort(key=lambda item: item.quantity)
self.assertEqual(2, len(discount_items))
# The half discount should be applied only once
self.assertEqual(1, discount_items[0].quantity)
self.assertEqual(discount_half.pk, discount_items[0].discount.pk)
# The full discount should be applied twice
self.assertEqual(2, discount_items[1].quantity)
self.assertEqual(discount_full.pk, discount_items[1].discount.pk)
def test_discount_applies_across_carts(self):
self.add_discount_prod_1_includes_prod_2()
# Enable the discount during the first cart.
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
cart.next_cart()
# Use the discount in the second cart
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
# The discount should be applied.
self.assertEqual(1, len(cart.cart.discountitem_set.all()))
cart.next_cart()
# The discount should respect the total quantity across all
# of the user's carts.
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 2)
# Having one item in the second cart leaves one more item where
# the discount is applicable. The discount should apply, but only for
# quantity=1
discount_items = list(cart.cart.discountitem_set.all())
self.assertEqual(1, discount_items[0].quantity)
def test_discount_applies_only_once_enabled(self):
# Enable the discount during the first cart.
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
# This would exhaust discount if present
cart.add_to_cart(self.PROD_2, 2)
cart.next_cart()
self.add_discount_prod_1_includes_prod_2()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 2)
discount_items = list(cart.cart.discountitem_set.all())
self.assertEqual(2, discount_items[0].quantity)
def test_category_discount_applies_once_per_category(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
# Add two items from category 2
cart.add_to_cart(self.PROD_3, 1)
cart.add_to_cart(self.PROD_4, 1)
discount_items = list(cart.cart.discountitem_set.all())
# There is one discount, and it should apply to one item.
self.assertEqual(1, len(discount_items))
self.assertEqual(1, discount_items[0].quantity)
def test_category_discount_applies_to_highest_value(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
# Add two items from category 2, add the less expensive one first
cart.add_to_cart(self.PROD_4, 1)
cart.add_to_cart(self.PROD_3, 1)
discount_items = list(cart.cart.discountitem_set.all())
# There is one discount, and it should apply to the more expensive.
self.assertEqual(1, len(discount_items))
self.assertEqual(self.PROD_3, discount_items[0].product)
def test_discount_quantity_is_per_user(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
# Both users should be able to apply the same discount
# in the same way
for user in (self.USER_1, self.USER_2):
cart = TestingCartController.for_user(user)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
cart.add_to_cart(self.PROD_3, 1)
discount_items = list(cart.cart.discountitem_set.all())
# The discount is applied.
self.assertEqual(1, len(discount_items))
def test_discount_applies_to_most_expensive_item(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
import itertools
prods = (self.PROD_3, self.PROD_4)
for first, second in itertools.permutations(prods, 2):
cart.set_quantity(first, 1)
cart.set_quantity(second, 1)
# There should only be one discount
discount_items = list(cart.cart.discountitem_set.all())
self.assertEqual(1, len(discount_items))
# It should always apply to PROD_3, as it costs more.
self.assertEqual(discount_items[0].product, self.PROD_3)
cart.set_quantity(first, 0)
cart.set_quantity(second, 0)
# Tests for the DiscountController.available_discounts enumerator
def test_enumerate_no_discounts_for_no_input(self):
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[],
)
self.assertEqual(0, len(discounts))
def test_enumerate_no_discounts_if_condition_not_met(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_3],
)
self.assertEqual(0, len(discounts))
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_2],
[],
)
self.assertEqual(0, len(discounts))
def test_category_discount_appears_once_if_met_twice(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_2],
[self.PROD_3],
)
self.assertEqual(1, len(discounts))
def test_category_discount_appears_with_category(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_2],
[],
)
self.assertEqual(1, len(discounts))
def test_category_discount_appears_with_product(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_3],
)
self.assertEqual(1, len(discounts))
def test_category_discount_appears_once_with_two_valid_product(self):
self.add_discount_prod_1_includes_cat_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_3, self.PROD_4]
)
self.assertEqual(1, len(discounts))
def test_product_discount_appears_with_product(self):
self.add_discount_prod_1_includes_prod_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_2],
)
self.assertEqual(1, len(discounts))
def test_product_discount_does_not_appear_with_category(self):
self.add_discount_prod_1_includes_prod_2(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_1],
[],
)
self.assertEqual(0, len(discounts))
def test_discount_quantity_is_correct_before_first_purchase(self):
self.add_discount_prod_1_includes_cat_2(quantity=2)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_2],
[],
)
self.assertEqual(2, discounts[0].quantity)
cart.next_cart()
def test_discount_quantity_is_correct_after_first_purchase(self):
self.test_discount_quantity_is_correct_before_first_purchase()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_2],
[],
)
self.assertEqual(1, discounts[0].quantity)
cart.next_cart()
def test_discount_is_gone_after_quantity_exhausted(self):
self.test_discount_quantity_is_correct_after_first_purchase()
discounts = DiscountController.available_discounts(
self.USER_1,
[self.CAT_2],
[],
)
self.assertEqual(0, len(discounts))
def test_product_discount_enabled_twice_appears_twice(self):
self.add_discount_prod_1_includes_prod_3_and_prod_4(quantity=2)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_3, self.PROD_4],
)
self.assertEqual(2, len(discounts))
def test_product_discount_applied_on_different_invoices(self):
# quantity=1 means "quantity per product"
self.add_discount_prod_1_includes_prod_3_and_prod_4(quantity=1)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_3, self.PROD_4],
)
self.assertEqual(2, len(discounts))
# adding one of PROD_3 should make it no longer an available discount.
cart.add_to_cart(self.PROD_3, 1)
cart.next_cart()
# should still have (and only have) the discount for prod_4
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_3, self.PROD_4],
)
self.assertEqual(1, len(discounts))
def test_discounts_are_released_by_refunds(self):
self.add_discount_prod_1_includes_prod_2(quantity=2)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1) # Enable the discount
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_2],
)
self.assertEqual(1, len(discounts))
cart.next_cart()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted
cart.next_cart()
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_2],
)
self.assertEqual(0, len(discounts))
cart.cart.status = commerce.Cart.STATUS_RELEASED
cart.cart.save()
discounts = DiscountController.available_discounts(
self.USER_1,
[],
[self.PROD_2],
)
self.assertEqual(1, len(discounts))

396
vendor/registrasion/tests/test_flag.py vendored Normal file
View file

@ -0,0 +1,396 @@
import pytz
from django.core.exceptions import ValidationError
from registrasion.models import commerce
from registrasion.models import conditions
from registrasion.controllers.category import CategoryController
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.controller_helpers import TestingInvoiceController
from registrasion.controllers.product import ProductController
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class FlagTestCases(RegistrationCartTestCase):
@classmethod
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 = conditions.ProductFlag.objects.create(
description="Product condition",
condition=condition,
)
flag.products.add(cls.PROD_1)
flag.enabling_products.add(cls.PROD_2)
@classmethod
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 = conditions.ProductFlag.objects.create(
description="Product condition",
condition=condition,
)
flag.categories.add(cls.CAT_1)
flag.enabling_products.add(cls.PROD_3)
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 = conditions.CategoryFlag.objects.create(
description="Category condition",
condition=condition,
enabling_category=cls.CAT_2,
)
flag.products.add(cls.PROD_1)
def test_product_flag_enables_product(self):
self.add_product_flag()
# Cannot buy PROD_1 without buying PROD_2
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 1)
current_cart.add_to_cart(self.PROD_2, 1)
current_cart.add_to_cart(self.PROD_1, 1)
def test_product_enabled_by_product_in_previous_cart(self):
self.add_product_flag()
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.add_to_cart(self.PROD_2, 1)
current_cart.next_cart()
# Create new cart and try to add PROD_1
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.add_to_cart(self.PROD_1, 1)
def test_product_flag_enables_category(self):
self.add_product_flag_on_category()
# Cannot buy PROD_1 without buying item from CAT_2
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 1)
current_cart.add_to_cart(self.PROD_3, 1)
current_cart.add_to_cart(self.PROD_1, 1)
def test_category_flag_enables_product(self):
self.add_category_flag()
# Cannot buy PROD_1 without buying PROD_2
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 1)
# PROD_3 is in CAT_2
current_cart.add_to_cart(self.PROD_3, 1)
current_cart.add_to_cart(self.PROD_1, 1)
def test_product_enabled_by_category_in_previous_cart(self):
self.add_category_flag()
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.add_to_cart(self.PROD_3, 1)
current_cart.next_cart()
# Create new cart and try to add PROD_1
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.add_to_cart(self.PROD_1, 1)
def test_multiple_eit_conditions(self):
self.add_product_flag()
self.add_category_flag()
# User 1 is testing the product flag condition
cart_1 = TestingCartController.for_user(self.USER_1)
# Cannot add PROD_1 until a condition is met
with self.assertRaises(ValidationError):
cart_1.add_to_cart(self.PROD_1, 1)
cart_1.add_to_cart(self.PROD_2, 1)
cart_1.add_to_cart(self.PROD_1, 1)
# User 2 is testing the category flag condition
cart_2 = TestingCartController.for_user(self.USER_2)
# Cannot add PROD_1 until a condition is met
with self.assertRaises(ValidationError):
cart_2.add_to_cart(self.PROD_1, 1)
cart_2.add_to_cart(self.PROD_3, 1)
cart_2.add_to_cart(self.PROD_1, 1)
def test_multiple_dif_conditions(self):
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
with self.assertRaises(ValidationError):
cart_1.add_to_cart(self.PROD_1, 1)
cart_1.add_to_cart(self.PROD_2, 1) # Meets the product condition
with self.assertRaises(ValidationError):
cart_1.add_to_cart(self.PROD_1, 1)
cart_1.add_to_cart(self.PROD_3, 1) # Meets the category condition
cart_1.add_to_cart(self.PROD_1, 1)
def test_eit_and_dif_conditions_work_together(self):
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
with self.assertRaises(ValidationError):
cart_1.add_to_cart(self.PROD_1, 1)
cart_1.add_to_cart(self.PROD_2, 1) # Meets the EIT condition
# Need to meet both conditions before you can add
with self.assertRaises(ValidationError):
cart_1.add_to_cart(self.PROD_1, 1)
cart_1.set_quantity(self.PROD_2, 0) # Un-meets the EIT condition
cart_1.add_to_cart(self.PROD_3, 1) # Meets the DIF condition
# Need to meet both conditions before you can add
with self.assertRaises(ValidationError):
cart_1.add_to_cart(self.PROD_1, 1)
cart_1.add_to_cart(self.PROD_2, 1) # Meets the EIT condition
# Now that both conditions are met, we can add the product
cart_1.add_to_cart(self.PROD_1, 1)
def test_available_products_works_with_no_conditions_set(self):
prods = ProductController.available_products(
self.USER_1,
category=self.CAT_1,
)
self.assertTrue(self.PROD_1 in prods)
self.assertTrue(self.PROD_2 in prods)
prods = ProductController.available_products(
self.USER_1,
category=self.CAT_2,
)
self.assertTrue(self.PROD_3 in prods)
self.assertTrue(self.PROD_4 in prods)
prods = ProductController.available_products(
self.USER_1,
products=[self.PROD_1, self.PROD_2, self.PROD_3, self.PROD_4],
)
self.assertTrue(self.PROD_1 in prods)
self.assertTrue(self.PROD_2 in prods)
self.assertTrue(self.PROD_3 in prods)
self.assertTrue(self.PROD_4 in prods)
def test_available_products_on_category_works_when_condition_not_met(self):
self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE)
prods = ProductController.available_products(
self.USER_1,
category=self.CAT_1,
)
self.assertTrue(self.PROD_1 not in prods)
self.assertTrue(self.PROD_2 in prods)
def test_available_products_on_category_works_when_condition_is_met(self):
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)
prods = ProductController.available_products(
self.USER_1,
category=self.CAT_1,
)
self.assertTrue(self.PROD_1 in prods)
self.assertTrue(self.PROD_2 in prods)
def test_available_products_on_products_works_when_condition_not_met(self):
self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE)
prods = ProductController.available_products(
self.USER_1,
products=[self.PROD_1, self.PROD_2],
)
self.assertTrue(self.PROD_1 not in prods)
self.assertTrue(self.PROD_2 in prods)
def test_available_products_on_products_works_when_condition_is_met(self):
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)
prods = ProductController.available_products(
self.USER_1,
products=[self.PROD_1, self.PROD_2],
)
self.assertTrue(self.PROD_1 in prods)
self.assertTrue(self.PROD_2 in prods)
def test_category_flag_fails_if_cart_refunded(self):
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)
cart.next_cart()
cart_2 = TestingCartController.for_user(self.USER_1)
cart_2.add_to_cart(self.PROD_1, 1)
cart_2.set_quantity(self.PROD_1, 0)
cart.cart.status = commerce.Cart.STATUS_RELEASED
cart.cart.save()
with self.assertRaises(ValidationError):
cart_2.set_quantity(self.PROD_1, 1)
def test_product_flag_fails_if_cart_refunded(self):
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)
cart.next_cart()
cart_2 = TestingCartController.for_user(self.USER_1)
cart_2.add_to_cart(self.PROD_1, 1)
cart_2.set_quantity(self.PROD_1, 0)
cart.cart.status = commerce.Cart.STATUS_RELEASED
cart.cart.save()
with self.assertRaises(ValidationError):
cart_2.set_quantity(self.PROD_1, 1)
def test_available_categories(self):
self.add_product_flag_on_category(
condition=conditions.FlagBase.ENABLE_IF_TRUE,
)
cart_1 = TestingCartController.for_user(self.USER_1)
cats = CategoryController.available_categories(
self.USER_1,
)
self.assertFalse(self.CAT_1 in cats)
self.assertTrue(self.CAT_2 in cats)
cart_1.add_to_cart(self.PROD_3, 1)
cats = CategoryController.available_categories(
self.USER_1,
)
self.assertTrue(self.CAT_1 in cats)
self.assertTrue(self.CAT_2 in cats)
def test_validate_cart_when_flags_become_unmet(self):
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)
cart.add_to_cart(self.PROD_1, 1)
# Should pass
cart.validate_cart()
cart.set_quantity(self.PROD_2, 0)
# Should fail
with self.assertRaises(ValidationError):
cart.validate_cart()
def test_fix_simple_errors_resolves_unavailable_products(self):
self.test_validate_cart_when_flags_become_unmet()
cart = TestingCartController.for_user(self.USER_1)
# Should just remove all of the unavailable products
cart.fix_simple_errors()
# Should now succeed
cart.validate_cart()
# Should keep PROD_2 in the 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):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
cart.add_to_cart(self.PROD_1, 10)
# Should just remove all of the unavailable products
cart.fix_simple_errors()
# Should now succeed
cart.validate_cart()
# Should keep PROD_2 in the cart
# and also PROD_1, which is now exhausted for user.
items = commerce.ProductItem.objects.filter(cart=cart.cart)
self.assertTrue([i for i in items if i.product == self.PROD_1])
def test_product_stays_enabled_even_if_some_are_cancelled(self):
''' Flags should be enabled, even if *some* enabling products are cnx.
Tests issue #68.
'''
self.add_product_flag()
cart1 = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
# Can't do this without PROD_2
cart1.add_to_cart(self.PROD_1, 1)
cart1.add_to_cart(self.PROD_2, 1)
inv = TestingInvoiceController.for_cart(cart1.cart)
inv.pay("Lol", inv.invoice.value)
cart2 = TestingCartController.for_user(self.USER_1)
cart2.add_to_cart(self.PROD_2, 1)
inv.refund()
# Even though cart1 has been cancelled, we have the item in cart2.
# So we should be able to add PROD_1, which depends on PROD_2
cart2.add_to_cart(self.PROD_1, 1)
cart2.set_quantity(self.PROD_2, 0)
with self.assertRaises(ValidationError):
cart2.add_to_cart(self.PROD_1, 1)
def test_flag_failures_only_break_affected_products(self):
''' If a flag fails, it should only affect its own products. '''
self.add_product_flag()
cart1 = TestingCartController.for_user(self.USER_1)
cart1.add_to_cart(self.PROD_2, 1)
cart1.add_to_cart(self.PROD_1, 1)
cart1.set_quantity(self.PROD_2, 0)
# The following should not fail, as PROD_3 is not affected by flag.
cart1.add_to_cart(self.PROD_3, 1)

View file

@ -0,0 +1,68 @@
import pytz
from django.contrib.auth.models import Group
from registrasion.models import conditions
from registrasion.controllers.product import ProductController
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class GroupMemberTestCase(RegistrationCartTestCase):
@classmethod
def _create_group_and_flag(cls):
''' Creates cls.GROUP_1, and restricts cls.PROD_1 only to users who are
members of the group. Likewise GROUP_2 and PROD_2 '''
groups = []
products = [cls.PROD_1, cls.PROD_2]
for i, product in enumerate(products):
group = Group.objects.create(name="TEST GROUP" + str(i))
flag = conditions.GroupMemberFlag.objects.create(
description="Group member flag " + str(i),
condition=conditions.FlagBase.ENABLE_IF_TRUE,
)
flag.group.add(group)
flag.products.add(product)
groups.append(group)
cls.GROUP_1 = groups[0]
cls.GROUP_2 = groups[1]
def test_product_not_enabled_until_user_joins_group(self):
''' Tests that GroupMemberFlag disables a product for a user until
they are a member of a specific group. '''
self._create_group_and_flag()
groups = [self.GROUP_1, self.GROUP_2]
products = [self.PROD_1, self.PROD_2]
for group, product in zip(groups, products):
# USER_1 cannot see PROD_1 until they're in GROUP.
available = ProductController.available_products(
self.USER_1,
products=[product],
)
self.assertNotIn(product, available)
self.USER_1.groups.add(group)
# USER_1 cannot see PROD_1 until they're in GROUP.
available = ProductController.available_products(
self.USER_1,
products=[product],
)
self.assertIn(product, available)
# USER_2 is still locked out
available = ProductController.available_products(
self.USER_2,
products=[product],
)
self.assertNotIn(product, available)

View file

@ -0,0 +1,27 @@
import datetime
from registrasion.models import commerce
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.controller_helpers import TestingCreditNoteController
from registrasion.tests.controller_helpers import TestingInvoiceController
class TestHelperMixin(object):
def _invoice_containing_prod_1(self, qty=1):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, qty)
return TestingInvoiceController.for_cart(self.reget(cart.cart))
def _manual_invoice(self, value=1):
items = [("Item", value)]
due = datetime.timedelta(hours=1)
inv = TestingInvoiceController.manual_invoice(self.USER_1, due, items)
return TestingInvoiceController(inv)
def _credit_note_for_invoice(self, invoice):
note = commerce.CreditNote.objects.get(invoice=invoice)
return TestingCreditNoteController(note)

View file

@ -0,0 +1,376 @@
import datetime
import pytz
from decimal import Decimal
from django.core.exceptions import ValidationError
from registrasion.models import commerce
from registrasion.models import conditions
from registrasion.models import inventory
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.controller_helpers import TestingInvoiceController
from registrasion.tests.test_helpers import TestHelperMixin
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase):
def test_create_invoice(self):
current_cart = TestingCartController.for_user(self.USER_1)
# Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
# That invoice should have a single line item
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)
# Adding item to cart should produce a new invoice
current_cart.add_to_cart(self.PROD_2, 1)
invoice_2 = TestingInvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
# The old invoice should automatically be voided
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 = 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(
self.PROD_1.price + self.PROD_2.price,
invoice_2.invoice.value)
def test_invoice_controller_for_id_works(self):
invoice = self._invoice_containing_prod_1(1)
id_ = invoice.invoice.id
invoice1 = TestingInvoiceController.for_id(id_)
invoice2 = TestingInvoiceController.for_id(str(id_))
self.assertEqual(invoice.invoice, invoice1.invoice)
self.assertEqual(invoice.invoice, invoice2.invoice)
def test_create_invoice_fails_if_cart_invalid(self):
self.make_ceiling("Limit ceiling", limit=1)
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.add_to_cart(self.PROD_1, 1)
self.add_timedelta(self.RESERVATION * 2)
cart_2 = TestingCartController.for_user(self.USER_2)
cart_2.add_to_cart(self.PROD_1, 1)
# Now try to invoice the first user
with self.assertRaises(ValidationError):
TestingInvoiceController.for_cart(current_cart.cart)
def test_paying_invoice_makes_new_cart(self):
invoice = self._invoice_containing_prod_1(1)
invoice.pay("A payment!", invoice.invoice.value)
# This payment is for the correct amount invoice should be paid.
self.assertTrue(invoice.invoice.is_paid)
# Cart should not be active
self.assertNotEqual(
commerce.Cart.STATUS_ACTIVE,
invoice.invoice.cart.status,
)
# Asking for a cart should generate a new one
new_cart = TestingCartController.for_user(self.USER_1)
self.assertNotEqual(invoice.invoice.cart, new_cart.cart)
def test_total_payments_balance_due(self):
invoice = self._invoice_containing_prod_1(2)
# range only takes int, and the following logic fails if not a round
# number. So fail if we are not a round number so developer may fix
# this test or the product.
self.assertTrue((invoice.invoice.value % 1).is_zero())
for i in range(0, int(invoice.invoice.value)):
self.assertTrue(
i + 1, invoice.invoice.total_payments()
)
self.assertTrue(
invoice.invoice.value - i, invoice.invoice.balance_due()
)
invoice.pay("Pay 1", 1)
def test_invoice_includes_discounts(self):
voucher = inventory.Voucher.objects.create(
recipient="Voucher recipient",
code="VOUCHER",
limit=1
)
discount = conditions.VoucherDiscount.objects.create(
description="VOUCHER RECIPIENT",
voucher=voucher,
)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=self.PROD_1,
percentage=Decimal(50),
quantity=1
)
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
# Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
# That invoice should have two line items
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(
self.PROD_1.price * Decimal("0.5"),
invoice_1.invoice.value)
def _make_zero_value_invoice(self):
voucher = inventory.Voucher.objects.create(
recipient="Voucher recipient",
code="VOUCHER",
limit=1
)
discount = conditions.VoucherDiscount.objects.create(
description="VOUCHER RECIPIENT",
voucher=voucher,
)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=self.PROD_1,
percentage=Decimal(100),
quantity=1
)
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
# Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1)
return TestingInvoiceController.for_cart(current_cart.cart)
def test_zero_value_invoice_is_automatically_paid(self):
invoice_1 = self._make_zero_value_invoice()
self.assertTrue(invoice_1.invoice.is_paid)
def test_refunding_zero_value_invoice_releases_cart(self):
invoice_1 = self._make_zero_value_invoice()
cart = invoice_1.invoice.cart
invoice_1.refund()
cart.refresh_from_db()
self.assertEquals(commerce.Cart.STATUS_RELEASED, cart.status)
def test_invoice_voids_self_if_cart_changes(self):
current_cart = TestingCartController.for_user(self.USER_1)
# Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1)
invoice_1 = TestingInvoiceController.for_cart(current_cart.cart)
self.assertFalse(invoice_1.invoice.is_void)
# Adding item to cart should produce a new invoice
current_cart.add_to_cart(self.PROD_2, 1)
invoice_2 = TestingInvoiceController.for_cart(current_cart.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
# Viewing invoice_1's invoice should show it as void
invoice_1_new = TestingInvoiceController(invoice_1.invoice)
self.assertTrue(invoice_1_new.invoice.is_void)
# Viewing invoice_2's invoice should *not* show it as void
invoice_2_new = TestingInvoiceController(invoice_2.invoice)
self.assertFalse(invoice_2_new.invoice.is_void)
def test_invoice_voids_self_if_cart_becomes_invalid(self):
''' Invoices should be void if cart becomes invalid over time '''
self.make_ceiling("Limit ceiling", limit=1)
self.set_time(datetime.datetime(
year=2015, month=1, day=1, hour=0, minute=0, tzinfo=UTC,
))
cart1 = TestingCartController.for_user(self.USER_1)
cart2 = TestingCartController.for_user(self.USER_2)
# Create a valid invoice for USER_1
cart1.add_to_cart(self.PROD_1, 1)
inv1 = TestingInvoiceController.for_cart(cart1.cart)
# Expire the reservations, and have USER_2 take up PROD_1's ceiling
# generate an invoice
self.add_timedelta(self.RESERVATION * 2)
cart2.add_to_cart(self.PROD_2, 1)
TestingInvoiceController.for_cart(cart2.cart)
# Re-get inv1's invoice; it should void itself on loading.
inv1 = TestingInvoiceController(inv1.invoice)
self.assertTrue(inv1.invoice.is_void)
def test_voiding_invoice_creates_new_invoice(self):
invoice_1 = self._invoice_containing_prod_1(1)
self.assertFalse(invoice_1.invoice.is_void)
invoice_1.void()
invoice_2 = TestingInvoiceController.for_cart(invoice_1.invoice.cart)
self.assertNotEqual(invoice_1.invoice, invoice_2.invoice)
def test_cannot_pay_void_invoice(self):
invoice_1 = self._invoice_containing_prod_1(1)
invoice_1.void()
with self.assertRaises(ValidationError):
invoice_1.validate_allowed_to_pay()
def test_cannot_void_paid_invoice(self):
invoice = self._invoice_containing_prod_1(1)
invoice.pay("Reference", invoice.invoice.value)
with self.assertRaises(ValidationError):
invoice.void()
def test_cannot_void_partially_paid_invoice(self):
invoice = self._invoice_containing_prod_1(1)
invoice.pay("Reference", invoice.invoice.value - 1)
self.assertTrue(invoice.invoice.is_unpaid)
with self.assertRaises(ValidationError):
invoice.void()
def test_cannot_generate_blank_invoice(self):
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
TestingInvoiceController.for_cart(current_cart.cart)
def test_cannot_pay_implicitly_void_invoice(self):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(self.reget(cart.cart))
# Implicitly void the invoice
cart.add_to_cart(self.PROD_1, 1)
with self.assertRaises(ValidationError):
invoice.validate_allowed_to_pay()
def test_required_category_constraints_prevent_invoicing(self):
self.CAT_1.required = True
self.CAT_1.save()
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_3, 1)
# CAT_1 is required, we don't have CAT_1 yet
with self.assertRaises(ValidationError):
invoice = TestingInvoiceController.for_cart(cart.cart)
# Now that we have CAT_1, we can check out the cart
cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(cart.cart)
# Paying for the invoice should work fine
invoice.pay("Boop", invoice.invoice.value)
# We have an item in the first cart, so should be able to invoice
# for the second cart, even without CAT_1 in it.
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_3, 1)
invoice2 = TestingInvoiceController.for_cart(cart.cart)
# Void invoice2, and release the first cart
# now we don't have any CAT_1
invoice2.void()
invoice.refund()
# Now that we don't have CAT_1, we can't checkout this cart
with self.assertRaises(ValidationError):
invoice = TestingInvoiceController.for_cart(cart.cart)
def test_can_generate_manual_invoice(self):
description_price_pairs = [
("Item 1", 15),
("Item 2", 30),
]
due_delta = datetime.timedelta(hours=24)
_invoice = TestingInvoiceController.manual_invoice(
self.USER_1, due_delta, description_price_pairs
)
inv = TestingInvoiceController(_invoice)
self.assertEquals(
inv.invoice.value,
sum(i[1] for i in description_price_pairs)
)
self.assertEquals(
len(inv.invoice.lineitem_set.all()),
len(description_price_pairs)
)
inv.pay("Demo payment", inv.invoice.value)
def test_sends_email_on_invoice_creation(self):
invoice = self._invoice_containing_prod_1(1)
self.assertEquals(1, len(self.emails))
email = self.emails[0]
self.assertEquals([self.USER_1.email], email["to"])
self.assertEquals("invoice_created", email["kind"])
self.assertEquals(invoice.invoice, email["context"]["invoice"])
def test_sends_first_change_email_on_invoice_fully_paid(self):
invoice = self._invoice_containing_prod_1(1)
self.assertEquals(1, len(self.emails))
invoice.pay("Partial", invoice.invoice.value - 1)
# Should have an "invoice_created" email and nothing else.
self.assertEquals(1, len(self.emails))
invoice.pay("Remainder", 1)
self.assertEquals(2, len(self.emails))
email = self.emails[1]
self.assertEquals([self.USER_1.email], email["to"])
self.assertEquals("invoice_updated", email["kind"])
self.assertEquals(invoice.invoice, email["context"]["invoice"])
def test_sends_email_when_invoice_refunded(self):
invoice = self._invoice_containing_prod_1(1)
self.assertEquals(1, len(self.emails))
invoice.pay("Payment", invoice.invoice.value)
self.assertEquals(2, len(self.emails))
invoice.refund()
self.assertEquals(3, len(self.emails))
email = self.emails[2]
self.assertEquals([self.USER_1.email], email["to"])
self.assertEquals("invoice_updated", email["kind"])
self.assertEquals(invoice.invoice, email["context"]["invoice"])

View file

@ -0,0 +1,38 @@
import pytz
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.controller_helpers import TestingInvoiceController
from registrasion.tests.test_cart import RegistrationCartTestCase
from registrasion.models import commerce
UTC = pytz.timezone('UTC')
class RefundTestCase(RegistrationCartTestCase):
def test_refund_marks_void_and_unpaid_and_cart_released(self):
current_cart = TestingCartController.for_user(self.USER_1)
# Should be able to create an invoice after the product is added
current_cart.add_to_cart(self.PROD_1, 1)
invoice = TestingInvoiceController.for_cart(current_cart.cart)
invoice.pay("A Payment!", invoice.invoice.value)
self.assertFalse(invoice.invoice.is_void)
self.assertTrue(invoice.invoice.is_paid)
self.assertFalse(invoice.invoice.is_refunded)
self.assertNotEqual(
commerce.Cart.STATUS_RELEASED,
invoice.invoice.cart.status,
)
invoice.refund()
self.assertFalse(invoice.invoice.is_void)
self.assertFalse(invoice.invoice.is_paid)
self.assertTrue(invoice.invoice.is_refunded)
self.assertEqual(
commerce.Cart.STATUS_RELEASED,
invoice.invoice.cart.status,
)

View file

@ -0,0 +1,231 @@
import pytz
from registrasion.models import conditions
from registrasion.controllers.product import ProductController
from symposion.conference import models as conference_models
from symposion.proposals import models as proposal_models
from symposion.reviews.models import promote_proposal
from symposion.schedule import models as schedule_models
from symposion.speakers import models as speaker_models
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class SpeakerTestCase(RegistrationCartTestCase):
@classmethod
def _create_proposals(cls):
''' Creates two proposals:
- User 1 will be presenter
- User 2 will be an additional presenter
Each proposal is of a different ProposalKind.
'''
conference = conference_models.Conference.objects.create(
title="TEST CONFERENCE.",
)
section = conference_models.Section.objects.create(
conference=conference,
name="TEST_SECTION",
slug="testsection",
)
proposal_section = proposal_models.ProposalSection.objects.create( # noqa
section=section,
closed=False,
published=False,
)
kind_1 = proposal_models.ProposalKind.objects.create(
section=section,
name="Kind 1",
slug="kind1",
)
kind_2 = proposal_models.ProposalKind.objects.create(
section=section,
name="Kind 2",
slug="kind2",
)
speaker_1 = speaker_models.Speaker.objects.create(
user=cls.USER_1,
name="Speaker 1",
annotation="",
)
speaker_2 = speaker_models.Speaker.objects.create(
user=cls.USER_2,
name="Speaker 2",
annotation="",
)
proposal_1 = proposal_models.ProposalBase.objects.create(
kind=kind_1,
title="Proposal 1",
abstract="Abstract",
private_abstract="Private Abstract",
speaker=speaker_1,
)
proposal_models.AdditionalSpeaker.objects.create(
speaker=speaker_2,
proposalbase=proposal_1,
status=proposal_models.AdditionalSpeaker.SPEAKING_STATUS_ACCEPTED,
)
proposal_2 = proposal_models.ProposalBase.objects.create(
kind=kind_2,
title="Proposal 2",
abstract="Abstract",
private_abstract="Private Abstract",
speaker=speaker_1,
)
proposal_models.AdditionalSpeaker.objects.create(
speaker=speaker_2,
proposalbase=proposal_2,
status=proposal_models.AdditionalSpeaker.SPEAKING_STATUS_ACCEPTED,
)
cls.KIND_1 = kind_1
cls.KIND_2 = kind_2
cls.PROPOSAL_1 = proposal_1
cls.PROPOSAL_2 = proposal_2
@classmethod
def _create_flag_for_primary_speaker(cls):
''' Adds flag -- PROD_1 is not available unless user is a primary
presenter of a KIND_1 '''
flag = conditions.SpeakerFlag.objects.create(
description="User must be presenter",
condition=conditions.FlagBase.ENABLE_IF_TRUE,
is_presenter=True,
is_copresenter=False,
)
flag.proposal_kind.add(cls.KIND_1)
flag.products.add(cls.PROD_1)
@classmethod
def _create_flag_for_additional_speaker(cls):
''' Adds flag -- PROD_1 is not available unless user is a primary
presenter of a KIND_2 '''
flag = conditions.SpeakerFlag.objects.create(
description="User must be copresenter",
condition=conditions.FlagBase.ENABLE_IF_TRUE,
is_presenter=False,
is_copresenter=True,
)
flag.proposal_kind.add(cls.KIND_1)
flag.products.add(cls.PROD_1)
def test_create_proposals(self):
self._create_proposals()
self.assertIsNotNone(self.KIND_1)
self.assertIsNotNone(self.KIND_2)
self.assertIsNotNone(self.PROPOSAL_1)
self.assertIsNotNone(self.PROPOSAL_2)
def test_primary_speaker_enables_item(self):
self._create_proposals()
self._create_flag_for_primary_speaker()
# USER_1 cannot see PROD_1 until proposal is promoted.
available = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available)
# promote proposal_1 so that USER_1 becomes a speaker
promote_proposal(self.PROPOSAL_1)
# USER_1 can see PROD_1
available_1 = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertIn(self.PROD_1, available_1)
# USER_2 can *NOT* see PROD_1 because they're a copresenter
available_2 = ProductController.available_products(
self.USER_2,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available_2)
def test_additional_speaker_enables_item(self):
self._create_proposals()
self._create_flag_for_additional_speaker()
# USER_2 cannot see PROD_1 until proposal is promoted.
available = ProductController.available_products(
self.USER_2,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available)
# promote proposal_1 so that USER_2 becomes an additional speaker
promote_proposal(self.PROPOSAL_1)
# USER_2 can see PROD_1
available_2 = ProductController.available_products(
self.USER_2,
products=[self.PROD_1],
)
self.assertIn(self.PROD_1, available_2)
# USER_1 can *NOT* see PROD_1 because they're a presenter
available_1 = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available_1)
def test_speaker_on_different_proposal_kind_does_not_enable_item(self):
self._create_proposals()
self._create_flag_for_primary_speaker()
# USER_1 cannot see PROD_1 until proposal is promoted.
available = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available)
# promote proposal_2 so that USER_1 becomes a speaker, but of
# KIND_2, which is not covered by this condition
promote_proposal(self.PROPOSAL_2)
# USER_1 cannot see PROD_1
available_1 = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available_1)
def test_proposal_cancelled_disables_condition(self):
self._create_proposals()
self._create_flag_for_primary_speaker()
# USER_1 cannot see PROD_1 until proposal is promoted.
available = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available)
# promote proposal_1 so that USER_1 becomes a speaker
promote_proposal(self.PROPOSAL_1)
presentation = schedule_models.Presentation.objects.get(
proposal_base=self.PROPOSAL_1
)
presentation.cancelled = True
presentation.save()
# USER_1 can *NOT* see PROD_1 because proposal_1 has been cancelled
available_after_cancelled = ProductController.available_products(
self.USER_1,
products=[self.PROD_1],
)
self.assertNotIn(self.PROD_1, available_after_cancelled)

View file

@ -0,0 +1,160 @@
import datetime
import pytz
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db import transaction
from registrasion.models import conditions
from registrasion.models import inventory
from registrasion.tests.controller_helpers import TestingCartController
from registrasion.tests.controller_helpers import TestingInvoiceController
from registrasion.tests.test_cart import RegistrationCartTestCase
UTC = pytz.timezone('UTC')
class VoucherTestCases(RegistrationCartTestCase):
def test_apply_voucher(self):
voucher = self.new_voucher()
self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC))
cart_1 = TestingCartController.for_user(self.USER_1)
cart_1.apply_voucher(voucher.code)
self.assertIn(voucher, cart_1.cart.vouchers.all())
# Second user should not be able to apply this voucher (it's exhausted)
cart_2 = TestingCartController.for_user(self.USER_2)
with self.assertRaises(ValidationError):
cart_2.apply_voucher(voucher.code)
# After the reservation duration
# user 2 should be able to apply voucher
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(inventory.Voucher.RESERVATION_DURATION * 2)
with self.assertRaises(ValidationError):
cart_1.validate_cart()
def test_fix_simple_errors_resolves_unavailable_voucher(self):
self.test_apply_voucher()
# User has an exhausted voucher leftover from test_apply_voucher
cart_1 = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
cart_1.validate_cart()
cart_1.fix_simple_errors()
# This should work now.
cart_1.validate_cart()
def test_voucher_enables_item(self):
voucher = self.new_voucher()
flag = conditions.VoucherFlag.objects.create(
description="Voucher condition",
voucher=voucher,
condition=conditions.FlagBase.ENABLE_IF_TRUE,
)
flag.products.add(self.PROD_1)
# Adding the product without a voucher will not work
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.add_to_cart(self.PROD_1, 1)
# Apply the voucher
current_cart.apply_voucher(voucher.code)
current_cart.add_to_cart(self.PROD_1, 1)
def test_voucher_enables_discount(self):
voucher = self.new_voucher()
discount = conditions.VoucherDiscount.objects.create(
description="VOUCHER RECIPIENT",
voucher=voucher,
)
conditions.DiscountForProduct.objects.create(
discount=discount,
product=self.PROD_1,
percentage=Decimal(100),
quantity=1
)
# Having PROD_1 in place should add a discount
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
current_cart.add_to_cart(self.PROD_1, 1)
self.assertEqual(1, len(current_cart.cart.discountitem_set.all()))
@transaction.atomic
def test_voucher_codes_unique(self):
self.new_voucher(code="VOUCHER")
with self.assertRaises(IntegrityError):
self.new_voucher(code="VOUCHER")
def test_multiple_vouchers_work(self):
self.new_voucher(code="VOUCHER1")
self.new_voucher(code="VOUCHER2")
def test_vouchers_case_insensitive(self):
voucher = self.new_voucher(code="VOUCHeR")
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code.lower())
def test_voucher_can_only_be_applied_once(self):
voucher = self.new_voucher(limit=2)
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
current_cart.apply_voucher(voucher.code)
# You can apply the code twice, but it will only add to the cart once.
self.assertEqual(1, current_cart.cart.vouchers.count())
def test_voucher_can_only_be_applied_once_across_multiple_carts(self):
voucher = self.new_voucher(limit=2)
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
current_cart.next_cart()
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code)
return current_cart
def test_refund_releases_used_vouchers(self):
voucher = self.new_voucher(limit=2)
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
current_cart.add_to_cart(self.PROD_1, 1)
inv = TestingInvoiceController.for_cart(current_cart.cart)
if not inv.invoice.is_paid:
inv.pay("Hello!", inv.invoice.value)
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code)
inv.refund()
current_cart.apply_voucher(voucher.code)
def test_fix_simple_errors_does_not_remove_limited_voucher(self):
voucher = self.new_voucher(code="VOUCHER")
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
current_cart.fix_simple_errors()
self.assertEqual(1, current_cart.cart.vouchers.count())

78
vendor/registrasion/urls.py vendored Normal file
View file

@ -0,0 +1,78 @@
from registrasion.reporting import views as rv
from django.conf.urls import include
from django.conf.urls import url
from .views import (
amend_registration,
badge,
badges,
checkout,
credit_note,
edit_profile,
extend_reservation,
guided_registration,
invoice,
invoice_access,
invoice_mailout,
manual_payment,
product_category,
refund,
review,
)
public = [
url(r"^amend/([0-9]+)$", amend_registration, name="amend_registration"),
url(r"^badge/([0-9]+)$", badge, name="badge"),
url(r"^badges$", badges, name="badges"),
url(r"^category/([0-9]+)$", product_category, name="product_category"),
url(r"^checkout$", checkout, name="checkout"),
url(r"^checkout/([0-9]+)$", checkout, name="checkout"),
url(r"^credit_note/([0-9]+)$", credit_note, name="credit_note"),
url(r"^extend/([0-9]+)$", extend_reservation, name="extend_reservation"),
url(r"^invoice/([0-9]+)$", invoice, name="invoice"),
url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", invoice, name="invoice"),
url(r"^invoice/([0-9]+)/manual_payment$",
manual_payment, name="manual_payment"),
url(r"^invoice/([0-9]+)/refund$",
refund, name="refund"),
url(r"^invoice_access/([A-Z0-9]+)$", invoice_access,
name="invoice_access"),
url(r"^invoice_mailout$", invoice_mailout, name="invoice_mailout"),
url(r"^profile$", edit_profile, name="attendee_edit"),
url(r"^register$", guided_registration, name="guided_registration"),
url(r"^review$", review, name="review"),
url(r"^register/([0-9]+)$", guided_registration,
name="guided_registration"),
]
reports = [
url(r"^$", rv.reports_list, name="reports_list"),
url(r"^attendee/?$", rv.attendee, name="attendee"),
url(r"^attendee_data/?$", rv.attendee_data, name="attendee_data"),
url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"),
url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"),
url(r"^manifest/?$", rv.manifest, name="manifest"),
url(r"^discount_status/?$", rv.discount_status, name="discount_status"),
url(r"^invoices/?$", rv.invoices, name="invoices"),
url(
r"^paid_invoices_by_date/?$",
rv.paid_invoices_by_date,
name="paid_invoices_by_date"
),
url(r"^product_status/?$", rv.product_status, name="product_status"),
url(r"^reconciliation/?$", rv.reconciliation, name="reconciliation"),
url(
r"^speaker_registrations/?$",
rv.speaker_registrations,
name="speaker_registrations",
),
]
urlpatterns = [
url(r"^reports/", include(reports)),
url(r"^", include(public)) # This one must go last.
]

74
vendor/registrasion/util.py vendored Normal file
View file

@ -0,0 +1,74 @@
import string
import sys
from django.utils.crypto import get_random_string
def generate_access_code():
''' Generates an access code for users' payments as well as their
fulfilment code for check-in.
The access code will 4 characters long, which allows for 1,500,625
unique codes, which really should be enough for anyone. '''
length = 6
# all upper-case letters + digits 1-9 (no 0 vs O confusion)
chars = string.ascii_uppercase + string.digits[1:]
# 6 chars => 35 ** 6 = 1838265625 (should be enough for anyone)
return get_random_string(length=length, allowed_chars=chars)
def all_arguments_optional(ntcls):
''' Takes a namedtuple derivative and makes all of the arguments optional.
'''
ntcls.__new__.__defaults__ = (
(None,) * len(ntcls._fields)
)
return ntcls
def lazy(function, *args, **kwargs):
''' Produces a callable so that functions can be lazily evaluated in
templates.
Arguments:
function (callable): The function to call at evaluation time.
args: Positional arguments, passed directly to ``function``.
kwargs: Keyword arguments, passed directly to ``function``.
Return:
callable: A callable that will evaluate a call to ``function`` with
the specified arguments.
'''
NOT_EVALUATED = object()
retval = [NOT_EVALUATED]
def evaluate():
if retval[0] is NOT_EVALUATED:
retval[0] = function(*args, **kwargs)
return retval[0]
return evaluate
def get_object_from_name(name):
''' Returns the named object.
Arguments:
name (str): A string of form `package.subpackage.etc.module.property`.
This function will import `package.subpackage.etc.module` and
return `property` from that module.
'''
dot = name.rindex(".")
mod_name, property_name = name[:dot], name[dot + 1:]
__import__(mod_name)
return getattr(sys.modules[mod_name], property_name)

1032
vendor/registrasion/views.py vendored Normal file

File diff suppressed because it is too large Load diff