Vendor registration
This commit is contained in:
commit
c1abf4717d
55 changed files with 11034 additions and 0 deletions
201
vendor/registrasion/LICENSE
vendored
Normal file
201
vendor/registrasion/LICENSE
vendored
Normal 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
3
vendor/registrasion/__init__.py
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
__version__ = "0.2.0-dev"
|
||||
|
||||
default_app_config = "registrasion.apps.RegistrasionConfig"
|
230
vendor/registrasion/admin.py
vendored
Normal file
230
vendor/registrasion/admin.py
vendored
Normal 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
8
vendor/registrasion/apps.py
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
from __future__ import unicode_literals
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RegistrasionConfig(AppConfig):
|
||||
name = "registrasion"
|
||||
label = "registrasion"
|
||||
verbose_name = "Registrasion"
|
0
vendor/registrasion/contrib/__init__.py
vendored
Normal file
0
vendor/registrasion/contrib/__init__.py
vendored
Normal file
69
vendor/registrasion/contrib/mail.py
vendored
Normal file
69
vendor/registrasion/contrib/mail.py
vendored
Normal 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()
|
0
vendor/registrasion/controllers/__init__.py
vendored
Normal file
0
vendor/registrasion/controllers/__init__.py
vendored
Normal file
119
vendor/registrasion/controllers/batch.py
vendored
Normal file
119
vendor/registrasion/controllers/batch.py
vendored
Normal 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
512
vendor/registrasion/controllers/cart.py
vendored
Normal 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
|
78
vendor/registrasion/controllers/category.py
vendored
Normal file
78
vendor/registrasion/controllers/category.py
vendored
Normal 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)
|
342
vendor/registrasion/controllers/conditions.py
vendored
Normal file
342
vendor/registrasion/controllers/conditions.py
vendored
Normal 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())
|
83
vendor/registrasion/controllers/credit_note.py
vendored
Normal file
83
vendor/registrasion/controllers/credit_note.py
vendored
Normal 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)
|
197
vendor/registrasion/controllers/discount.py
vendored
Normal file
197
vendor/registrasion/controllers/discount.py
vendored
Normal 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
258
vendor/registrasion/controllers/flag.py
vendored
Normal 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)
|
24
vendor/registrasion/controllers/for_id.py
vendored
Normal file
24
vendor/registrasion/controllers/for_id.py
vendored
Normal 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()
|
453
vendor/registrasion/controllers/invoice.py
vendored
Normal file
453
vendor/registrasion/controllers/invoice.py
vendored
Normal 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
126
vendor/registrasion/controllers/item.py
vendored
Normal 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)
|
92
vendor/registrasion/controllers/product.py
vendored
Normal file
92
vendor/registrasion/controllers/product.py
vendored
Normal 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
5
vendor/registrasion/exceptions.py
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class CartValidationError(ValidationError):
|
||||
pass
|
493
vendor/registrasion/forms.py
vendored
Normal file
493
vendor/registrasion/forms.py
vendored
Normal 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,
|
||||
)
|
383
vendor/registrasion/migrations/0001_initial.py
vendored
Normal file
383
vendor/registrasion/migrations/0001_initial.py
vendored
Normal 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')]),
|
||||
),
|
||||
]
|
20
vendor/registrasion/migrations/0002_auto_20160822_0034.py
vendored
Normal file
20
vendor/registrasion/migrations/0002_auto_20160822_0034.py
vendored
Normal 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'),
|
||||
),
|
||||
]
|
90
vendor/registrasion/migrations/0003_auto_20160904_0235.py
vendored
Normal file
90
vendor/registrasion/migrations/0003_auto_20160904_0235.py
vendored
Normal 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'),
|
||||
),
|
||||
]
|
41
vendor/registrasion/migrations/0004_groupmemberdiscount_groupmemberflag.py
vendored
Normal file
41
vendor/registrasion/migrations/0004_groupmemberdiscount_groupmemberflag.py
vendored
Normal 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),
|
||||
),
|
||||
]
|
35
vendor/registrasion/migrations/0005_auto_20160905_0945.py
vendored
Normal file
35
vendor/registrasion/migrations/0005_auto_20160905_0945.py
vendored
Normal 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'),
|
||||
),
|
||||
]
|
0
vendor/registrasion/migrations/__init__.py
vendored
Normal file
0
vendor/registrasion/migrations/__init__.py
vendored
Normal file
4
vendor/registrasion/models/__init__.py
vendored
Normal file
4
vendor/registrasion/models/__init__.py
vendored
Normal 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
406
vendor/registrasion/models/commerce.py
vendored
Normal 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
559
vendor/registrasion/models/conditions.py
vendored
Normal 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
219
vendor/registrasion/models/inventory.py
vendored
Normal 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
95
vendor/registrasion/models/people.py
vendored
Normal 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)
|
0
vendor/registrasion/reporting/__init__.py
vendored
Normal file
0
vendor/registrasion/reporting/__init__.py
vendored
Normal file
97
vendor/registrasion/reporting/forms.py
vendored
Normal file
97
vendor/registrasion/reporting/forms.py
vendored
Normal 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
354
vendor/registrasion/reporting/reports.py
vendored
Normal 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
867
vendor/registrasion/reporting/views.py
vendored
Normal 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()
|
0
vendor/registrasion/templatetags/__init__.py
vendored
Normal file
0
vendor/registrasion/templatetags/__init__.py
vendored
Normal file
119
vendor/registrasion/templatetags/registrasion_tags.py
vendored
Normal file
119
vendor/registrasion/templatetags/registrasion_tags.py
vendored
Normal 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
1
vendor/registrasion/tests/__init__.py
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
default_app_config = "registrasion.apps.RegistrationConfig"
|
63
vendor/registrasion/tests/controller_helpers.py
vendored
Normal file
63
vendor/registrasion/tests/controller_helpers.py
vendored
Normal 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
50
vendor/registrasion/tests/patches.py
vendored
Normal 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
137
vendor/registrasion/tests/test_batch.py
vendored
Normal 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
551
vendor/registrasion/tests/test_cart.py
vendored
Normal 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,
|
||||
)
|
223
vendor/registrasion/tests/test_ceilings.py
vendored
Normal file
223
vendor/registrasion/tests/test_ceilings.py
vendored
Normal 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())
|
467
vendor/registrasion/tests/test_credit_note.py
vendored
Normal file
467
vendor/registrasion/tests/test_credit_note.py
vendored
Normal 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()
|
480
vendor/registrasion/tests/test_discount.py
vendored
Normal file
480
vendor/registrasion/tests/test_discount.py
vendored
Normal 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
396
vendor/registrasion/tests/test_flag.py
vendored
Normal 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)
|
68
vendor/registrasion/tests/test_group_member.py
vendored
Normal file
68
vendor/registrasion/tests/test_group_member.py
vendored
Normal 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)
|
27
vendor/registrasion/tests/test_helpers.py
vendored
Normal file
27
vendor/registrasion/tests/test_helpers.py
vendored
Normal 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)
|
376
vendor/registrasion/tests/test_invoice.py
vendored
Normal file
376
vendor/registrasion/tests/test_invoice.py
vendored
Normal 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"])
|
38
vendor/registrasion/tests/test_refund.py
vendored
Normal file
38
vendor/registrasion/tests/test_refund.py
vendored
Normal 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,
|
||||
)
|
231
vendor/registrasion/tests/test_speaker.py
vendored
Normal file
231
vendor/registrasion/tests/test_speaker.py
vendored
Normal 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)
|
160
vendor/registrasion/tests/test_voucher.py
vendored
Normal file
160
vendor/registrasion/tests/test_voucher.py
vendored
Normal 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
78
vendor/registrasion/urls.py
vendored
Normal 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
74
vendor/registrasion/util.py
vendored
Normal 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
1032
vendor/registrasion/views.py
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue