From 647e3fd10bf4e8013d292a65c16856a5f15d77c9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer <_@chrisjrn.com> Date: Fri, 22 Jan 2016 15:53:23 +1100 Subject: [PATCH 001/418] Initial commit --- .gitignore | 59 ++++++++++++++++ LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 262 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7d440988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8dada3ed --- /dev/null +++ b/LICENSE @@ -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 {yyyy} {name of copyright owner} + + 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. diff --git a/README.md b/README.md new file mode 100644 index 00000000..e1ab38c4 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# registrasion +A conference registration app, built on top of the Symposion conference management system From ecd5e082636ff09ad446ba4551a0048f0124ecbd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 16:00:58 +1100 Subject: [PATCH 002/418] Update gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 7d440988..629c0e69 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,8 @@ docs/_build/ # PyBuilder target/ + +# Grumble OSX Grumble + +.DS_Store +*/.DS_Store From d9e433659d012540de3712fe8832970e36930567 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 16:01:30 +1100 Subject: [PATCH 003/418] Imports code from old Symposion repo --- design/design.md | 298 ++++++++++++++ registrasion/__init__.py | 3 + registrasion/admin.py | 83 ++++ registrasion/apps.py | 8 + registrasion/cart.py | 210 ++++++++++ registrasion/conditions.py | 160 ++++++++ registrasion/controllers.py | 99 +++++ registrasion/invoice.py | 137 +++++++ registrasion/migrations/0001_initial.py | 270 +++++++++++++ registrasion/migrations/__init__.py | 0 registrasion/models.py | 375 ++++++++++++++++++ registrasion/tests/__init__.py | 1 + registrasion/tests/patch_datetime.py | 24 ++ registrasion/tests/test_cart.py | 185 +++++++++ registrasion/tests/test_ceilings.py | 141 +++++++ registrasion/tests/test_discount.py | 184 +++++++++ registrasion/tests/test_enabling_condition.py | 171 ++++++++ registrasion/tests/test_invoice.py | 108 +++++ registrasion/tests/test_voucher.py | 97 +++++ 19 files changed, 2554 insertions(+) create mode 100644 design/design.md create mode 100644 registrasion/__init__.py create mode 100644 registrasion/admin.py create mode 100644 registrasion/apps.py create mode 100644 registrasion/cart.py create mode 100644 registrasion/conditions.py create mode 100644 registrasion/controllers.py create mode 100644 registrasion/invoice.py create mode 100644 registrasion/migrations/0001_initial.py create mode 100644 registrasion/migrations/__init__.py create mode 100644 registrasion/models.py create mode 100644 registrasion/tests/__init__.py create mode 100644 registrasion/tests/patch_datetime.py create mode 100644 registrasion/tests/test_cart.py create mode 100644 registrasion/tests/test_ceilings.py create mode 100644 registrasion/tests/test_discount.py create mode 100644 registrasion/tests/test_enabling_condition.py create mode 100644 registrasion/tests/test_invoice.py create mode 100644 registrasion/tests/test_voucher.py diff --git a/design/design.md b/design/design.md new file mode 100644 index 00000000..116ed2ca --- /dev/null +++ b/design/design.md @@ -0,0 +1,298 @@ +# Logic + +## Definitions +- User has one 'active Cart' at a time. The Cart remains active until a paid Invoice is attached to it. +- A 'paid Cart' is a Cart with a paid Invoice attached to it, where the Invoice has not been voided. +- An unpaid Cart is 'reserved' if + - CURRENT_TIME - "Time last updated" <= max(reservation duration of Products in Cart), + - A Voucher was added and CURRENT_TIME - "Time last updated" < VOUCHER_RESERVATION_TIME (15 minutes?) +- An Item is 'reserved' if: + - it belongs to a reserved Cart + - it belongs to a paid Cart +- A Cart can have any number of Items added to it, subject to limits. + + +## Entering Vouchers +- Vouchers are attached to Carts +- A user can enter codes for as many different Vouchers as they like. +- A Voucher is added to the Cart if the number of paid or reserved Carts containing the Voucher is less than the "total available" for the voucher. +- A cart is invalid if it contains a voucher that has been overused + + +## Are products available? + +- Availability is determined by the number of items we want to add to the cart: items_to_add + +- If items_to_add + count(Product in their active and paid Carts) > "Limit per user" for the Product, the Product is "unavailable". +- If the Product belongs to an exhausted Ceiling, the Product is "unavailable". +- Otherwise, the product is available + + +## Displaying Products: + +- If there is at least one mandatory EnablingCondition attached to the Product, display it only if all EnablingConditions are met +- If there is at least one EnablingCondition attached to the Product, display it only if at least one EnablingCondition is met +- If there are zero EnablingConditions attached to the Product, display it +- If the product is not available for items_to_add=0, mark it as "unavailable" + +- If the Product is displayed and available, its price is the price for the Product, minus the greatest Discount available to this Cart and Product + +- The product is displayed per the rendering characteristics of the Category it belongs to + + +## Displaying Categories + +- If the Category contains only "unavailable" Products, mark it as "unavailable" +- If the Category contains no displayed Products, do not display the Category +- If the Category contains at least one EnablingCondition, display it only if at least one EnablingCondition is met +- If the Category contains no EnablingConditions, display it + + +## Exhausting Ceilings + +- Exhaustion is determined by the number of items we want to add to the cart: items_to_add + +- A ceiling is exhausted if: + - Its start date has not yet been reached + - Its end date has been exceeded + - items_to_add + sum(paid and reserved Items for each Product in the ceiling) > Total available + + +## Applying Discounts + +- Discounts only apply to the current cart +- Discounts can be applied to multiple carts until the user has exhausted the quantity for each product attached to the discount. +- Only one discount discount can be applied to each single item. Discounts are applied as follows: + - All non-exhausted discounts for the product or its category are ordered by value + - The highest discount is applied for the lower of the quantity of the product in the cart, or the remaining quantity from this discount + - If the quantity remaining is non-zero, apply the next available discount + +- Individual discount objects should not contain more than one DiscountForProduct for the same product +- Individual discount objects should not contain more than one DiscountForCategory for the same category +- Individual discount objects should not contain a discount for both a product and its category + + +## Adding Items to the Cart + +- Products that are not displayed may not be added to a Cart +- The requested number of items must be available for those items to be added to a Cart +- If a different price applies to a Product when it is added to a cart, add at the new price, and display an alert to the user +- If a discount is used when adding a Product to the cart, add the discount as well +- Adding an item resets the "Time last updated" for the cart +- Each time carts have items added or removed, the revision number is updated + + +## Generating an invoice + +- User can ask to 'check out' the active Cart. Doing so generates an Invoice. The invoice corresponds to a revision number of the cart. +- Checking out the active Cart resets the "Time last updated" for the cart. +- The invoice represents the current state of the cart. +- If the revision number for the cart is different to the cart's revision number for the invoice, the invoice is void. +- The invoice is void if + + +## Paying an invoice + +- A payment can only be attached to an invoice if all of the items in it are available at the time payment is processed + +### One-Shot +- Update the "Time last updated" for the cart based on the expected time it takes for a payment to complete +- Verify that all items are available, and if so: +- Proceed to make payment +- Apply payment record from amount received + + +### Authorization-based approach: +- Capture an authorization on the card +- Verify that all items are available, and if so: +- Apply payment record +- Take payment + + +# Registration workflow: + +## User has not taken a guided registration yet: + +User is shown two options: + +1. Undertake guided registration ("for current user") +1. Purchase vouchers + + +## User has not purchased a ticket, and wishes to: + +This gives the user a guided registration process. + +1. Take list of categories, sorted by display order, and display the next lowest enabled & available category +1. Take user to category page +1. User can click "back" to go to previous screen, or "next" to go the next lowest enabled & available category + +Once all categories have been seen: +1. Ask for badge information -- badge information is *not* the same as the invoicee. +1. User is taken to the "user has purchased a ticket" workflow + + +## User is buying vouchers +TODO: Consider separate workflow for purchasing ticket vouchers. + + +## User has completed a guided registration or purchased vouchers + +1. Show list of products that are pending purchase. +1. Show list of categories + badge information, as well as 'checkout' button if the user has items in their current cart + + +## Category page + +- User can enter a voucher at any time +- User is shown the list of products that have been paid for +- User has the option to add/remove products that are in the current cart + + +## Checkout + +1. Ask for invoicing details (pre-fill from previous invoice?) +1. Ask for payment + + +# User Models + +- Profile: + - User + - Has done guided registration? + - Badge + - + +## Transaction Models + +- Cart: + - User + - {Items} + - {Voucher} + - {DiscountItems} + - Time last updated + - Revision Number + - Active? + +- Item + - Product + - Quantity + +- DiscountItem + - Product + - Discount + - Quantity + +- Invoice: + - Invoice number + - User + - Cart + - Cart Revision + - {Line Items} + - (Invoice Details) + - {Payments} + - Voided? + +- LineItem + - Description + - Quantity + - Price + +- Payment + - Time + - Amount + - Reference + + +## Inventory Model + +- Product: + - Name + - Description + - Category + - Price + - Limit per user + - Reservation duration + - Display order + - {Ceilings} + + +- Voucher + - Description + - Code + - Total available + + +- Category? + - Name + - Description + - Display Order + - Rendering Style + + +## Product Modifiers + +- Discount: + - Description + - {DiscountForProduct} + - {DiscountForCategory} + + - Discount Types: + - TimeOrStockLimitDiscount: + * A discount that is available for a limited amount of time, e.g. Early Bird sales * + - Start date + - End date + - Total available + + - VoucherDiscount: + * A discount that is available to a specific voucher * + - Voucher + + - RoleDiscount + * A discount that is available to a specific role * + - Role + + - IncludedProductDiscount: + * A discount that is available because another product has been purchased * + - {Parent Product} + +- DiscountForProduct + - Product + - Amount + - Percentage + - Quantity + +- DiscountForCategory + - Category + - Percentage + - Quantity + + +- EnablingCondition: + - Description + - Mandatory? + - {Products} + - {Categories} + + - EnablingCondition Types: + - ProductEnablingCondition: + * Enabling because the user has purchased a specific product * + - {Products that enable} + + - CategoryEnablingCondition: + * Enabling because the user has purchased a product in a specific category * + - {Categories that enable} + + - VoucherEnablingCondition: + * Enabling because the user has entered a voucher code * + - Voucher + + - RoleEnablingCondition: + * Enabling because the user has a specific role * + - Role + + - TimeOrStockLimitEnablingCondition: + * Enabling because a time condition has been met, or a number of items underneath it have not been sold * + - Start date + - End date + - Total available diff --git a/registrasion/__init__.py b/registrasion/__init__.py new file mode 100644 index 00000000..97a6f232 --- /dev/null +++ b/registrasion/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1a1" + +default_app_config = "registrasion.apps.RegistrasionConfig" diff --git a/registrasion/admin.py b/registrasion/admin.py new file mode 100644 index 00000000..74016950 --- /dev/null +++ b/registrasion/admin.py @@ -0,0 +1,83 @@ +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + +import nested_admin + +from registrasion import models as rego + + + +# Inventory admin + +class ProductInline(admin.TabularInline): + model = rego.Product + + +@admin.register(rego.Category) +class CategoryAdmin(admin.ModelAdmin): + model = rego.Category + verbose_name_plural = _("Categories") + inlines = [ + ProductInline, + ] + +admin.site.register(rego.Product) + + +# Discounts + +class DiscountForProductInline(admin.TabularInline): + model = rego.DiscountForProduct + verbose_name = _("Product included in discount") + verbose_name_plural = _("Products included in discount") + + +class DiscountForCategoryInline(admin.TabularInline): + model = rego.DiscountForCategory + verbose_name = _("Category included in discount") + verbose_name_plural = _("Categories included in discount") + + +@admin.register( + rego.TimeOrStockLimitDiscount, + rego.IncludedProductDiscount, +) +class DiscountAdmin(admin.ModelAdmin): + inlines = [ + DiscountForProductInline, + DiscountForCategoryInline, + ] + + +# Vouchers + +class VoucherDiscountInline(nested_admin.NestedStackedInline): + model = rego.VoucherDiscount + verbose_name = _("Discount") + + # TODO work out why we're allowed to add more than one? + max_num = 1 + extra = 1 + inlines = [ + DiscountForProductInline, + DiscountForCategoryInline, + ] + + +class VoucherEnablingConditionInline(nested_admin.NestedStackedInline): + model = rego.VoucherEnablingCondition + verbose_name = _("Product and category enabled by voucher") + verbose_name_plural = _("Products and categories enabled by voucher") + + # TODO work out why we're allowed to add more than one? + max_num = 1 + extra = 1 + + +@admin.register(rego.Voucher) +class VoucherAdmin(nested_admin.NestedAdmin): + model = rego.Voucher + inlines = [ + VoucherDiscountInline, + VoucherEnablingConditionInline, + ] diff --git a/registrasion/apps.py b/registrasion/apps.py new file mode 100644 index 00000000..fd35af4a --- /dev/null +++ b/registrasion/apps.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +from django.apps import AppConfig + + +class RegistrasionConfig(AppConfig): + name = "registrasion" + label = "registrasion" + verbose_name = "Registrasion" diff --git a/registrasion/cart.py b/registrasion/cart.py new file mode 100644 index 00000000..521980f6 --- /dev/null +++ b/registrasion/cart.py @@ -0,0 +1,210 @@ +import datetime +import itertools + +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError +from django.db.models import Avg, Min, Max, Sum +from django.utils import timezone + +from registrasion import models as rego + +from conditions import ConditionController +from controllers import ProductController + + +class CartController(object): + + def __init__(self, cart): + self.cart = cart + + @staticmethod + def for_user(user): + ''' Returns the user's current cart, or creates a new cart + if there isn't one ready yet. ''' + + try: + existing = rego.Cart.objects.get(user=user, active=True) + except ObjectDoesNotExist: + existing = rego.Cart.objects.create( + user=user, + time_last_updated=timezone.now(), + reservation_duration=datetime.timedelta(), + ) + existing.save() + return CartController(existing) + + + def extend_reservation(self): + ''' Updates the cart's time last updated value, which is used to + determine whether the cart has reserved the items and discounts it + holds. ''' + + reservations = [datetime.timedelta()] + + # If we have vouchers, we're entitled to an hour at minimum. + if len(self.cart.vouchers.all()) >= 1: + reservations.append(rego.Voucher.RESERVATION_DURATION) + + # Else, it's the maximum of the included products + items = rego.ProductItem.objects.filter(cart=self.cart) + agg = items.aggregate(Max("product__reservation_duration")) + product_max = agg["product__reservation_duration__max"] + + if product_max is not None: + reservations.append(product_max) + + self.cart.time_last_updated = timezone.now() + self.cart.reservation_duration = max(reservations) + + + def add_to_cart(self, product, quantity): + ''' Adds _quantity_ of the given _product_ to the cart. Raises + ValidationError if constraints are violated.''' + + prod = ProductController(product) + + # TODO: Check enabling conditions for product for user + + if not prod.can_add_with_enabling_conditions(self.cart.user, quantity): + raise ValidationError("Not enough of that product left (ec)") + + if not prod.user_can_add_within_limit(self.cart.user, quantity): + raise ValidationError("Not enough of that product left (user)") + + try: + # Try to update an existing item within this cart if possible. + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product) + product_item.quantity += quantity + except ObjectDoesNotExist: + product_item = rego.ProductItem.objects.create( + cart=self.cart, + product=product, + quantity=quantity, + ) + product_item.save() + + self.recalculate_discounts() + + self.extend_reservation() + self.cart.revision += 1 + self.cart.save() + + + def apply_voucher(self, voucher): + ''' Applies the given voucher to this cart. ''' + + # TODO: is it valid for a cart to re-add a voucher that they have? + + # Is voucher exhausted? + active_carts = rego.Cart.reserved_carts() + carts_with_voucher = active_carts.filter(vouchers=voucher) + if len(carts_with_voucher) >= voucher.limit: + raise ValidationError("This voucher is no longer available") + + # If successful... + self.cart.vouchers.add(voucher) + + self.extend_reservation() + self.cart.revision += 1 + self.cart.save() + + + def validate_cart(self): + ''' Determines whether the status of the current cart is valid; + this is normally called before generating or paying an invoice ''' + + is_reserved = self.cart in rego.Cart.reserved_carts() + + # TODO: validate vouchers + + items = rego.ProductItem.objects.filter(cart=self.cart) + for item in items: + # per-user limits are tested at add time, and are unliklely to change + prod = ProductController(item.product) + + # If the cart is not reserved, we need to see if we can re-reserve + quantity = 0 if is_reserved else item.quantity + + if not prod.can_add_with_enabling_conditions(self.cart.user, quantity): + raise ValidationError("Products are no longer available") + + # Validate the discounts + discount_items = rego.DiscountItem.objects.filter(cart=self.cart) + seen_discounts = set() + + for discount_item in discount_items: + discount = discount_item.discount + if discount in seen_discounts: + continue + seen_discounts.add(discount) + real_discount = rego.DiscountBase.objects.get_subclass(pk=discount.pk) + cond = ConditionController.for_condition(real_discount) + + quantity = 0 if is_reserved else discount_item.quantity + + if not cond.is_met(self.cart.user, quantity): + raise ValidationError("Discounts are no longer available") + + + + def recalculate_discounts(self): + ''' Calculates all of the discounts available for this product. + NB should be transactional, and it's terribly inefficient. + ''' + + # Delete the existing entries. + rego.DiscountItem.objects.filter(cart=self.cart).delete() + + for item in self.cart.productitem_set.all(): + self._add_discount(item.product, item.quantity) + + + def _add_discount(self, product, quantity): + ''' Calculates the best available discounts for this product. + NB this will be super-inefficient in aggregate because discounts will be + re-tested for each product. We should work on that.''' + + prod = ProductController(product) + discounts = prod.available_discounts(self.cart.user) + discounts.sort(key=lambda discount: discount.value) + + for discount in reversed(discounts): + if quantity == 0: + break + + # Get the count of past uses of this discount condition + # as this affects the total amount we're allowed to use now. + past_uses = rego.DiscountItem.objects.filter( + cart__active=False, + discount=discount.discount, + product=product, + ) + agg = past_uses.aggregate(Sum("quantity")) + past_uses = agg["quantity__sum"] + if past_uses is None: + past_uses = 0 + if past_uses == discount.condition.quantity: + continue + + # Get a provisional instance for this DiscountItem + # with the quantity set to as much as we have in the cart + discount_item = rego.DiscountItem.objects.create( + product=product, + cart=self.cart, + discount=discount.discount, + quantity=quantity, + ) + + # Truncate the quantity for this DiscountItem if we exceed quantity + ours = discount_item.quantity + allowed = discount.condition.quantity - past_uses + if ours > allowed: + discount_item.quantity = allowed + # Update the remaining quantity. + quantity = ours - allowed + else: + quantity = 0 + + discount_item.save() diff --git a/registrasion/conditions.py b/registrasion/conditions.py new file mode 100644 index 00000000..27770c95 --- /dev/null +++ b/registrasion/conditions.py @@ -0,0 +1,160 @@ +from django.db.models import F, Q +from django.db.models import Sum +from django.utils import timezone + +from registrasion import models as rego + + +class ConditionController(object): + ''' Base class for testing conditions that activate EnablingCondition + or Discount objects. ''' + + def __init__(self): + pass + + @staticmethod + def for_condition(condition): + CONTROLLERS = { + rego.CategoryEnablingCondition : CategoryConditionController, + rego.IncludedProductDiscount : ProductConditionController, + rego.ProductEnablingCondition : ProductConditionController, + rego.TimeOrStockLimitDiscount : + TimeOrStockLimitConditionController, + rego.TimeOrStockLimitEnablingCondition : + TimeOrStockLimitConditionController, + rego.VoucherDiscount : VoucherConditionController, + rego.VoucherEnablingCondition : VoucherConditionController, + } + + try: + return CONTROLLERS[type(condition)](condition) + except KeyError: + return ConditionController() + + + def is_met(self, user, quantity): + return True + + +class CategoryConditionController(ConditionController): + + def __init__(self, condition): + self.condition = condition + + def is_met(self, user, quantity): + ''' returns True if the user has a product from a category that invokes + this condition in one of their carts ''' + + carts = rego.Cart.objects.filter(user=user) + enabling_products = rego.Product.objects.filter( + category=self.condition.enabling_category) + products = rego.ProductItem.objects.filter(cart=carts, + product=enabling_products) + return len(products) > 0 + + +class ProductConditionController(ConditionController): + ''' Condition tests for ProductEnablingCondition and + IncludedProductDiscount. ''' + + def __init__(self, condition): + self.condition = condition + + def is_met(self, user, quantity): + ''' returns True if the user has a product that invokes this + condition in one of their carts ''' + + carts = rego.Cart.objects.filter(user=user) + products = rego.ProductItem.objects.filter(cart=carts, + product=self.condition.enabling_products.all()) + return len(products) > 0 + + +class TimeOrStockLimitConditionController(ConditionController): + ''' Condition tests for TimeOrStockLimit EnablingCondition and + Discount.''' + + def __init__(self, ceiling): + self.ceiling = ceiling + + + def is_met(self, user, quantity): + ''' returns True if adding _quantity_ of _product_ will not vioilate + this ceiling. ''' + + # Test date range + if not self.test_date_range(): + return False + + # Test limits + if not self.test_limits(quantity): + return False + + # All limits have been met + return True + + + def test_date_range(self): + now = timezone.now() + + if self.ceiling.start_time is not None: + if now < self.ceiling.start_time: + return False + + if self.ceiling.end_time is not None: + if now > self.ceiling.end_time: + return False + + return True + + + def _products(self): + ''' Abstracts away the product list, becuase enabling conditions + list products differently to discounts. ''' + if isinstance(self.ceiling, rego.TimeOrStockLimitEnablingCondition): + category_products = rego.Product.objects.filter( + category=self.ceiling.categories.all() + ) + return self.ceiling.products.all() | category_products + else: + categories = rego.Category.objects.filter( + discountforcategory__discount=self.ceiling + ) + return rego.Product.objects.filter( + Q(discountforproduct__discount=self.ceiling) | + Q(category=categories.all()) + ) + + + def test_limits(self, quantity): + if self.ceiling.limit is None: + return True + + reserved_carts = rego.Cart.reserved_carts() + product_items = rego.ProductItem.objects.filter( + product=self._products().all() + ) + product_items = product_items.filter(cart=reserved_carts) + + agg = product_items.aggregate(Sum("quantity")) + count = agg["quantity__sum"] + if count is None: + count = 0 + + if count + quantity > self.ceiling.limit: + return False + + return True + + +class VoucherConditionController(ConditionController): + ''' Condition test for VoucherEnablingCondition and VoucherDiscount.''' + + def __init__(self, condition): + self.condition = condition + + def is_met(self, user, quantity): + ''' returns True if the user has the given voucher attached. ''' + carts = rego.Cart.objects.filter(user=user, + vouchers=self.condition.voucher) + return len(carts) > 0 diff --git a/registrasion/controllers.py b/registrasion/controllers.py new file mode 100644 index 00000000..795c2d48 --- /dev/null +++ b/registrasion/controllers.py @@ -0,0 +1,99 @@ +import itertools + +from collections import namedtuple + +from django.db.models import F, Q +from registrasion import models as rego + +from conditions import ConditionController + +DiscountEnabler = namedtuple("DiscountEnabler", ("discount", "condition", "value")) + +class ProductController(object): + + def __init__(self, product): + self.product = product + + def user_can_add_within_limit(self, user, quantity): + ''' Return true if the user is able to add _quantity_ to their count of + this Product without exceeding _limit_per_user_.''' + + carts = rego.Cart.objects.filter(user=user) + items = rego.ProductItem.objects.filter(product=self.product, cart=carts) + + count = 0 + for item in items: + count += item.quantity + + if quantity + count > self.product.limit_per_user: + return False + else: + return True + + def can_add_with_enabling_conditions(self, user, quantity): + ''' Returns true if the user is able to add _quantity_ to their count + of this Product without exceeding the ceilings the product is attached + to. ''' + + conditions = rego.EnablingConditionBase.objects.filter( + Q(products=self.product) | Q(categories=self.product.category) + ).select_subclasses() + + mandatory_violated = False + non_mandatory_met = False + + for condition in conditions: + cond = ConditionController.for_condition(condition) + met = cond.is_met(user, quantity) + + if condition.mandatory and not met: + mandatory_violated = True + break + if met: + non_mandatory_met = True + + if mandatory_violated: + # All mandatory conditions must be met + return False + + if len(conditions) > 0 and not non_mandatory_met: + # If there's any non-mandatory conditions, one must be met + return False + + return True + + + def get_enabler(self, condition): + if condition.percentage is not None: + value = condition.percentage * self.product.price + else: + value = condition.price + return DiscountEnabler( + discount=condition.discount, + condition=condition, + value=value + ) + + def available_discounts(self, user): + ''' Returns the set of available discounts for this user, for this + product. ''' + + product_discounts = rego.DiscountForProduct.objects.filter( + product=self.product) + category_discounts = rego.DiscountForCategory.objects.filter( + category=self.product.category + ) + + potential_discounts = set(itertools.chain( + (self.get_enabler(i) for i in product_discounts), + (self.get_enabler(i) for i in category_discounts), + )) + + discounts = [] + for discount in potential_discounts: + real_discount = rego.DiscountBase.objects.get_subclass(pk=discount.discount.pk) + cond = ConditionController.for_condition(real_discount) + if cond.is_met(user, 0): + discounts.append(discount) + + return discounts diff --git a/registrasion/invoice.py b/registrasion/invoice.py new file mode 100644 index 00000000..67a8c836 --- /dev/null +++ b/registrasion/invoice.py @@ -0,0 +1,137 @@ +from decimal import Decimal +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Avg, Min, Max, Sum + +from registrasion import models as rego + +from cart import CartController + +class InvoiceController(object): + + def __init__(self, invoice): + self.invoice = invoice + + @classmethod + def for_cart(cls, cart): + ''' Returns an invoice object for a given cart at its current revision. + If such an invoice does not exist, the cart is validated, and if valid, + an invoice is generated.''' + + try: + invoice = rego.Invoice.objects.get( + cart=cart, cart_revision=cart.revision) + except ObjectDoesNotExist: + cart_controller = CartController(cart) + cart_controller.validate_cart() # Raises ValidationError on fail. + invoice = cls._generate(cart) + + return InvoiceController(invoice) + + + @classmethod + def resolve_discount_value(cls, item): + try: + condition = rego.DiscountForProduct.objects.get( + discount=item.discount, + product=item.product + ) + except ObjectDoesNotExist: + condition = rego.DiscountForCategory.objects.get( + discount=item.discount, + category=item.product.category + ) + if condition.percentage is not None: + value = item.product.price * (condition.percentage / 100) + else: + value = condition.price + return value + + + @classmethod + def _generate(cls, cart): + ''' Generates an invoice for the given cart. ''' + invoice = rego.Invoice.objects.create( + user=cart.user, + cart=cart, + cart_revision=cart.revision, + value=Decimal() + ) + invoice.save() + + # TODO: calculate line items. + product_items = rego.ProductItem.objects.filter(cart=cart) + discount_items = rego.DiscountItem.objects.filter(cart=cart) + invoice_value = Decimal() + for item in product_items: + line_item = rego.LineItem.objects.create( + invoice=invoice, + description=item.product.name, + quantity=item.quantity, + price=item.product.price, + ) + line_item.save() + invoice_value += line_item.quantity * line_item.price + + for item in discount_items: + + line_item = rego.LineItem.objects.create( + invoice=invoice, + description=item.discount.description, + quantity=item.quantity, + price=cls.resolve_discount_value(item) * -1, + ) + line_item.save() + invoice_value += line_item.quantity * line_item.price + + # TODO: calculate line items from discounts + invoice.value = invoice_value + invoice.save() + + return invoice + + + def is_valid(self): + ''' Returns true if the attached invoice is not void and it represents + a valid cart. ''' + if self.invoice.void: + return False + if self.invoice.cart is not None: + if self.invoice.cart.revision != self.invoice.cart_revision: + return False + return True + + + def void(self): + ''' Voids the invoice. ''' + self.invoice.void = True + + + def pay(self, reference, amount): + ''' Pays the invoice by the given amount. If the payment + equals the total on the invoice, finalise the invoice. + (NB should be transactional.) + ''' + if self.invoice.cart is not None: + cart = CartController(self.invoice.cart) + cart.validate_cart() # Raises ValidationError if invalid + + ''' Adds a payment ''' + payment = rego.Payment.objects.create( + invoice=self.invoice, + reference=reference, + amount=amount, + ) + payment.save() + + payments = rego.Payment.objects .filter(invoice=self.invoice) + agg = payments.aggregate(Sum("amount")) + total = agg["amount__sum"] + + if total==self.invoice.value: + self.invoice.paid = True + + cart = self.invoice.cart + cart.active = False + cart.save() + + self.invoice.save() diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_initial.py new file mode 100644 index 00000000..bfa64439 --- /dev/null +++ b/registrasion/migrations/0001_initial.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import datetime +import django.utils.timezone +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Badge', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=256)), + ('company', models.CharField(max_length=256)), + ], + ), + migrations.CreateModel( + name='Cart', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('time_last_updated', models.DateTimeField()), + ('reservation_duration', models.DurationField()), + ('revision', models.PositiveIntegerField(default=1)), + ('active', models.BooleanField(default=True)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=65, verbose_name='Name')), + ('description', models.CharField(max_length=255, verbose_name='Description')), + ('order', models.PositiveIntegerField(verbose_name='Display order')), + ('render_type', models.IntegerField(verbose_name='Render type', choices=[(1, 'Radio button'), (2, 'Quantity boxes')])), + ], + ), + migrations.CreateModel( + name='DiscountBase', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('description', models.CharField(max_length=255, verbose_name='Description')), + ], + ), + migrations.CreateModel( + name='DiscountForCategory', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('percentage', models.DecimalField(max_digits=4, decimal_places=1, blank=True)), + ('quantity', models.PositiveIntegerField()), + ('category', models.ForeignKey(to='registrasion.Category')), + ], + ), + migrations.CreateModel( + name='DiscountForProduct', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('percentage', models.DecimalField(null=True, max_digits=4, decimal_places=1)), + ('price', models.DecimalField(null=True, max_digits=8, decimal_places=2)), + ('quantity', models.PositiveIntegerField()), + ], + ), + migrations.CreateModel( + name='DiscountItem', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('quantity', models.PositiveIntegerField()), + ('cart', models.ForeignKey(to='registrasion.Cart')), + ], + ), + migrations.CreateModel( + name='EnablingConditionBase', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('description', models.CharField(max_length=255)), + ('mandatory', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('cart_revision', models.IntegerField(null=True)), + ('void', models.BooleanField(default=False)), + ('paid', models.BooleanField(default=False)), + ('value', models.DecimalField(max_digits=8, decimal_places=2)), + ('cart', models.ForeignKey(to='registrasion.Cart', null=True)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LineItem', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('description', models.CharField(max_length=255)), + ('quantity', models.PositiveIntegerField()), + ('price', models.DecimalField(max_digits=8, decimal_places=2)), + ('invoice', models.ForeignKey(to='registrasion.Invoice')), + ], + ), + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('time', models.DateTimeField(default=django.utils.timezone.now)), + ('reference', models.CharField(max_length=64)), + ('amount', models.DecimalField(max_digits=8, decimal_places=2)), + ('invoice', models.ForeignKey(to='registrasion.Invoice')), + ], + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=65, verbose_name='Name')), + ('description', models.CharField(max_length=255, verbose_name='Description')), + ('price', models.DecimalField(verbose_name='Price', max_digits=8, decimal_places=2)), + ('limit_per_user', models.PositiveIntegerField(verbose_name='Limit per user', blank=True)), + ('reservation_duration', models.DurationField(default=datetime.timedelta(0, 3600), verbose_name='Reservation duration')), + ('order', models.PositiveIntegerField(verbose_name='Display order')), + ('category', models.ForeignKey(verbose_name='Product category', to='registrasion.Category')), + ], + ), + migrations.CreateModel( + name='ProductItem', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('quantity', models.PositiveIntegerField()), + ('cart', models.ForeignKey(to='registrasion.Cart')), + ('product', models.ForeignKey(to='registrasion.Product')), + ], + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('completed_registration', models.BooleanField(default=False)), + ('highest_complete_category', models.IntegerField(default=0)), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Voucher', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('recipient', models.CharField(max_length=64, verbose_name='Recipient')), + ('code', models.CharField(unique=True, max_length=16, verbose_name='Voucher code')), + ('limit', models.PositiveIntegerField(verbose_name='Voucher use limit')), + ], + ), + migrations.CreateModel( + name='CategoryEnablingCondition', + fields=[ + ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), + ('enabling_category', models.ForeignKey(to='registrasion.Category')), + ], + bases=('registrasion.enablingconditionbase',), + ), + migrations.CreateModel( + name='IncludedProductDiscount', + fields=[ + ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), + ('enabling_products', models.ManyToManyField(to='registrasion.Product', verbose_name='Including product')), + ], + options={ + 'verbose_name': 'Product inclusion', + }, + bases=('registrasion.discountbase',), + ), + migrations.CreateModel( + name='ProductEnablingCondition', + fields=[ + ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), + ('enabling_products', models.ManyToManyField(to='registrasion.Product')), + ], + bases=('registrasion.enablingconditionbase',), + ), + migrations.CreateModel( + name='TimeOrStockLimitDiscount', + fields=[ + ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), + ('start_time', models.DateTimeField(null=True, verbose_name='Start time')), + ('end_time', models.DateTimeField(null=True, verbose_name='End time')), + ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit')), + ], + options={ + 'verbose_name': 'Promotional discount', + }, + bases=('registrasion.discountbase',), + ), + migrations.CreateModel( + name='TimeOrStockLimitEnablingCondition', + fields=[ + ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), + ('start_time', models.DateTimeField(null=True, verbose_name='Start time')), + ('end_time', models.DateTimeField(null=True, verbose_name='End time')), + ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit')), + ], + bases=('registrasion.enablingconditionbase',), + ), + migrations.CreateModel( + name='VoucherDiscount', + fields=[ + ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), + ('voucher', models.OneToOneField(verbose_name='Voucher', to='registrasion.Voucher')), + ], + bases=('registrasion.discountbase',), + ), + migrations.CreateModel( + name='VoucherEnablingCondition', + fields=[ + ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), + ('voucher', models.OneToOneField(to='registrasion.Voucher')), + ], + bases=('registrasion.enablingconditionbase',), + ), + migrations.AddField( + model_name='enablingconditionbase', + name='categories', + field=models.ManyToManyField(to='registrasion.Category'), + ), + migrations.AddField( + model_name='enablingconditionbase', + name='products', + field=models.ManyToManyField(to='registrasion.Product'), + ), + migrations.AddField( + model_name='discountitem', + name='discount', + field=models.ForeignKey(to='registrasion.DiscountBase'), + ), + migrations.AddField( + model_name='discountitem', + name='product', + field=models.ForeignKey(to='registrasion.Product'), + ), + migrations.AddField( + model_name='discountforproduct', + name='discount', + field=models.ForeignKey(to='registrasion.DiscountBase'), + ), + migrations.AddField( + model_name='discountforproduct', + name='product', + field=models.ForeignKey(to='registrasion.Product'), + ), + migrations.AddField( + model_name='discountforcategory', + name='discount', + field=models.ForeignKey(to='registrasion.DiscountBase'), + ), + migrations.AddField( + model_name='cart', + name='vouchers', + field=models.ManyToManyField(to='registrasion.Voucher', blank=True), + ), + migrations.AddField( + model_name='badge', + name='profile', + field=models.OneToOneField(to='registrasion.Profile'), + ), + ] diff --git a/registrasion/migrations/__init__.py b/registrasion/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/models.py b/registrasion/models.py new file mode 100644 index 00000000..cc3c6aac --- /dev/null +++ b/registrasion/models.py @@ -0,0 +1,375 @@ +from __future__ import unicode_literals + +import datetime + +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError +from django.contrib.auth.models import User +from django.db import models +from django.db.models import F, Q +from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ +from model_utils.managers import InheritanceManager + + +from symposion.markdown_parser import parse +from symposion.proposals.models import ProposalBase + + +# User models + +@python_2_unicode_compatible +class Profile(models.Model): + ''' Miscellaneous user-related data. ''' + + def __str__(self): + return "%s" % self.user + + user = models.OneToOneField(User, on_delete=models.CASCADE) + # Badge is linked + completed_registration = models.BooleanField(default=False) + highest_complete_category = models.IntegerField(default=0) + + +@python_2_unicode_compatible +class Badge(models.Model): + ''' Information for an attendee's badge. ''' + + def __str__(self): + return "Badge for: %s of %s" % (self.name, self.company) + + profile = models.OneToOneField(Profile, on_delete=models.CASCADE) + + name = models.CharField(max_length=256) + company = models.CharField(max_length=256) + + +# Inventory Models + +@python_2_unicode_compatible +class Category(models.Model): + ''' Registration product categories ''' + + def __str__(self): + return self.name + + RENDER_TYPE_RADIO = 1 + RENDER_TYPE_QUANTITY = 2 + + CATEGORY_RENDER_TYPES = [ + (RENDER_TYPE_RADIO, _("Radio button")), + (RENDER_TYPE_QUANTITY, _("Quantity boxes")), + ] + + name = models.CharField(max_length=65, verbose_name=_("Name")) + description = models.CharField(max_length=255, verbose_name=_("Description")) + order = models.PositiveIntegerField(verbose_name=("Display order")) + render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, verbose_name=_("Render type")) + + +@python_2_unicode_compatible +class Product(models.Model): + ''' Registration products ''' + + def __str__(self): + return self.name + + name = models.CharField(max_length=65, verbose_name=_("Name")) + description = models.CharField(max_length=255, verbose_name=_("Description")) + category = models.ForeignKey(Category, verbose_name=_("Product category")) + price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name=_("Price")) + limit_per_user = models.PositiveIntegerField(blank=True, verbose_name=_("Limit per user")) + reservation_duration = models.DurationField( + default=datetime.timedelta(hours=1), + verbose_name=_("Reservation duration")) + order = models.PositiveIntegerField(verbose_name=("Display order")) + + +@python_2_unicode_compatible +class Voucher(models.Model): + ''' Registration vouchers ''' + + # Vouchers reserve a cart for a fixed amount of time, so that + # items may be added without the voucher being swiped by someone else + RESERVATION_DURATION = datetime.timedelta(hours=1) + + def __str__(self): + return "Voucher for %s" % self.recipient + + recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) + code = models.CharField(max_length=16, unique=True, verbose_name=_("Voucher code")) + limit = models.PositiveIntegerField(verbose_name=_("Voucher use limit")) + + +# Product Modifiers + +@python_2_unicode_compatible +class DiscountBase(models.Model): + ''' Base class for discounts. Each subclass has controller code that + determines whether or not the given discount is available to be added to the + current cart. ''' + + objects = InheritanceManager() + + def __str__(self): + return "Discount: " + self.description + + description = models.CharField(max_length=255, + verbose_name=_("Description")) + + +@python_2_unicode_compatible +class DiscountForProduct(models.Model): + ''' Represents a discount on an individual product. Each Discount can + contain multiple products and categories. Discounts can either be a + percentage or a fixed amount, but not both. ''' + + def __str__(self): + if self.percentage: + return "%s%% off %s" % (self.percentage, self.product) + elif self.price: + return "$%s off %s" % (self.price, self.product) + + def clean(self): + if self.percentage is None and self.price is None: + raise ValidationError( + _("Discount must have a percentage or a price.")) + elif self.percentage is not None and self.price is not None: + raise ValidationError( + _("Discount may only have a percentage or only a price.")) + + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE) + percentage = models.DecimalField(max_digits=4, decimal_places=1, null=True) + price = models.DecimalField(max_digits=8, decimal_places=2, null=True) + quantity = models.PositiveIntegerField() + + +@python_2_unicode_compatible +class DiscountForCategory(models.Model): + ''' Represents a discount for a category of products. Each discount can + contain multiple products. Category discounts can only be a percentage. ''' + + def __str__(self): + return "%s%% off %s" % (self.percentage, self.category) + + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + percentage = models.DecimalField(max_digits=4, decimal_places=1, blank=True) + quantity = models.PositiveIntegerField() + + +class TimeOrStockLimitDiscount(DiscountBase): + ''' Discounts that are generally available, but are limited by timespan or + usage count. This is for e.g. Early Bird discounts. ''' + + class Meta: + verbose_name = _("Promotional discount") + + start_time = models.DateTimeField(null=True, verbose_name=_("Start time")) + end_time = models.DateTimeField(null=True, verbose_name=_("End time")) + limit = models.PositiveIntegerField(null=True, verbose_name=_("Limit")) + + +class VoucherDiscount(DiscountBase): + ''' Discounts that are enabled when a voucher code is in the current + cart. ''' + + voucher = models.OneToOneField(Voucher, on_delete=models.CASCADE, + verbose_name=_("Voucher")) + + +class IncludedProductDiscount(DiscountBase): + ''' Discounts that are enabled because another product has been purchased. + e.g. A conference ticket includes a free t-shirt. ''' + + class Meta: + verbose_name = _("Product inclusion") + + enabling_products = models.ManyToManyField(Product, + verbose_name=_("Including product")) + + +class RoleDiscount(object): + ''' Discounts that are enabled because the active user has a specific + role. This is for e.g. volunteers who can get a discount ticket. ''' + ## TODO: implement RoleDiscount + pass + + +@python_2_unicode_compatible +class EnablingConditionBase(models.Model): + ''' This defines a condition which allows products or categories to + be made visible. If there is at least one mandatory enabling condition + defined on a Product or Category, it will only be enabled if *all* + mandatory conditions are met, otherwise, if there is at least one enabling + condition defined on a Product or Category, it will only be enabled if at + least one condition is met. ''' + + objects = InheritanceManager() + + def __str__(self): + return self.name + + description = models.CharField(max_length=255) + mandatory = models.BooleanField(default=False) + products = models.ManyToManyField(Product) + categories = models.ManyToManyField(Category) + + +class TimeOrStockLimitEnablingCondition(EnablingConditionBase): + ''' Registration product ceilings ''' + + start_time = models.DateTimeField(null=True, verbose_name=_("Start time")) + end_time = models.DateTimeField(null=True, verbose_name=_("End time")) + limit = models.PositiveIntegerField(null=True, verbose_name=_("Limit")) + + +@python_2_unicode_compatible +class ProductEnablingCondition(EnablingConditionBase): + ''' The condition is met because a specific product is purchased. ''' + + def __str__(self): + return "Enabled by product: " + + enabling_products = models.ManyToManyField(Product) + + +@python_2_unicode_compatible +class CategoryEnablingCondition(EnablingConditionBase): + ''' The condition is met because a product in a particular product is + purchased. ''' + + def __str__(self): + return "Enabled by product in category: " + + enabling_category = models.ForeignKey(Category) + + +@python_2_unicode_compatible +class VoucherEnablingCondition(EnablingConditionBase): + ''' The condition is met because a Voucher is present. This is for e.g. + enabling sponsor tickets. ''' + + def __str__(self): + return "Enabled by voucher: %s" % voucher + + voucher = models.OneToOneField(Voucher) + + +#@python_2_unicode_compatible +class RoleEnablingCondition(object): + ''' The condition is met because the active user has a particular Role. + This is for e.g. enabling Team tickets. ''' + ## TODO: implement RoleEnablingCondition + pass + + +# Commerce Models + +@python_2_unicode_compatible +class Cart(models.Model): + ''' Represents a set of product items that have been purchased, or are + pending purchase. ''' + + def __str__(self): + return "%d rev #%d" % (self.id, self.revision) + + user = models.ForeignKey(User) + # ProductItems (foreign key) + vouchers = models.ManyToManyField(Voucher, blank=True) + time_last_updated = models.DateTimeField() + reservation_duration = models.DurationField() + revision = models.PositiveIntegerField(default=1) + active = models.BooleanField(default=True) + + @classmethod + def reserved_carts(cls): + ''' Gets all carts that are 'reserved' ''' + return Cart.objects.filter( + (Q(active=True) & + Q(time_last_updated__gt=timezone.now()-F('reservation_duration') + )) | + Q(active=False) + ) + + +@python_2_unicode_compatible +class ProductItem(models.Model): + ''' Represents a product-quantity pair in a Cart. ''' + + def __str__(self): + return "product: %s * %d in Cart: %s" % ( + self.product, self.quantity, self.cart) + + cart = models.ForeignKey(Cart) + product = models.ForeignKey(Product) + quantity = models.PositiveIntegerField() + + +@python_2_unicode_compatible +class DiscountItem(models.Model): + ''' Represents a discount-product-quantity relation in a Cart. ''' + + def __str__(self): + return "%s: %s * %d in Cart: %s" % ( + self.discount, self.product, self.quantity, self.cart) + + cart = models.ForeignKey(Cart) + product = models.ForeignKey(Product) + discount = models.ForeignKey(DiscountBase) + quantity = models.PositiveIntegerField() + + +@python_2_unicode_compatible +class Invoice(models.Model): + ''' An invoice. Invoices can be automatically generated when checking out + a Cart, in which case, it is attached to a given revision of a Cart. ''' + + def __str__(self): + return "Invoice #%d" % self.id + + def clean(self): + if self.cart is not None and self.cart_revision is None: + raise ValidationError( + "If this is a cart invoice, it must have a revision") + + # Invoice Number + user = models.ForeignKey(User) + cart = models.ForeignKey(Cart, null=True) + cart_revision = models.IntegerField(null=True) + # Line Items (foreign key) + void = models.BooleanField(default=False) + paid = models.BooleanField(default=False) + value = models.DecimalField(max_digits=8, decimal_places=2) + + +@python_2_unicode_compatible +class LineItem(models.Model): + ''' Line items for an invoice. These are denormalised from the ProductItems + and DiscountItems that belong to a cart (for consistency), but also allow + for arbitrary line items when required. ''' + + def __str__(self): + return "Line: %s * %d @ %s" % ( + self.description, self.quantity, self.price) + + invoice = models.ForeignKey(Invoice) + description = models.CharField(max_length=255) + quantity = models.PositiveIntegerField() + price = models.DecimalField(max_digits=8, decimal_places=2) + + +@python_2_unicode_compatible +class Payment(models.Model): + ''' A payment for an invoice. Each invoice can have multiple payments + attached to it.''' + + def __str__(self): + return "Payment: ref=%s amount=%s" % (self.reference, self.amount) + + invoice = models.ForeignKey(Invoice) + time = models.DateTimeField(default=timezone.now) + reference = models.CharField(max_length=64) + amount = models.DecimalField(max_digits=8, decimal_places=2) diff --git a/registrasion/tests/__init__.py b/registrasion/tests/__init__.py new file mode 100644 index 00000000..7f9e0703 --- /dev/null +++ b/registrasion/tests/__init__.py @@ -0,0 +1 @@ +default_app_config = "registrasion.apps.RegistrationConfig" diff --git a/registrasion/tests/patch_datetime.py b/registrasion/tests/patch_datetime.py new file mode 100644 index 00000000..f1149331 --- /dev/null +++ b/registrasion/tests/patch_datetime.py @@ -0,0 +1,24 @@ +from django.utils import timezone + +class SetTimeMixin(object): + ''' Patches timezone.now() for the duration of a test case. Allows us to + test time-based conditions (ceilings etc) relatively easily. ''' + + def setUp(self): + super(SetTimeMixin, self).setUp() + self._old_timezone_now = timezone.now + self.now = timezone.now() + timezone.now = self.new_timezone_now + + def tearDown(self): + timezone.now = self._old_timezone_now + super(SetTimeMixin, self).tearDown() + + def set_time(self, time): + self.now = time + + def add_timedelta(self, delta): + self.now += delta + + def new_timezone_now(self): + return self.now diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py new file mode 100644 index 00000000..d994d3d4 --- /dev/null +++ b/registrasion/tests/test_cart.py @@ -0,0 +1,185 @@ +import datetime +import pytz + +from decimal import Decimal +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from registrasion import models as rego +from registrasion.cart import CartController + +from patch_datetime import SetTimeMixin + +UTC = pytz.timezone('UTC') + +class RegistrationCartTestCase(SetTimeMixin, TestCase): + + def setUp(self): + super(RegistrationCartTestCase, self).setUp() + + @classmethod + def setUpTestData(cls): + cls.USER_1 = User.objects.create_user(username='testuser', + email='test@example.com', password='top_secret') + + cls.USER_2 = User.objects.create_user(username='testuser2', + email='test2@example.com', password='top_secret') + + cls.CAT_1 = rego.Category.objects.create( + name="Category 1", + description="This is a test category", + order=10, + render_type=rego.Category.RENDER_TYPE_RADIO, + ) + cls.CAT_1.save() + + cls.CAT_2 = rego.Category.objects.create( + name="Category 2", + description="This is a test category", + order=10, + render_type=rego.Category.RENDER_TYPE_RADIO, + ) + cls.CAT_2.save() + + cls.RESERVATION = datetime.timedelta(hours=1) + + cls.PROD_1 = rego.Product.objects.create( + name="Product 1", + description= "This is a test product. It costs $10. " \ + "A user may have 10 of them.", + category=cls.CAT_1, + price=Decimal("10.00"), + reservation_duration=cls.RESERVATION, + limit_per_user=10, + order=10, + ) + cls.PROD_1.save() + + cls.PROD_2 = rego.Product.objects.create( + name="Product 2", + description= "This is a test product. It costs $10. " \ + "A user may have 10 of them.", + category=cls.CAT_1, + price=Decimal("10.00"), + limit_per_user=10, + order=10, + ) + cls.PROD_2.save() + + cls.PROD_3 = rego.Product.objects.create( + name="Product 3", + description= "This is a test product. It costs $10. " \ + "A user may have 10 of them.", + category=cls.CAT_2, + price=Decimal("10.00"), + limit_per_user=10, + order=10, + ) + cls.PROD_2.save() + + + @classmethod + def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): + limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( + description=name, + mandatory=True, + limit=limit, + start_time=start_time, + end_time=end_time + ) + limit_ceiling.save() + limit_ceiling.products.add(cls.PROD_1, cls.PROD_2) + limit_ceiling.save() + + + @classmethod + def make_category_ceiling(cls, name, limit=None, start_time=None, end_time=None): + limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( + description=name, + mandatory=True, + limit=limit, + start_time=start_time, + end_time=end_time + ) + limit_ceiling.save() + limit_ceiling.categories.add(cls.CAT_1) + limit_ceiling.save() + + + @classmethod + def make_discount_ceiling(cls, name, limit=None, start_time=None, end_time=None): + limit_ceiling = rego.TimeOrStockLimitDiscount.objects.create( + description=name, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + limit_ceiling.save() + rego.DiscountForProduct.objects.create( + discount=limit_ceiling, + product=cls.PROD_1, + percentage=100, + quantity=10, + ).save() + + +class BasicCartTests(RegistrationCartTestCase): + + def test_get_cart(self): + current_cart = CartController.for_user(self.USER_1) + + current_cart.cart.active = False + current_cart.cart.save() + + old_cart = current_cart + + current_cart = CartController.for_user(self.USER_1) + self.assertNotEqual(old_cart.cart, current_cart.cart) + + current_cart2 = CartController.for_user(self.USER_1) + self.assertEqual(current_cart.cart, current_cart2.cart) + + + def test_add_to_cart_collapses_product_items(self): + current_cart = CartController.for_user(self.USER_1) + + # Add a product twice + current_cart.add_to_cart(self.PROD_1, 1) + current_cart.add_to_cart(self.PROD_1, 1) + + ## Count of products for a given user should be collapsed. + items = rego.ProductItem.objects.filter(cart=current_cart.cart, + product=self.PROD_1) + self.assertEqual(1, len(items)) + item = items[0] + self.assertEquals(2, item.quantity) + + + def test_add_to_cart_per_user_limit(self): + current_cart = CartController.for_user(self.USER_1) + + # User should be able to add 1 of PROD_1 to the current cart. + current_cart.add_to_cart(self.PROD_1, 1) + + # User should be able to add 1 of PROD_1 to the current cart. + current_cart.add_to_cart(self.PROD_1, 1) + + # User should not be able to add 10 of PROD_1 to the current cart now, + # because they have a limit of 10. + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 10) + + current_cart.cart.active = False + current_cart.cart.save() + + current_cart = CartController.for_user(self.USER_1) + # User should not be able to add 10 of PROD_1 to the current cart now, + # even though it's a new cart. + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 10) + + # Second user should not be affected by first user's limits + second_user_cart = CartController.for_user(self.USER_2) + second_user_cart.add_to_cart(self.PROD_1, 10) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py new file mode 100644 index 00000000..8e5f60d8 --- /dev/null +++ b/registrasion/tests/test_ceilings.py @@ -0,0 +1,141 @@ +import datetime +import pytz + +from decimal import Decimal +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from registrasion import models as rego +from registrasion.cart import CartController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + +class CeilingsTestCases(RegistrationCartTestCase): + + def test_add_to_cart_ceiling_limit(self): + self.make_ceiling("Limit ceiling", limit=9) + self.__add_to_cart_test() + + def test_add_to_cart_ceiling_category_limit(self): + self.make_category_ceiling("Limit ceiling", limit=9) + self.__add_to_cart_test() + + def __add_to_cart_test(self): + + current_cart = CartController.for_user(self.USER_1) + + # User should not be able to add 10 of PROD_1 to the current cart + # because it is affected by limit_ceiling + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_2, 10) + + # User should be able to add 5 of PROD_1 to the current cart + current_cart.add_to_cart(self.PROD_1, 5) + + # User should not be able to add 6 of PROD_2 to the current cart + # because it is affected by CEIL_1 + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_2, 6) + + # User should be able to add 5 of PROD_2 to the current cart + current_cart.add_to_cart(self.PROD_2, 4) + + + def test_add_to_cart_ceiling_date_range(self): + self.make_ceiling("date range ceiling", + start_time=datetime.datetime(2015, 01, 01, tzinfo=UTC), + end_time=datetime.datetime(2015, 02, 01, tzinfo=UTC) + ) + + current_cart = CartController.for_user(self.USER_1) + + # User should not be able to add whilst we're before start_time + self.set_time(datetime.datetime(2014, 01, 01, tzinfo=UTC)) + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 1) + + # User should be able to add whilst we're during date range + # On edge of start + self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + current_cart.add_to_cart(self.PROD_1, 1) + # In middle + self.set_time(datetime.datetime(2015, 01, 15, tzinfo=UTC)) + current_cart.add_to_cart(self.PROD_1, 1) + # On edge of end + self.set_time(datetime.datetime(2015, 02, 01, tzinfo=UTC)) + current_cart.add_to_cart(self.PROD_1, 1) + + # User should not be able to add whilst we're after date range + self.set_time(datetime.datetime(2014, 01, 01, minute=01, tzinfo=UTC)) + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_add_to_cart_ceiling_limit_reserved_carts(self): + self.make_ceiling("Limit ceiling", limit=1) + + self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + + first_cart = CartController.for_user(self.USER_1) + second_cart = CartController.for_user(self.USER_2) + + first_cart.add_to_cart(self.PROD_1, 1) + + # User 2 should not be able to add item to their cart + # because user 1 has item reserved, exhausting the ceiling + with self.assertRaises(ValidationError): + second_cart.add_to_cart(self.PROD_1, 1) + + # User 2 should be able to add item to their cart once the + # reservation duration is elapsed + self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1)) + second_cart.add_to_cart(self.PROD_1, 1) + + # User 2 pays for their cart + second_cart.cart.active = False + second_cart.cart.save() + + # User 1 should not be able to add item to their cart + # because user 2 has paid for their reserved item, exhausting + # the ceiling, regardless of the reservation time. + self.add_timedelta(self.RESERVATION * 20) + with self.assertRaises(ValidationError): + first_cart.add_to_cart(self.PROD_1, 1) + + + def test_validate_cart_fails_product_ceilings(self): + self.make_ceiling("Limit ceiling", limit=1) + self.__validation_test() + + def test_validate_cart_fails_product_discount_ceilings(self): + self.make_discount_ceiling("Limit ceiling", limit=1) + self.__validation_test() + + def __validation_test(self): + self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + + first_cart = CartController.for_user(self.USER_1) + second_cart = CartController.for_user(self.USER_2) + + # Adding a valid product should validate. + first_cart.add_to_cart(self.PROD_1, 1) + first_cart.validate_cart() + + # Cart should become invalid if lapsed carts are claimed. + self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1)) + + # Unpaid cart within reservation window + second_cart.add_to_cart(self.PROD_1, 1) + with self.assertRaises(ValidationError): + first_cart.validate_cart() + + # Paid cart outside the reservation window + second_cart.cart.active = False + second_cart.cart.save() + self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1)) + with self.assertRaises(ValidationError): + first_cart.validate_cart() diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py new file mode 100644 index 00000000..0199ac52 --- /dev/null +++ b/registrasion/tests/test_discount.py @@ -0,0 +1,184 @@ +import datetime +import pytz + +from decimal import Decimal +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from registrasion import models as rego +from registrasion.cart import CartController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + +class DiscountTestCase(RegistrationCartTestCase): + + @classmethod + def add_discount_prod_1_includes_prod_2(cls, amount=Decimal(100)): + discount = rego.IncludedProductDiscount.objects.create( + description="PROD_1 includes PROD_2 " + str(amount) + "%", + ) + discount.save() + discount.enabling_products.add(cls.PROD_1) + discount.save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=cls.PROD_2, + percentage=amount, + quantity=2 + ).save() + return discount + + + @classmethod + def add_discount_prod_1_includes_cat_2(cls, amount=Decimal(100)): + discount = rego.IncludedProductDiscount.objects.create( + description="PROD_1 includes CAT_2 " + str(amount) + "%", + ) + discount.save() + discount.enabling_products.add(cls.PROD_1) + discount.save() + rego.DiscountForCategory.objects.create( + discount=discount, + category=cls.CAT_2, + percentage=amount, + quantity=2 + ).save() + return discount + + + def test_discount_is_applied(self): + discount = self.add_discount_prod_1_includes_prod_2() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.add_to_cart(self.PROD_2, 1) + + # Discounts should be applied at this point... + self.assertEqual(1, len(cart.cart.discountitem_set.all())) + + + def test_discount_is_applied_for_category(self): + discount = self.add_discount_prod_1_includes_cat_2() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.add_to_cart(self.PROD_3, 1) + + # Discounts should be applied at this point... + self.assertEqual(1, len(cart.cart.discountitem_set.all())) + + + def test_discount_does_not_apply_if_not_met(self): + discount = self.add_discount_prod_1_includes_prod_2() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 1) + + # No discount should be applied as the condition is not met + self.assertEqual(0, len(cart.cart.discountitem_set.all())) + + + def test_discount_applied_out_of_order(self): + discount = self.add_discount_prod_1_includes_prod_2() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 1) + cart.add_to_cart(self.PROD_1, 1) + + # No discount should be applied as the condition is not met + self.assertEqual(1, len(cart.cart.discountitem_set.all())) + + + def test_discounts_collapse(self): + discount = self.add_discount_prod_1_includes_prod_2() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.add_to_cart(self.PROD_2, 1) + cart.add_to_cart(self.PROD_2, 1) + + # Discounts should be applied and collapsed at this point... + self.assertEqual(1, len(cart.cart.discountitem_set.all())) + + + def test_discounts_respect_quantity(self): + discount = self.add_discount_prod_1_includes_prod_2() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.add_to_cart(self.PROD_2, 3) + + # There should be three items in the cart, but only two should + # attract a discount. + discount_items = list(cart.cart.discountitem_set.all()) + self.assertEqual(2, discount_items[0].quantity) + + + def test_multiple_discounts_apply_in_order(self): + discount_full = self.add_discount_prod_1_includes_prod_2() + discount_half = self.add_discount_prod_1_includes_prod_2(Decimal(50)) + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.add_to_cart(self.PROD_2, 3) + + # There should be two discounts + discount_items = list(cart.cart.discountitem_set.all()) + discount_items.sort(key=lambda item: item.quantity) + self.assertEqual(2, len(discount_items)) + # The half discount should be applied only once + self.assertEqual(1, discount_items[0].quantity) + self.assertEqual(discount_half.pk, discount_items[0].discount.pk) + # The full discount should be applied twice + self.assertEqual(2, discount_items[1].quantity) + self.assertEqual(discount_full.pk, discount_items[1].discount.pk) + + + def test_discount_applies_across_carts(self): + discount_full = self.add_discount_prod_1_includes_prod_2() + + # Enable the discount during the first cart. + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.cart.active = False + cart.cart.save() + + # Use the discount in the second cart + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 1) + + # The discount should be applied. + self.assertEqual(1, len(cart.cart.discountitem_set.all())) + cart.cart.active = False + cart.cart.save() + + # The discount should respect the total quantity across all + # of the user's carts. + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 2) + + # Having one item in the second cart leaves one more item where + # the discount is applicable. The discount should apply, but only for + # quantity=1 + discount_items = list(cart.cart.discountitem_set.all()) + self.assertEqual(1, discount_items[0].quantity) + + + def test_discount_applies_only_once_enabled(self): + # Enable the discount during the first cart. + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.add_to_cart(self.PROD_2, 2) # This would exhaust discount if present + cart.cart.active = False + cart.cart.save() + + discount_full = self.add_discount_prod_1_includes_prod_2() + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 2) + + discount_items = list(cart.cart.discountitem_set.all()) + self.assertEqual(2, discount_items[0].quantity) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py new file mode 100644 index 00000000..9e6b8966 --- /dev/null +++ b/registrasion/tests/test_enabling_condition.py @@ -0,0 +1,171 @@ +import datetime +import pytz + +from decimal import Decimal +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from registrasion import models as rego +from registrasion.cart import CartController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + +class EnablingConditionTestCases(RegistrationCartTestCase): + + @classmethod + def add_product_enabling_condition(cls, mandatory=False): + ''' Adds a product enabling condition: adding PROD_1 to a cart is + predicated on adding PROD_2 beforehand. ''' + enabling_condition = rego.ProductEnablingCondition.objects.create( + description="Product condition", + mandatory=mandatory, + ) + enabling_condition.save() + enabling_condition.products.add(cls.PROD_1) + enabling_condition.enabling_products.add(cls.PROD_2) + enabling_condition.save() + + + @classmethod + def add_product_enabling_condition_on_category(cls, mandatory=False): + ''' Adds a product enabling condition that operates on a category: + adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' + enabling_condition = rego.ProductEnablingCondition.objects.create( + description="Product condition", + mandatory=mandatory, + ) + enabling_condition.save() + enabling_condition.categories.add(cls.CAT_1) + enabling_condition.enabling_products.add(cls.PROD_3) + enabling_condition.save() + + + def add_category_enabling_condition(cls, mandatory=False): + ''' Adds a category enabling condition: adding PROD_1 to a cart is + predicated on adding an item from CAT_2 beforehand.''' + enabling_condition = rego.CategoryEnablingCondition.objects.create( + description="Category condition", + mandatory=mandatory, + enabling_category=cls.CAT_2, + ) + enabling_condition.save() + enabling_condition.products.add(cls.PROD_1) + enabling_condition.save() + + + def test_product_enabling_condition_enables_product(self): + self.add_product_enabling_condition() + + # Cannot buy PROD_1 without buying PROD_2 + current_cart = CartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 1) + + current_cart.add_to_cart(self.PROD_2, 1) + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_product_enabled_by_product_in_previous_cart(self): + self.add_product_enabling_condition() + + current_cart = CartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_2, 1) + current_cart.cart.active = False + current_cart.cart.save() + + # Create new cart and try to add PROD_1 + current_cart = CartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_product_enabling_condition_enables_category(self): + self.add_product_enabling_condition_on_category() + + # Cannot buy PROD_1 without buying item from CAT_2 + current_cart = CartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 1) + + current_cart.add_to_cart(self.PROD_3, 1) + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_category_enabling_condition_enables_product(self): + self.add_category_enabling_condition() + + # Cannot buy PROD_1 without buying PROD_2 + current_cart = CartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 1) + + # PROD_3 is in CAT_2 + current_cart.add_to_cart(self.PROD_3, 1) + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_product_enabled_by_category_in_previous_cart(self): + self.add_category_enabling_condition() + + current_cart = CartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_3, 1) + current_cart.cart.active = False + current_cart.cart.save() + + # Create new cart and try to add PROD_1 + current_cart = CartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_multiple_non_mandatory_conditions(self): + self.add_product_enabling_condition() + self.add_category_enabling_condition() + + # User 1 is testing the product enabling condition + cart_1 = CartController.for_user(self.USER_1) + # Cannot add PROD_1 until a condition is met + with self.assertRaises(ValidationError): + cart_1.add_to_cart(self.PROD_1, 1) + cart_1.add_to_cart(self.PROD_2, 1) + cart_1.add_to_cart(self.PROD_1, 1) + + # User 2 is testing the category enabling condition + cart_2 = CartController.for_user(self.USER_2) + # Cannot add PROD_1 until a condition is met + with self.assertRaises(ValidationError): + cart_2.add_to_cart(self.PROD_1, 1) + cart_2.add_to_cart(self.PROD_3, 1) + cart_2.add_to_cart(self.PROD_1, 1) + + + def test_multiple_mandatory_conditions(self): + self.add_product_enabling_condition(mandatory=True) + self.add_category_enabling_condition(mandatory=True) + + cart_1 = CartController.for_user(self.USER_1) + # Cannot add PROD_1 until both conditions are met + with self.assertRaises(ValidationError): + cart_1.add_to_cart(self.PROD_1, 1) + cart_1.add_to_cart(self.PROD_2, 1) # Meets the product condition + with self.assertRaises(ValidationError): + cart_1.add_to_cart(self.PROD_1, 1) + cart_1.add_to_cart(self.PROD_3, 1) # Meets the category condition + cart_1.add_to_cart(self.PROD_1, 1) + + + def test_mandatory_conditions_are_mandatory(self): + self.add_product_enabling_condition(mandatory=False) + self.add_category_enabling_condition(mandatory=True) + + cart_1 = CartController.for_user(self.USER_1) + # Cannot add PROD_1 until both conditions are met + with self.assertRaises(ValidationError): + cart_1.add_to_cart(self.PROD_1, 1) + cart_1.add_to_cart(self.PROD_2, 1) # Meets the product condition + with self.assertRaises(ValidationError): + cart_1.add_to_cart(self.PROD_1, 1) + cart_1.add_to_cart(self.PROD_3, 1) # Meets the category condition + cart_1.add_to_cart(self.PROD_1, 1) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py new file mode 100644 index 00000000..323a425e --- /dev/null +++ b/registrasion/tests/test_invoice.py @@ -0,0 +1,108 @@ +import datetime +import pytz + +from decimal import Decimal +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from registrasion import models as rego +from registrasion.cart import CartController +from registrasion.invoice import InvoiceController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + + +class InvoiceTestCase(RegistrationCartTestCase): + + def test_create_invoice(self): + current_cart = CartController.for_user(self.USER_1) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + # That invoice should have a single line item + line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) + self.assertEqual(1, len(line_items)) + # That invoice should have a value equal to cost of PROD_1 + self.assertEqual(self.PROD_1.price, invoice_1.invoice.value) + + # Adding item to cart should void all active invoices and produce + # a new invoice + current_cart.add_to_cart(self.PROD_2, 1) + invoice_2 = InvoiceController.for_cart(current_cart.cart) + self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) + # Invoice should have two line items + line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) + self.assertEqual(2, len(line_items)) + # Invoice should have a value equal to cost of PROD_1 and PROD_2 + self.assertEqual( + self.PROD_1.price + self.PROD_2.price, + invoice_2.invoice.value) + + def test_create_invoice_fails_if_cart_invalid(self): + self.make_ceiling("Limit ceiling", limit=1) + self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + current_cart = CartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_1, 1) + + self.add_timedelta(self.RESERVATION * 2) + cart_2 = CartController.for_user(self.USER_2) + cart_2.add_to_cart(self.PROD_1, 1) + + # Now try to invoice the first user + with self.assertRaises(ValidationError): + InvoiceController.for_cart(current_cart.cart) + + def test_paying_invoice_makes_new_cart(self): + current_cart = CartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_1, 1) + + invoice = InvoiceController.for_cart(current_cart.cart) + invoice.pay("A payment!", invoice.invoice.value) + + # This payment is for the correct amount invoice should be paid. + self.assertTrue(invoice.invoice.paid) + + # Cart should not be active + self.assertFalse(invoice.invoice.cart.active) + + # Asking for a cart should generate a new one + new_cart = CartController.for_user(self.USER_1) + self.assertNotEqual(current_cart.cart, new_cart.cart) + + + def test_invoice_includes_discounts(self): + voucher = rego.Voucher.objects.create( + recipient="Voucher recipient", + code="VOUCHER", + limit=1 + ) + voucher.save() + discount = rego.VoucherDiscount.objects.create( + description="VOUCHER RECIPIENT", + voucher=voucher, + ) + discount.save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=self.PROD_1, + percentage=Decimal(50), + quantity=1 + ).save() + + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + # That invoice should have two line items + line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) + self.assertEqual(2, len(line_items)) + # That invoice should have a value equal to 50% of the cost of PROD_1 + self.assertEqual(self.PROD_1.price * Decimal("0.5"), invoice_1.invoice.value) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py new file mode 100644 index 00000000..0c9e362c --- /dev/null +++ b/registrasion/tests/test_voucher.py @@ -0,0 +1,97 @@ +import datetime +import pytz + +from decimal import Decimal +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from registrasion import models as rego +from registrasion.cart import CartController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + + +class VoucherTestCases(RegistrationCartTestCase): + + @classmethod + def new_voucher(self): + voucher = rego.Voucher.objects.create( + recipient="Voucher recipient", + code="VOUCHER", + limit=1 + ) + voucher.save() + return voucher + + def test_apply_voucher(self): + voucher = self.new_voucher() + + self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + + cart_1 = CartController.for_user(self.USER_1) + cart_1.apply_voucher(voucher) + self.assertIn(voucher, cart_1.cart.vouchers.all()) + + # Second user should not be able to apply this voucher (it's exhausted) + cart_2 = CartController.for_user(self.USER_2) + with self.assertRaises(ValidationError): + cart_2.apply_voucher(voucher) + + # After the reservation duration, user 2 should be able to apply voucher + self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) + cart_2.apply_voucher(voucher) + cart_2.cart.active = False + cart_2.cart.save() + + # After the reservation duration, user 1 should not be able to apply + # voucher, as user 2 has paid for their cart. + self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) + with self.assertRaises(ValidationError): + cart_1.apply_voucher(voucher) + + def test_voucher_enables_item(self): + voucher = self.new_voucher() + + enabling_condition = rego.VoucherEnablingCondition.objects.create( + description="Voucher condition", + voucher=voucher, + mandatory=False, + ) + enabling_condition.save() + enabling_condition.products.add(self.PROD_1) + enabling_condition.save() + + # Adding the product without a voucher will not work + current_cart = CartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_1, 1) + + # Apply the voucher + current_cart.apply_voucher(voucher) + current_cart.add_to_cart(self.PROD_1, 1) + + + def test_voucher_enables_discount(self): + voucher = self.new_voucher() + + discount = rego.VoucherDiscount.objects.create( + description="VOUCHER RECIPIENT", + voucher=voucher, + ) + discount.save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=self.PROD_1, + percentage=Decimal(100), + quantity=1 + ).save() + + # Having PROD_1 in place should add a discount + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher) + current_cart.add_to_cart(self.PROD_1, 1) + self.assertEqual(1, len(current_cart.cart.discountitem_set.all())) From c2400c4695aed708f1e6f2dd81df97bdff0bf508 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 16:21:23 +1100 Subject: [PATCH 004/418] Moves the controller modules into their own subpackage. There's going to be a lot of stuff in there. --- registrasion/controllers/__init__.py | 0 registrasion/{ => controllers}/cart.py | 4 ++-- registrasion/{ => controllers}/conditions.py | 0 registrasion/{ => controllers}/invoice.py | 0 registrasion/{controllers.py => controllers/product.py} | 0 registrasion/tests/test_cart.py | 2 +- registrasion/tests/test_ceilings.py | 2 +- registrasion/tests/test_discount.py | 2 +- registrasion/tests/test_enabling_condition.py | 2 +- registrasion/tests/test_invoice.py | 4 ++-- registrasion/tests/test_voucher.py | 2 +- 11 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 registrasion/controllers/__init__.py rename registrasion/{ => controllers}/cart.py (99%) rename registrasion/{ => controllers}/conditions.py (100%) rename registrasion/{ => controllers}/invoice.py (100%) rename registrasion/{controllers.py => controllers/product.py} (100%) diff --git a/registrasion/controllers/__init__.py b/registrasion/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/cart.py b/registrasion/controllers/cart.py similarity index 99% rename from registrasion/cart.py rename to registrasion/controllers/cart.py index 521980f6..8995b864 100644 --- a/registrasion/cart.py +++ b/registrasion/controllers/cart.py @@ -9,7 +9,7 @@ from django.utils import timezone from registrasion import models as rego from conditions import ConditionController -from controllers import ProductController +from product import ProductController class CartController(object): @@ -206,5 +206,5 @@ class CartController(object): quantity = ours - allowed else: quantity = 0 - + discount_item.save() diff --git a/registrasion/conditions.py b/registrasion/controllers/conditions.py similarity index 100% rename from registrasion/conditions.py rename to registrasion/controllers/conditions.py diff --git a/registrasion/invoice.py b/registrasion/controllers/invoice.py similarity index 100% rename from registrasion/invoice.py rename to registrasion/controllers/invoice.py diff --git a/registrasion/controllers.py b/registrasion/controllers/product.py similarity index 100% rename from registrasion/controllers.py rename to registrasion/controllers/product.py diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d994d3d4..7cec8bf5 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -8,7 +8,7 @@ from django.test import TestCase from django.utils import timezone from registrasion import models as rego -from registrasion.cart import CartController +from registrasion.controllers.cart import CartController from patch_datetime import SetTimeMixin diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 8e5f60d8..1e0ba3d4 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -8,7 +8,7 @@ from django.test import TestCase from django.utils import timezone from registrasion import models as rego -from registrasion.cart import CartController +from registrasion.controllers.cart import CartController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 0199ac52..8d93b64f 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -8,7 +8,7 @@ from django.test import TestCase from django.utils import timezone from registrasion import models as rego -from registrasion.cart import CartController +from registrasion.controllers.cart import CartController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 9e6b8966..60b1a64b 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -8,7 +8,7 @@ from django.test import TestCase from django.utils import timezone from registrasion import models as rego -from registrasion.cart import CartController +from registrasion.controllers.cart import CartController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 323a425e..7dd5aaa5 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -8,8 +8,8 @@ from django.test import TestCase from django.utils import timezone from registrasion import models as rego -from registrasion.cart import CartController -from registrasion.invoice import InvoiceController +from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 0c9e362c..3f52db7b 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -8,7 +8,7 @@ from django.test import TestCase from django.utils import timezone from registrasion import models as rego -from registrasion.cart import CartController +from registrasion.controllers.cart import CartController from test_cart import RegistrationCartTestCase From 224878a10ce374abbb83e4425a6e3a4de64c34a3 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 17:02:07 +1100 Subject: [PATCH 005/418] Fixes flake8 errors --- registrasion/admin.py | 1 - registrasion/controllers/cart.py | 23 +++----- registrasion/controllers/conditions.py | 30 +++++----- registrasion/controllers/invoice.py | 14 ++--- registrasion/controllers/product.py | 17 ++++-- registrasion/models.py | 57 +++++++++++-------- registrasion/tests/patch_datetime.py | 1 + registrasion/tests/test_cart.py | 42 +++++++------- registrasion/tests/test_ceilings.py | 15 ++--- registrasion/tests/test_discount.py | 35 ++++-------- registrasion/tests/test_enabling_condition.py | 24 ++------ registrasion/tests/test_invoice.py | 8 +-- registrasion/tests/test_voucher.py | 7 +-- setup.cfg | 3 + 14 files changed, 125 insertions(+), 152 deletions(-) create mode 100644 setup.cfg diff --git a/registrasion/admin.py b/registrasion/admin.py index 74016950..e00b58ea 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -6,7 +6,6 @@ import nested_admin from registrasion import models as rego - # Inventory admin class ProductInline(admin.TabularInline): diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 8995b864..e9532bf5 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,9 +1,8 @@ import datetime -import itertools from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError -from django.db.models import Avg, Min, Max, Sum +from django.db.models import Max, Sum from django.utils import timezone from registrasion import models as rego @@ -33,7 +32,6 @@ class CartController(object): existing.save() return CartController(existing) - def extend_reservation(self): ''' Updates the cart's time last updated value, which is used to determine whether the cart has reserved the items and discounts it @@ -56,7 +54,6 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) - def add_to_cart(self, product, quantity): ''' Adds _quantity_ of the given _product_ to the cart. Raises ValidationError if constraints are violated.''' @@ -91,7 +88,6 @@ class CartController(object): self.cart.revision += 1 self.cart.save() - def apply_voucher(self, voucher): ''' Applies the given voucher to this cart. ''' @@ -110,7 +106,6 @@ class CartController(object): self.cart.revision += 1 self.cart.save() - def validate_cart(self): ''' Determines whether the status of the current cart is valid; this is normally called before generating or paying an invoice ''' @@ -121,13 +116,15 @@ class CartController(object): items = rego.ProductItem.objects.filter(cart=self.cart) for item in items: - # per-user limits are tested at add time, and are unliklely to change + # NOTE: per-user limits are tested at add time + # and are unliklely to change prod = ProductController(item.product) # If the cart is not reserved, we need to see if we can re-reserve quantity = 0 if is_reserved else item.quantity - if not prod.can_add_with_enabling_conditions(self.cart.user, quantity): + if not prod.can_add_with_enabling_conditions( + self.cart.user, quantity): raise ValidationError("Products are no longer available") # Validate the discounts @@ -139,7 +136,8 @@ class CartController(object): if discount in seen_discounts: continue seen_discounts.add(discount) - real_discount = rego.DiscountBase.objects.get_subclass(pk=discount.pk) + real_discount = rego.DiscountBase.objects.get_subclass( + pk=discount.pk) cond = ConditionController.for_condition(real_discount) quantity = 0 if is_reserved else discount_item.quantity @@ -147,8 +145,6 @@ class CartController(object): if not cond.is_met(self.cart.user, quantity): raise ValidationError("Discounts are no longer available") - - def recalculate_discounts(self): ''' Calculates all of the discounts available for this product. NB should be transactional, and it's terribly inefficient. @@ -160,11 +156,10 @@ class CartController(object): for item in self.cart.productitem_set.all(): self._add_discount(item.product, item.quantity) - def _add_discount(self, product, quantity): ''' Calculates the best available discounts for this product. - NB this will be super-inefficient in aggregate because discounts will be - re-tested for each product. We should work on that.''' + NB this will be super-inefficient in aggregate because discounts will + be re-tested for each product. We should work on that.''' prod = ProductController(product) discounts = prod.available_discounts(self.cart.user) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 27770c95..914a2092 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,4 +1,4 @@ -from django.db.models import F, Q +from django.db.models import Q from django.db.models import Sum from django.utils import timezone @@ -15,15 +15,15 @@ class ConditionController(object): @staticmethod def for_condition(condition): CONTROLLERS = { - rego.CategoryEnablingCondition : CategoryConditionController, - rego.IncludedProductDiscount : ProductConditionController, - rego.ProductEnablingCondition : ProductConditionController, - rego.TimeOrStockLimitDiscount : + rego.CategoryEnablingCondition: CategoryConditionController, + rego.IncludedProductDiscount: ProductConditionController, + rego.ProductEnablingCondition: ProductConditionController, + rego.TimeOrStockLimitDiscount: TimeOrStockLimitConditionController, - rego.TimeOrStockLimitEnablingCondition : + rego.TimeOrStockLimitEnablingCondition: TimeOrStockLimitConditionController, - rego.VoucherDiscount : VoucherConditionController, - rego.VoucherEnablingCondition : VoucherConditionController, + rego.VoucherDiscount: VoucherConditionController, + rego.VoucherEnablingCondition: VoucherConditionController, } try: @@ -31,7 +31,6 @@ class ConditionController(object): except KeyError: return ConditionController() - def is_met(self, user, quantity): return True @@ -48,7 +47,8 @@ class CategoryConditionController(ConditionController): carts = rego.Cart.objects.filter(user=user) enabling_products = rego.Product.objects.filter( category=self.condition.enabling_category) - products = rego.ProductItem.objects.filter(cart=carts, + products = rego.ProductItem.objects.filter( + cart=carts, product=enabling_products) return len(products) > 0 @@ -65,7 +65,8 @@ class ProductConditionController(ConditionController): condition in one of their carts ''' carts = rego.Cart.objects.filter(user=user) - products = rego.ProductItem.objects.filter(cart=carts, + products = rego.ProductItem.objects.filter( + cart=carts, product=self.condition.enabling_products.all()) return len(products) > 0 @@ -77,7 +78,6 @@ class TimeOrStockLimitConditionController(ConditionController): def __init__(self, ceiling): self.ceiling = ceiling - def is_met(self, user, quantity): ''' returns True if adding _quantity_ of _product_ will not vioilate this ceiling. ''' @@ -93,7 +93,6 @@ class TimeOrStockLimitConditionController(ConditionController): # All limits have been met return True - def test_date_range(self): now = timezone.now() @@ -107,7 +106,6 @@ class TimeOrStockLimitConditionController(ConditionController): return True - def _products(self): ''' Abstracts away the product list, becuase enabling conditions list products differently to discounts. ''' @@ -125,7 +123,6 @@ class TimeOrStockLimitConditionController(ConditionController): Q(category=categories.all()) ) - def test_limits(self, quantity): if self.ceiling.limit is None: return True @@ -155,6 +152,7 @@ class VoucherConditionController(ConditionController): def is_met(self, user, quantity): ''' returns True if the user has the given voucher attached. ''' - carts = rego.Cart.objects.filter(user=user, + carts = rego.Cart.objects.filter( + user=user, vouchers=self.condition.voucher) return len(carts) > 0 diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 67a8c836..5ce562f3 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -1,11 +1,12 @@ from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist -from django.db.models import Avg, Min, Max, Sum +from django.db.models import Sum from registrasion import models as rego from cart import CartController + class InvoiceController(object): def __init__(self, invoice): @@ -22,12 +23,11 @@ class InvoiceController(object): cart=cart, cart_revision=cart.revision) except ObjectDoesNotExist: cart_controller = CartController(cart) - cart_controller.validate_cart() # Raises ValidationError on fail. + cart_controller.validate_cart() # Raises ValidationError on fail. invoice = cls._generate(cart) return InvoiceController(invoice) - @classmethod def resolve_discount_value(cls, item): try: @@ -46,7 +46,6 @@ class InvoiceController(object): value = condition.price return value - @classmethod def _generate(cls, cart): ''' Generates an invoice for the given cart. ''' @@ -89,7 +88,6 @@ class InvoiceController(object): return invoice - def is_valid(self): ''' Returns true if the attached invoice is not void and it represents a valid cart. ''' @@ -100,12 +98,10 @@ class InvoiceController(object): return False return True - def void(self): ''' Voids the invoice. ''' self.invoice.void = True - def pay(self, reference, amount): ''' Pays the invoice by the given amount. If the payment equals the total on the invoice, finalise the invoice. @@ -113,7 +109,7 @@ class InvoiceController(object): ''' if self.invoice.cart is not None: cart = CartController(self.invoice.cart) - cart.validate_cart() # Raises ValidationError if invalid + cart.validate_cart() # Raises ValidationError if invalid ''' Adds a payment ''' payment = rego.Payment.objects.create( @@ -127,7 +123,7 @@ class InvoiceController(object): agg = payments.aggregate(Sum("amount")) total = agg["amount__sum"] - if total==self.invoice.value: + if total == self.invoice.value: self.invoice.paid = True cart = self.invoice.cart diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 795c2d48..8a1f402e 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -2,12 +2,17 @@ import itertools from collections import namedtuple -from django.db.models import F, Q +from django.db.models import Q from registrasion import models as rego from conditions import ConditionController -DiscountEnabler = namedtuple("DiscountEnabler", ("discount", "condition", "value")) +DiscountEnabler = namedtuple( + "DiscountEnabler", ( + "discount", + "condition", + "value")) + class ProductController(object): @@ -19,7 +24,9 @@ class ProductController(object): this Product without exceeding _limit_per_user_.''' carts = rego.Cart.objects.filter(user=user) - items = rego.ProductItem.objects.filter(product=self.product, cart=carts) + items = rego.ProductItem.objects.filter( + product=self.product, + cart=carts) count = 0 for item in items: @@ -62,7 +69,6 @@ class ProductController(object): return True - def get_enabler(self, condition): if condition.percentage is not None: value = condition.percentage * self.product.price @@ -91,7 +97,8 @@ class ProductController(object): discounts = [] for discount in potential_discounts: - real_discount = rego.DiscountBase.objects.get_subclass(pk=discount.discount.pk) + real_discount = rego.DiscountBase.objects.get_subclass( + pk=discount.discount.pk) cond = ConditionController.for_condition(real_discount) if cond.is_met(user, 0): discounts.append(discount) diff --git a/registrasion/models.py b/registrasion/models.py index cc3c6aac..e3c6a9f8 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals import datetime -from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.db import models @@ -13,10 +12,6 @@ from django.utils.translation import ugettext_lazy as _ from model_utils.managers import InheritanceManager -from symposion.markdown_parser import parse -from symposion.proposals.models import ProposalBase - - # User models @python_2_unicode_compatible @@ -63,9 +58,11 @@ class Category(models.Model): ] name = models.CharField(max_length=65, verbose_name=_("Name")) - description = models.CharField(max_length=255, verbose_name=_("Description")) + description = models.CharField(max_length=255, + verbose_name=_("Description")) order = models.PositiveIntegerField(verbose_name=("Display order")) - render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, verbose_name=_("Render type")) + render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, + verbose_name=_("Render type")) @python_2_unicode_compatible @@ -76,10 +73,15 @@ class Product(models.Model): return self.name name = models.CharField(max_length=65, verbose_name=_("Name")) - description = models.CharField(max_length=255, verbose_name=_("Description")) + description = models.CharField(max_length=255, + verbose_name=_("Description")) category = models.ForeignKey(Category, verbose_name=_("Product category")) - price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name=_("Price")) - limit_per_user = models.PositiveIntegerField(blank=True, verbose_name=_("Limit per user")) + price = models.DecimalField(max_digits=8, + decimal_places=2, + verbose_name=_("Price")) + limit_per_user = models.PositiveIntegerField( + blank=True, + verbose_name=_("Limit per user")) reservation_duration = models.DurationField( default=datetime.timedelta(hours=1), verbose_name=_("Reservation duration")) @@ -98,7 +100,9 @@ class Voucher(models.Model): return "Voucher for %s" % self.recipient recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) - code = models.CharField(max_length=16, unique=True, verbose_name=_("Voucher code")) + code = models.CharField(max_length=16, + unique=True, + verbose_name=_("Voucher code")) limit = models.PositiveIntegerField(verbose_name=_("Voucher use limit")) @@ -107,8 +111,8 @@ class Voucher(models.Model): @python_2_unicode_compatible class DiscountBase(models.Model): ''' Base class for discounts. Each subclass has controller code that - determines whether or not the given discount is available to be added to the - current cart. ''' + determines whether or not the given discount is available to be added to + the current cart. ''' objects = InheritanceManager() @@ -116,7 +120,7 @@ class DiscountBase(models.Model): return "Discount: " + self.description description = models.CharField(max_length=255, - verbose_name=_("Description")) + verbose_name=_("Description")) @python_2_unicode_compatible @@ -156,7 +160,10 @@ class DiscountForCategory(models.Model): discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) category = models.ForeignKey(Category, on_delete=models.CASCADE) - percentage = models.DecimalField(max_digits=4, decimal_places=1, blank=True) + percentage = models.DecimalField( + max_digits=4, + decimal_places=1, + blank=True) quantity = models.PositiveIntegerField() @@ -176,7 +183,9 @@ class VoucherDiscount(DiscountBase): ''' Discounts that are enabled when a voucher code is in the current cart. ''' - voucher = models.OneToOneField(Voucher, on_delete=models.CASCADE, + voucher = models.OneToOneField( + Voucher, + on_delete=models.CASCADE, verbose_name=_("Voucher")) @@ -187,14 +196,15 @@ class IncludedProductDiscount(DiscountBase): class Meta: verbose_name = _("Product inclusion") - enabling_products = models.ManyToManyField(Product, + enabling_products = models.ManyToManyField( + Product, verbose_name=_("Including product")) class RoleDiscount(object): ''' Discounts that are enabled because the active user has a specific role. This is for e.g. volunteers who can get a discount ticket. ''' - ## TODO: implement RoleDiscount + # TODO: implement RoleDiscount pass @@ -253,16 +263,16 @@ class VoucherEnablingCondition(EnablingConditionBase): enabling sponsor tickets. ''' def __str__(self): - return "Enabled by voucher: %s" % voucher + return "Enabled by voucher: %s" % self.voucher voucher = models.OneToOneField(Voucher) -#@python_2_unicode_compatible +# @python_2_unicode_compatible class RoleEnablingCondition(object): ''' The condition is met because the active user has a particular Role. This is for e.g. enabling Team tickets. ''' - ## TODO: implement RoleEnablingCondition + # TODO: implement RoleEnablingCondition pass @@ -289,8 +299,9 @@ class Cart(models.Model): ''' Gets all carts that are 'reserved' ''' return Cart.objects.filter( (Q(active=True) & - Q(time_last_updated__gt=timezone.now()-F('reservation_duration') - )) | + Q(time_last_updated__gt=( + timezone.now()-F('reservation_duration') + ))) | Q(active=False) ) diff --git a/registrasion/tests/patch_datetime.py b/registrasion/tests/patch_datetime.py index f1149331..8b64b606 100644 --- a/registrasion/tests/patch_datetime.py +++ b/registrasion/tests/patch_datetime.py @@ -1,5 +1,6 @@ from django.utils import timezone + class SetTimeMixin(object): ''' Patches timezone.now() for the duration of a test case. Allows us to test time-based conditions (ceilings etc) relatively easily. ''' diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 7cec8bf5..c0c0bc49 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -5,7 +5,6 @@ from decimal import Decimal from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.test import TestCase -from django.utils import timezone from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -14,6 +13,7 @@ from patch_datetime import SetTimeMixin UTC = pytz.timezone('UTC') + class RegistrationCartTestCase(SetTimeMixin, TestCase): def setUp(self): @@ -21,11 +21,15 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def setUpTestData(cls): - cls.USER_1 = User.objects.create_user(username='testuser', - email='test@example.com', password='top_secret') + cls.USER_1 = User.objects.create_user( + username='testuser', + email='test@example.com', + password='top_secret') - cls.USER_2 = User.objects.create_user(username='testuser2', - email='test2@example.com', password='top_secret') + cls.USER_2 = User.objects.create_user( + username='testuser2', + email='test2@example.com', + password='top_secret') cls.CAT_1 = rego.Category.objects.create( name="Category 1", @@ -47,8 +51,8 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_1 = rego.Product.objects.create( name="Product 1", - description= "This is a test product. It costs $10. " \ - "A user may have 10 of them.", + description="This is a test product. It costs $10. " + "A user may have 10 of them.", category=cls.CAT_1, price=Decimal("10.00"), reservation_duration=cls.RESERVATION, @@ -59,8 +63,8 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_2 = rego.Product.objects.create( name="Product 2", - description= "This is a test product. It costs $10. " \ - "A user may have 10 of them.", + description="This is a test product. It costs $10. " + "A user may have 10 of them.", category=cls.CAT_1, price=Decimal("10.00"), limit_per_user=10, @@ -70,8 +74,8 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_3 = rego.Product.objects.create( name="Product 3", - description= "This is a test product. It costs $10. " \ - "A user may have 10 of them.", + description="This is a test product. It costs $10. " + "A user may have 10 of them.", category=cls.CAT_2, price=Decimal("10.00"), limit_per_user=10, @@ -79,7 +83,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): ) cls.PROD_2.save() - @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( @@ -93,9 +96,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): limit_ceiling.products.add(cls.PROD_1, cls.PROD_2) limit_ceiling.save() - @classmethod - def make_category_ceiling(cls, name, limit=None, start_time=None, end_time=None): + def make_category_ceiling( + cls, name, limit=None, start_time=None, end_time=None): limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( description=name, mandatory=True, @@ -107,9 +110,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): limit_ceiling.categories.add(cls.CAT_1) limit_ceiling.save() - @classmethod - def make_discount_ceiling(cls, name, limit=None, start_time=None, end_time=None): + def make_discount_ceiling( + cls, name, limit=None, start_time=None, end_time=None): limit_ceiling = rego.TimeOrStockLimitDiscount.objects.create( description=name, start_time=start_time, @@ -141,7 +144,6 @@ class BasicCartTests(RegistrationCartTestCase): current_cart2 = CartController.for_user(self.USER_1) self.assertEqual(current_cart.cart, current_cart2.cart) - def test_add_to_cart_collapses_product_items(self): current_cart = CartController.for_user(self.USER_1) @@ -149,14 +151,14 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) current_cart.add_to_cart(self.PROD_1, 1) - ## Count of products for a given user should be collapsed. - items = rego.ProductItem.objects.filter(cart=current_cart.cart, + # Count of products for a given user should be collapsed. + items = rego.ProductItem.objects.filter( + cart=current_cart.cart, product=self.PROD_1) self.assertEqual(1, len(items)) item = items[0] self.assertEquals(2, item.quantity) - def test_add_to_cart_per_user_limit(self): current_cart = CartController.for_user(self.USER_1) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 1e0ba3d4..0bbd6e9e 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -1,19 +1,15 @@ import datetime import pytz -from decimal import Decimal -from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone -from registrasion import models as rego from registrasion.controllers.cart import CartController from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') + class CeilingsTestCases(RegistrationCartTestCase): def test_add_to_cart_ceiling_limit(self): @@ -44,12 +40,11 @@ class CeilingsTestCases(RegistrationCartTestCase): # 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", + self.make_ceiling( + "date range ceiling", start_time=datetime.datetime(2015, 01, 01, tzinfo=UTC), - end_time=datetime.datetime(2015, 02, 01, tzinfo=UTC) - ) + end_time=datetime.datetime(2015, 02, 01, tzinfo=UTC)) current_cart = CartController.for_user(self.USER_1) @@ -74,7 +69,6 @@ class CeilingsTestCases(RegistrationCartTestCase): 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) @@ -106,7 +100,6 @@ class CeilingsTestCases(RegistrationCartTestCase): 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() diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 8d93b64f..c0709e8e 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -1,11 +1,6 @@ -import datetime import pytz from decimal import Decimal -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -14,6 +9,7 @@ from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') + class DiscountTestCase(RegistrationCartTestCase): @classmethod @@ -32,7 +28,6 @@ class DiscountTestCase(RegistrationCartTestCase): ).save() return discount - @classmethod def add_discount_prod_1_includes_cat_2(cls, amount=Decimal(100)): discount = rego.IncludedProductDiscount.objects.create( @@ -49,9 +44,8 @@ class DiscountTestCase(RegistrationCartTestCase): ).save() return discount - def test_discount_is_applied(self): - discount = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) @@ -60,9 +54,8 @@ class DiscountTestCase(RegistrationCartTestCase): # Discounts should be applied at this point... self.assertEqual(1, len(cart.cart.discountitem_set.all())) - def test_discount_is_applied_for_category(self): - discount = self.add_discount_prod_1_includes_cat_2() + self.add_discount_prod_1_includes_cat_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) @@ -71,9 +64,8 @@ class DiscountTestCase(RegistrationCartTestCase): # Discounts should be applied at this point... self.assertEqual(1, len(cart.cart.discountitem_set.all())) - def test_discount_does_not_apply_if_not_met(self): - discount = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -81,9 +73,8 @@ class DiscountTestCase(RegistrationCartTestCase): # No discount should be applied as the condition is not met self.assertEqual(0, len(cart.cart.discountitem_set.all())) - def test_discount_applied_out_of_order(self): - discount = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -92,9 +83,8 @@ class DiscountTestCase(RegistrationCartTestCase): # No discount should be applied as the condition is not met self.assertEqual(1, len(cart.cart.discountitem_set.all())) - def test_discounts_collapse(self): - discount = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) @@ -104,9 +94,8 @@ class DiscountTestCase(RegistrationCartTestCase): # Discounts should be applied and collapsed at this point... self.assertEqual(1, len(cart.cart.discountitem_set.all())) - def test_discounts_respect_quantity(self): - discount = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) @@ -117,7 +106,6 @@ class DiscountTestCase(RegistrationCartTestCase): 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)) @@ -137,9 +125,8 @@ class DiscountTestCase(RegistrationCartTestCase): self.assertEqual(2, discount_items[1].quantity) self.assertEqual(discount_full.pk, discount_items[1].discount.pk) - def test_discount_applies_across_carts(self): - discount_full = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() # Enable the discount during the first cart. cart = CartController.for_user(self.USER_1) @@ -167,16 +154,16 @@ class DiscountTestCase(RegistrationCartTestCase): discount_items = list(cart.cart.discountitem_set.all()) self.assertEqual(1, discount_items[0].quantity) - def test_discount_applies_only_once_enabled(self): # Enable the discount during the first cart. cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) - cart.add_to_cart(self.PROD_2, 2) # This would exhaust discount if present + # This would exhaust discount if present + cart.add_to_cart(self.PROD_2, 2) cart.cart.active = False cart.cart.save() - discount_full = self.add_discount_prod_1_includes_prod_2() + self.add_discount_prod_1_includes_prod_2() cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 2) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 60b1a64b..09433cdd 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -1,11 +1,6 @@ -import datetime import pytz -from decimal import Decimal -from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -14,6 +9,7 @@ from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') + class EnablingConditionTestCases(RegistrationCartTestCase): @classmethod @@ -29,7 +25,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): enabling_condition.enabling_products.add(cls.PROD_2) enabling_condition.save() - @classmethod def add_product_enabling_condition_on_category(cls, mandatory=False): ''' Adds a product enabling condition that operates on a category: @@ -43,7 +38,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): enabling_condition.enabling_products.add(cls.PROD_3) enabling_condition.save() - def add_category_enabling_condition(cls, mandatory=False): ''' Adds a category enabling condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' @@ -56,7 +50,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): enabling_condition.products.add(cls.PROD_1) enabling_condition.save() - def test_product_enabling_condition_enables_product(self): self.add_product_enabling_condition() @@ -68,7 +61,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_2, 1) current_cart.add_to_cart(self.PROD_1, 1) - def test_product_enabled_by_product_in_previous_cart(self): self.add_product_enabling_condition() @@ -81,7 +73,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart = CartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) - def test_product_enabling_condition_enables_category(self): self.add_product_enabling_condition_on_category() @@ -93,7 +84,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_3, 1) current_cart.add_to_cart(self.PROD_1, 1) - def test_category_enabling_condition_enables_product(self): self.add_category_enabling_condition() @@ -106,7 +96,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_3, 1) current_cart.add_to_cart(self.PROD_1, 1) - def test_product_enabled_by_category_in_previous_cart(self): self.add_category_enabling_condition() @@ -119,7 +108,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart = CartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) - def test_multiple_non_mandatory_conditions(self): self.add_product_enabling_condition() self.add_category_enabling_condition() @@ -140,7 +128,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_3, 1) cart_2.add_to_cart(self.PROD_1, 1) - def test_multiple_mandatory_conditions(self): self.add_product_enabling_condition(mandatory=True) self.add_category_enabling_condition(mandatory=True) @@ -149,13 +136,12 @@ class EnablingConditionTestCases(RegistrationCartTestCase): # 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 + 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_3, 1) # Meets the category condition cart_1.add_to_cart(self.PROD_1, 1) - def test_mandatory_conditions_are_mandatory(self): self.add_product_enabling_condition(mandatory=False) self.add_category_enabling_condition(mandatory=True) @@ -164,8 +150,8 @@ class EnablingConditionTestCases(RegistrationCartTestCase): # 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 + 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_3, 1) # Meets the category condition cart_1.add_to_cart(self.PROD_1, 1) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 7dd5aaa5..4d0360ce 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -2,10 +2,7 @@ import datetime import pytz from decimal import Decimal -from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -74,7 +71,6 @@ class InvoiceTestCase(RegistrationCartTestCase): new_cart = CartController.for_user(self.USER_1) self.assertNotEqual(current_cart.cart, new_cart.cart) - def test_invoice_includes_discounts(self): voucher = rego.Voucher.objects.create( recipient="Voucher recipient", @@ -105,4 +101,6 @@ class InvoiceTestCase(RegistrationCartTestCase): line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) self.assertEqual(2, len(line_items)) # That invoice should have a value equal to 50% of the cost of PROD_1 - self.assertEqual(self.PROD_1.price * Decimal("0.5"), invoice_1.invoice.value) + self.assertEqual( + self.PROD_1.price * Decimal("0.5"), + invoice_1.invoice.value) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 3f52db7b..169a1b79 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -2,10 +2,7 @@ import datetime import pytz from decimal import Decimal -from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -41,7 +38,8 @@ class VoucherTestCases(RegistrationCartTestCase): with self.assertRaises(ValidationError): cart_2.apply_voucher(voucher) - # After the reservation duration, user 2 should be able to apply voucher + # After the reservation duration + # user 2 should be able to apply voucher self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) cart_2.apply_voucher(voucher) cart_2.cart.active = False @@ -74,7 +72,6 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.apply_voucher(voucher) current_cart.add_to_cart(self.PROD_1, 1) - def test_voucher_enables_discount(self): voucher = self.new_voucher() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..c9dcb437 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +exclude = registrasion/migrations/* + From 95038d8b859192cb6ede87787918c5db5e75ac57 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 18:02:39 +1100 Subject: [PATCH 006/418] Commits goals.md --- design/goals.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 design/goals.md diff --git a/design/goals.md b/design/goals.md new file mode 100644 index 00000000..94d23c79 --- /dev/null +++ b/design/goals.md @@ -0,0 +1,55 @@ +# Registrasion + +## What + +A registration package that sits on top of the Symposion conference management system. It aims to be able to model complex events, such as those used by [Linux Australia events](http://lca2016.linux.org.au/register/info?_code=301). + + +## Planned features + +### KEY: +- _(MODEL)_: these have model/controller functionality and needs UI +- _(ADMIN)_: these have admin functionality + +### Inventory +- Allow conferences to manage complex inventories of products, including tickets, t-shirts, dinner tickets, and accommodation _(MODEL)_ _(ADMIN)_ +- Reports of available inventory and progressive sales for conference staff +- Restrict sales of products to specific classes of users +- Restrict sales of products based to users who've purchased specific products _(MODEL)_ _(ADMIN)_ +- Restrict sales of products based on time/inventory limits _(MODEL)_ _(ADMIN)_ +- Restrict sales of products to users with a voucher _(MODEL)_ _(ADMIN)_ + +### Tickets +- Sell multiple types of tickets, each with different included products _(MODEL)_ _(ADMIN)_ +- Allow for early bird-style discounts _(MODEL)_ _(ADMIN)_ +- Allow attendees to purchase products after initial registration is complete _(MODEL)_ + - Offer included products if they have not yet been claimed _(MODEL)_ +- Automatically offer free tickets to speakers and team +- Offer free tickets for sponsor attendees by voucher _(MODEL)_ _(ADMIN)_ + +### Vouchers +- Vouchers for arbitrary discounts off visible products _(MODEL)_ _(ADMIN)_ +- Vouchers that enable secret products _(MODEL)_ _(ADMIN)_ + +### Invoicing +- Automatic invoicing including discount calculation _(MODEL)_ +- Manual invoicing for arbitrary products by organisers _(MODEL)_ +- Refunds + +### Payments +- Allow multiple payment gateways (so that conferences are not locked into specific payment providers) +- Allow payment of registrations by unauthenticated users (allow business admins to pay for registrations) +- Allow payment of multiple registrations at once + +### Attendee profiles +- Attendees can enter information to be shown on their badge/dietary requirements etc +- Profile can be changed until check-in, allowing for badge/company updates + +### At the conference +- Badge generation, in batches, or on-demand during check-in +- Registration manifests for each attendee including purchased products +- Check-in process at registration desk allowing manifested items to be claimed + +### Tooling +- Generate simple registration cases (ones that don't have complex inventory requirements) +- Generate complex registration cases from spreadsheets From 5302bca18d2015e1ab297dc1cf397f296f66c625 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 18:04:34 +1100 Subject: [PATCH 007/418] Amends LICENCE to refer to chrisjrn --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 8dada3ed..65336534 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + 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. From 3f3db53232706f14bce9ac73cb95034472f80d0d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 18:16:15 +1100 Subject: [PATCH 008/418] Notes that things are tested. --- design/goals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/goals.md b/design/goals.md index 94d23c79..776220df 100644 --- a/design/goals.md +++ b/design/goals.md @@ -8,7 +8,7 @@ A registration package that sits on top of the Symposion conference management s ## Planned features ### KEY: -- _(MODEL)_: these have model/controller functionality and needs UI +- _(MODEL)_: these have model/controller functionality, and tests, and needs UI - _(ADMIN)_: these have admin functionality ### Inventory From 2e89bc48871f5e2e91373ea44e5f7608594fefd7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 21:53:30 +1100 Subject: [PATCH 009/418] Adds validation code to make sure that only one discount condition is applicable per product --- registrasion/models.py | 47 +++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index e3c6a9f8..4866c00b 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -143,10 +143,26 @@ class DiscountForProduct(models.Model): 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 or self not in prods: + raise ValidationError( + _("You may only have one discount line per product")) + if len(cats) != 0: + raise ValidationError( + _("You may only have one discount for " + "a product or its category")) + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) - percentage = models.DecimalField(max_digits=4, decimal_places=1, null=True) - price = models.DecimalField(max_digits=8, decimal_places=2, null=True) + 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() @@ -158,12 +174,26 @@ class DiscountForCategory(models.Model): 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 or self not in cats: + raise ValidationError( + _("You may only have one discount line per category")) + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) category = models.ForeignKey(Category, on_delete=models.CASCADE) percentage = models.DecimalField( max_digits=4, - decimal_places=1, - blank=True) + decimal_places=1) quantity = models.PositiveIntegerField() @@ -174,9 +204,12 @@ class TimeOrStockLimitDiscount(DiscountBase): class Meta: verbose_name = _("Promotional discount") - start_time = models.DateTimeField(null=True, verbose_name=_("Start time")) - end_time = models.DateTimeField(null=True, verbose_name=_("End time")) - limit = models.PositiveIntegerField(null=True, verbose_name=_("Limit")) + start_time = models.DateTimeField( + null=True, blank=True, verbose_name=_("Start time")) + end_time = models.DateTimeField( + null=True, blank=True, verbose_name=_("End time")) + limit = models.PositiveIntegerField( + null=True, blank=True, verbose_name=_("Limit")) class VoucherDiscount(DiscountBase): From c13a986f2dd86ce37f400aff775d9248cf47e4df Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Jan 2016 22:07:59 +1100 Subject: [PATCH 010/418] Updates migration --- registrasion/migrations/0001_initial.py | 31 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_initial.py index bfa64439..ab78d1f0 100644 --- a/registrasion/migrations/0001_initial.py +++ b/registrasion/migrations/0001_initial.py @@ -168,7 +168,7 @@ class Migration(migrations.Migration): name='IncludedProductDiscount', fields=[ ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), - ('enabling_products', models.ManyToManyField(to='registrasion.Product', verbose_name='Including product')), + ('enabling_products', models.ManyToManyField(to=b'registrasion.Product', verbose_name='Including product')), ], options={ 'verbose_name': 'Product inclusion', @@ -179,7 +179,7 @@ class Migration(migrations.Migration): name='ProductEnablingCondition', fields=[ ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), - ('enabling_products', models.ManyToManyField(to='registrasion.Product')), + ('enabling_products', models.ManyToManyField(to=b'registrasion.Product')), ], bases=('registrasion.enablingconditionbase',), ), @@ -187,9 +187,9 @@ class Migration(migrations.Migration): name='TimeOrStockLimitDiscount', fields=[ ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), - ('start_time', models.DateTimeField(null=True, verbose_name='Start time')), - ('end_time', models.DateTimeField(null=True, verbose_name='End time')), - ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit')), + ('start_time', models.DateTimeField(null=True, verbose_name='Start time', blank=True)), + ('end_time', models.DateTimeField(null=True, verbose_name='End time', blank=True)), + ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit', blank=True)), ], options={ 'verbose_name': 'Promotional discount', @@ -225,12 +225,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='enablingconditionbase', name='categories', - field=models.ManyToManyField(to='registrasion.Category'), + field=models.ManyToManyField(to=b'registrasion.Category'), ), migrations.AddField( model_name='enablingconditionbase', name='products', - field=models.ManyToManyField(to='registrasion.Product'), + field=models.ManyToManyField(to=b'registrasion.Product'), ), migrations.AddField( model_name='discountitem', @@ -260,11 +260,26 @@ class Migration(migrations.Migration): migrations.AddField( model_name='cart', name='vouchers', - field=models.ManyToManyField(to='registrasion.Voucher', blank=True), + field=models.ManyToManyField(to=b'registrasion.Voucher', blank=True), ), migrations.AddField( model_name='badge', name='profile', field=models.OneToOneField(to='registrasion.Profile'), ), + migrations.AlterField( + model_name='discountforcategory', + name='percentage', + field=models.DecimalField(max_digits=4, decimal_places=1), + ), + migrations.AlterField( + model_name='discountforproduct', + name='percentage', + field=models.DecimalField(null=True, max_digits=4, decimal_places=1, blank=True), + ), + migrations.AlterField( + model_name='discountforproduct', + name='price', + field=models.DecimalField(null=True, max_digits=8, decimal_places=2, blank=True), + ), ] From c51be4d30aff2325bd44dd0033305e1ca849b349 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 3 Mar 2016 18:18:58 -0800 Subject: [PATCH 011/418] Adds set_quantity as a method on CartController. Refactors add_to_cart to be in terms of set_quantity --- registrasion/controllers/cart.py | 94 +++++++++++++++++++++----------- registrasion/tests/test_cart.py | 33 +++++++++++ 2 files changed, 95 insertions(+), 32 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index e9532bf5..acdce035 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -54,40 +54,73 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) - def add_to_cart(self, product, quantity): - ''' Adds _quantity_ of the given _product_ to the cart. Raises - ValidationError if constraints are violated.''' - - prod = ProductController(product) - - # TODO: Check enabling conditions for product for user - - if not prod.can_add_with_enabling_conditions(self.cart.user, quantity): - raise ValidationError("Not enough of that product left (ec)") - - if not prod.user_can_add_within_limit(self.cart.user, quantity): - raise ValidationError("Not enough of that product left (user)") - - try: - # Try to update an existing item within this cart if possible. - product_item = rego.ProductItem.objects.get( - cart=self.cart, - product=product) - product_item.quantity += quantity - except ObjectDoesNotExist: - product_item = rego.ProductItem.objects.create( - cart=self.cart, - product=product, - quantity=quantity, - ) - product_item.save() - + def end_batch(self): + ''' Performs operations that occur occur at the end of a batch of + product changes/voucher applications etc. ''' self.recalculate_discounts() self.extend_reservation() self.cart.revision += 1 self.cart.save() + def set_quantity(self, product, quantity, batched=False): + ''' Sets the _quantity_ of the given _product_ in the cart to the given + _quantity_. ''' + + if quantity < 0: + raise ValidationError("Cannot have fewer than 0 items in cart.") + + try: + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product) + old_quantity = product_item.quantity + + if quantity == 0: + product_item.delete() + return + except ObjectDoesNotExist: + if quantity == 0: + return + + product_item = rego.ProductItem.objects.create( + cart=self.cart, + product=product, + quantity=0, + ) + + old_quantity = 0 + + # Validate the addition to the cart + adjustment = quantity - old_quantity + prod = ProductController(product) + + if not prod.can_add_with_enabling_conditions( + self.cart.user, adjustment): + raise ValidationError("Not enough of that product left (ec)") + + if not prod.user_can_add_within_limit(self.cart.user, adjustment): + raise ValidationError("Not enough of that product left (user)") + + product_item.quantity = quantity + product_item.save() + + if not batched: + self.end_batch() + + 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 = rego.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 apply_voucher(self, voucher): ''' Applies the given voucher to this cart. ''' @@ -101,10 +134,7 @@ class CartController(object): # If successful... self.cart.vouchers.add(voucher) - - self.extend_reservation() - self.cart.revision += 1 - self.cart.save() + self.end_batch() def validate_cart(self): ''' Determines whether the status of the current cart is valid; diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index c0c0bc49..dad32efa 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -3,6 +3,7 @@ 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.test import TestCase @@ -159,6 +160,38 @@ class BasicCartTests(RegistrationCartTestCase): item = items[0] self.assertEquals(2, item.quantity) + def test_set_quantity(self): + current_cart = CartController.for_user(self.USER_1) + + def get_item(): + return rego.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_per_user_limit(self): current_cart = CartController.for_user(self.USER_1) From 1b7d8a60c1197cf4648ad54de20ae34267f235c8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 3 Mar 2016 13:40:44 -0800 Subject: [PATCH 012/418] Adds product_category form, which allows users to add products from a specific category to their cart. --- registrasion/forms.py | 13 +++++ registrasion/models.py | 4 +- registrasion/templates/product_category.html | 20 +++++++ registrasion/urls.py | 7 +++ registrasion/views.py | 57 ++++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 registrasion/forms.py create mode 100644 registrasion/templates/product_category.html create mode 100644 registrasion/urls.py create mode 100644 registrasion/views.py diff --git a/registrasion/forms.py b/registrasion/forms.py new file mode 100644 index 00000000..dc32d7b1 --- /dev/null +++ b/registrasion/forms.py @@ -0,0 +1,13 @@ +import models as rego + +from django import forms + + +class ProductItemForm(forms.Form): + product = forms.ModelChoiceField(queryset=None, empty_label=None) + quantity = forms.IntegerField() + + def __init__(self, category, *a, **k): + super(ProductItemForm, self).__init__(*a, **k) + products = rego.Product.objects.filter(category=category) + self.fields['product'].queryset = products diff --git a/registrasion/models.py b/registrasion/models.py index 4866c00b..35b44305 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -155,7 +155,7 @@ class DiscountForProduct(models.Model): if len(cats) != 0: raise ValidationError( _("You may only have one discount for " - "a product or its category")) + "a product or its category")) discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) @@ -184,7 +184,7 @@ class DiscountForCategory(models.Model): if len(prods) != 0: raise ValidationError( _("You may only have one discount for " - "a product or its category")) + "a product or its category")) if len(cats) > 1 or self not in cats: raise ValidationError( _("You may only have one discount line per category")) diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html new file mode 100644 index 00000000..54a5bb7b --- /dev/null +++ b/registrasion/templates/product_category.html @@ -0,0 +1,20 @@ + + +{% extends "site_base.html" %} +{% block body %} + +

Product Category: {{ category.name }}

+ +

{{ category.description }}

+ +
+ {% csrf_token %} + + {{ formset }} +
+ + + +
+ +{% endblock %} diff --git a/registrasion/urls.py b/registrasion/urls.py new file mode 100644 index 00000000..257f2e2f --- /dev/null +++ b/registrasion/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import url, patterns + +urlpatterns = patterns( + "registrasion.views", + url(r"^category/([0-9]+)$", "product_category", name="product_category"), + # url(r"^category$", "product_category", name="product_category"), +) diff --git a/registrasion/views.py b/registrasion/views.py new file mode 100644 index 00000000..1b5bc254 --- /dev/null +++ b/registrasion/views.py @@ -0,0 +1,57 @@ +from registrasion import forms +from registrasion import models as rego +from registrasion.controllers.cart import CartController + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.forms import formset_factory +from django.shortcuts import render +from functools import partial, wraps + + +@login_required +def product_category(request, category_id): + ''' Registration selections form for a specific category of items ''' + + category_id = int(category_id) # Routing is [0-9]+ + category = rego.Category.objects.get(pk=category_id) + + ProductItemFormForCategory = ( + wraps(forms.ProductItemForm) + (partial(forms.ProductItemForm, category=category))) + ProductItemFormSet = formset_factory(ProductItemFormForCategory, extra=0) + + if request.method == "POST": + formset = ProductItemFormSet(request.POST, request.FILES) + if formset.is_valid(): + current_cart = CartController.for_user(request.user) + with transaction.atomic(): + for form in formset.forms: + data = form.cleaned_data + # TODO set form error instead of failing completely + current_cart.set_quantity( + data["product"], data["quantity"], batched=True) + current_cart.end_batch() + else: + # Create initial data for each of products in category + initial = [] + products = rego.Product.objects.filter(category=category) + items = rego.ProductItem.objects.filter(product__category=category) + products = products.order_by("order") + for product in products: + try: + quantity = items.get(product=product).quantity + except ObjectDoesNotExist: + quantity = 0 + data = {"product": product, "quantity": quantity} + initial.append(data) + + formset = ProductItemFormSet(initial=initial) + + data = { + "category": category, + "formset": formset, + } + + return render(request, "product_category.html", data) From a4de15830c1824464810d7a1b58e1fe474b5eb1d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 12:22:01 -0800 Subject: [PATCH 013/418] Adds checkout view, which generates an invoice, and then redirects to the invoice itself. --- registrasion/controllers/invoice.py | 9 ++++--- registrasion/templates/invoice.html | 37 +++++++++++++++++++++++++++++ registrasion/urls.py | 3 ++- registrasion/views.py | 27 +++++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 registrasion/templates/invoice.html diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 5ce562f3..e9a654e4 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -59,20 +59,23 @@ class InvoiceController(object): # TODO: calculate line items. product_items = rego.ProductItem.objects.filter(cart=cart) + product_items = product_items.order_by( + "product__category__order", "product__order" + ) discount_items = rego.DiscountItem.objects.filter(cart=cart) invoice_value = Decimal() for item in product_items: + product = item.product line_item = rego.LineItem.objects.create( invoice=invoice, - description=item.product.name, + description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, - price=item.product.price, + price=product.price, ) line_item.save() invoice_value += line_item.quantity * line_item.price for item in discount_items: - line_item = rego.LineItem.objects.create( invoice=invoice, description=item.discount.description, diff --git a/registrasion/templates/invoice.html b/registrasion/templates/invoice.html new file mode 100644 index 00000000..a66132cf --- /dev/null +++ b/registrasion/templates/invoice.html @@ -0,0 +1,37 @@ + + +{% extends "site_base.html" %} +{% block body %} + +

Invoice {{ invoice.id }}

+ +
    +
  • Void: {{ invoice.void }}
  • +
  • Paid: {{ invoice.paid }}
  • +
+ + + + + + + + + {% for line_item in invoice.lineitem_set.all %} + + + + + + + {% endfor %} + + + + + + +
DescriptionQuantityPrice/UnitTotal
{{line_item.description}}{{line_item.quantity}}{{line_item.price}} FIXME
TOTAL{{ invoice.value }}
+ + +{% endblock %} diff --git a/registrasion/urls.py b/registrasion/urls.py index 257f2e2f..d8a1c6db 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -3,5 +3,6 @@ from django.conf.urls import url, patterns urlpatterns = patterns( "registrasion.views", url(r"^category/([0-9]+)$", "product_category", name="product_category"), - # url(r"^category$", "product_category", name="product_category"), + url(r"^checkout$", "checkout", name="checkout"), + url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 1b5bc254..4e5f372a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,11 +1,13 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.forms import formset_factory +from django.shortcuts import redirect from django.shortcuts import render from functools import partial, wraps @@ -55,3 +57,28 @@ def product_category(request, category_id): } return render(request, "product_category.html", data) + +@login_required +def checkout(request): + ''' Runs checkout for the current cart of items, ideally generating an + invoice. ''' + + current_cart = CartController.for_user(request.user) + current_invoice = InvoiceController.for_cart(current_cart.cart) + + return redirect("invoice", current_invoice.invoice.id) + + +@login_required +def invoice(request, invoice_id): + ''' Displays an invoice for a given invoice id. ''' + + invoice_id = int(invoice_id) + inv = rego.Invoice.objects.get(pk=invoice_id) + current_invoice = InvoiceController(inv) + + data = { + "invoice": current_invoice.invoice, + } + + return render(request, "invoice.html", data) From 99f4b8dfe09a9e693c051771b22314dbcf98385a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 13:07:18 -0800 Subject: [PATCH 014/418] Fixes validation error in models.py for adding discounts --- registrasion/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index 35b44305..26e6b6b6 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -149,7 +149,7 @@ class DiscountForProduct(models.Model): cats = DiscountForCategory.objects.filter( discount=self.discount, category=self.product.category) - if len(prods) > 1 or self not in prods: + if len(prods) > 1: raise ValidationError( _("You may only have one discount line per product")) if len(cats) != 0: @@ -185,7 +185,7 @@ class DiscountForCategory(models.Model): raise ValidationError( _("You may only have one discount for " "a product or its category")) - if len(cats) > 1 or self not in cats: + if len(cats) > 1: raise ValidationError( _("You may only have one discount line per category")) From 0182a32f03475d673d745ec7fad9d10c41a847fc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 13:07:45 -0800 Subject: [PATCH 015/418] Fixes various errors in discount calculation, and adds tests for these --- registrasion/controllers/cart.py | 9 +++++--- registrasion/tests/test_cart.py | 13 ++++++++++- registrasion/tests/test_discount.py | 35 +++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index acdce035..5cbe5c8c 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -183,7 +183,12 @@ class CartController(object): # Delete the existing entries. rego.DiscountItem.objects.filter(cart=self.cart).delete() - for item in self.cart.productitem_set.all(): + # The highest-value discounts will apply to the highest-value + # products first. + product_items = self.cart.productitem_set.all() + product_items = product_items.order_by('product__price') + product_items = reversed(product_items) + for item in product_items: self._add_discount(item.product, item.quantity) def _add_discount(self, product, quantity): @@ -202,9 +207,7 @@ class CartController(object): # Get the count of past uses of this discount condition # as this affects the total amount we're allowed to use now. past_uses = rego.DiscountItem.objects.filter( - cart__active=False, discount=discount.discount, - product=product, ) agg = past_uses.aggregate(Sum("quantity")) past_uses = agg["quantity__sum"] diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index dad32efa..c65011b9 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -82,7 +82,18 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): limit_per_user=10, order=10, ) - cls.PROD_2.save() + cls.PROD_3.save() + + cls.PROD_4 = rego.Product.objects.create( + name="Product 4", + description="This is a test product. It costs $5. " + "A user may have 10 of them.", + category=cls.CAT_2, + price=Decimal("5.00"), + limit_per_user=10, + order=10, + ) + cls.PROD_4.save() @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index c0709e8e..5d325eff 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -29,7 +29,10 @@ class DiscountTestCase(RegistrationCartTestCase): return discount @classmethod - def add_discount_prod_1_includes_cat_2(cls, amount=Decimal(100)): + def add_discount_prod_1_includes_cat_2( + cls, + amount=Decimal(100), + quantity=2): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) @@ -40,7 +43,7 @@ class DiscountTestCase(RegistrationCartTestCase): discount=discount, category=cls.CAT_2, percentage=amount, - quantity=2 + quantity=quantity, ).save() return discount @@ -169,3 +172,31 @@ class DiscountTestCase(RegistrationCartTestCase): 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 = CartController.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 = CartController.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) From 8400da17da600eff505337ebe8d37b21adacc484 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 14:28:58 -0800 Subject: [PATCH 016/418] Fixes error in EnablingConditionBase, adds admins for Product and Category enabling conditions --- registrasion/admin.py | 12 ++++++++++++ ...l.py => 0001_squashed_0002_auto_20160304_1723.py} | 6 ++++-- registrasion/models.py | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) rename registrasion/migrations/{0001_initial.py => 0001_squashed_0002_auto_20160304_1723.py} (99%) diff --git a/registrasion/admin.py b/registrasion/admin.py index e00b58ea..17969cd1 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -80,3 +80,15 @@ class VoucherAdmin(nested_admin.NestedAdmin): VoucherDiscountInline, VoucherEnablingConditionInline, ] + + +# Enabling conditions +@admin.register(rego.ProductEnablingCondition) +class ProductEnablingConditionAdmin(nested_admin.NestedAdmin): + model = rego.ProductEnablingCondition + + +# Enabling conditions +@admin.register(rego.CategoryEnablingCondition) +class CategoryEnablingConditionAdmin(nested_admin.NestedAdmin): + model = rego.CategoryEnablingCondition diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py similarity index 99% rename from registrasion/migrations/0001_initial.py rename to registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py index ab78d1f0..cc1e3f0d 100644 --- a/registrasion/migrations/0001_initial.py +++ b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py @@ -9,6 +9,8 @@ from django.conf import settings class Migration(migrations.Migration): + replaces = [('registrasion', '0001_initial'), ('registrasion', '0002_auto_20160304_1723')] + dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -225,12 +227,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='enablingconditionbase', name='categories', - field=models.ManyToManyField(to=b'registrasion.Category'), + field=models.ManyToManyField(to=b'registrasion.Category', blank=True), ), migrations.AddField( model_name='enablingconditionbase', name='products', - field=models.ManyToManyField(to=b'registrasion.Product'), + field=models.ManyToManyField(to=b'registrasion.Product', blank=True), ), migrations.AddField( model_name='discountitem', diff --git a/registrasion/models.py b/registrasion/models.py index 26e6b6b6..c84a6abf 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -257,8 +257,8 @@ class EnablingConditionBase(models.Model): description = models.CharField(max_length=255) mandatory = models.BooleanField(default=False) - products = models.ManyToManyField(Product) - categories = models.ManyToManyField(Category) + products = models.ManyToManyField(Product, blank=True) + categories = models.ManyToManyField(Category, blank=True) class TimeOrStockLimitEnablingCondition(EnablingConditionBase): From 68e7e4e594fbfb862bf3da63d714ed0fde2c6a45 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 14:35:09 -0800 Subject: [PATCH 017/418] Checks enabling conditions before adding items to the list --- registrasion/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/registrasion/views.py b/registrasion/views.py index 4e5f372a..d13028ac 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -2,6 +2,7 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController +from registrasion.controllers.product import ProductController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist @@ -42,6 +43,11 @@ def product_category(request, category_id): items = rego.ProductItem.objects.filter(product__category=category) products = products.order_by("order") for product in products: + # Only add items that are enabled. + prod = ProductController(product) + if not prod.can_add_with_enabling_conditions(request.user, 0): + continue + try: quantity = items.get(product=product).quantity except ObjectDoesNotExist: From 745f6db444fa81384bc36c4a3da3e37d39f92875 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 18:01:16 -0800 Subject: [PATCH 018/418] =?UTF-8?q?Adds=20=E2=80=9CCategoryForm=E2=80=9D?= =?UTF-8?q?=20to=20forms.py.=20It=E2=80=99s=20about=20to=20replace=20the?= =?UTF-8?q?=20existing=20ProductItem=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/forms.py | 49 +++++++++++++--- registrasion/templates/product_category.html | 4 +- registrasion/views.py | 60 ++++++++++++-------- 3 files changed, 80 insertions(+), 33 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index dc32d7b1..34ccc878 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,11 +3,46 @@ import models as rego from django import forms -class ProductItemForm(forms.Form): - product = forms.ModelChoiceField(queryset=None, empty_label=None) - quantity = forms.IntegerField() +def CategoryForm(category): - def __init__(self, category, *a, **k): - super(ProductItemForm, self).__init__(*a, **k) - products = rego.Product.objects.filter(category=category) - self.fields['product'].queryset = products + PREFIX = "product_" + + def field_name(product): + return PREFIX + ("%d" % product.id) + + class _CategoryForm(forms.Form): + + @staticmethod + def initial_data(product_quantities): + ''' Prepares initial data for an instance of this form. + product_quantities is a sequence of (product,quantity) tuples ''' + initial = {} + for product, quantity in product_quantities: + initial[field_name(product)] = quantity + + return initial + + def product_quantities(self): + ''' Yields a sequence of (product, quantity) tuples from the + cleaned form data. ''' + for name, value in self.cleaned_data.items(): + if name.startswith(PREFIX): + product_id = int(name[len(PREFIX):]) + yield (product_id, value, name) + + def disable_product(self, product): + ''' Removes a given product from this form. ''' + del self.fields[field_name(product)] + + products = rego.Product.objects.filter(category=category).order_by("order") + for product in products: + + help_text = "$%d -- %s" % (product.price, product.description) + + field = forms.IntegerField( + label=product.name, + help_text=help_text, + ) + _CategoryForm.base_fields[field_name(product)] = field + + return _CategoryForm diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html index 54a5bb7b..9822cfcd 100644 --- a/registrasion/templates/product_category.html +++ b/registrasion/templates/product_category.html @@ -9,12 +9,14 @@
{% csrf_token %} + - {{ formset }} + {{ form }}
+ {% endblock %} diff --git a/registrasion/views.py b/registrasion/views.py index d13028ac..9f54f46e 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -6,11 +6,10 @@ from registrasion.controllers.product import ProductController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError from django.db import transaction -from django.forms import formset_factory from django.shortcuts import redirect from django.shortcuts import render -from functools import partial, wraps @login_required @@ -20,50 +19,61 @@ def product_category(request, category_id): category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) - ProductItemFormForCategory = ( - wraps(forms.ProductItemForm) - (partial(forms.ProductItemForm, category=category))) - ProductItemFormSet = formset_factory(ProductItemFormForCategory, extra=0) + CategoryForm = forms.CategoryForm(category) + + products = rego.Product.objects.filter(category=category) + products = products.order_by("order") if request.method == "POST": - formset = ProductItemFormSet(request.POST, request.FILES) - if formset.is_valid(): + cat_form = CategoryForm(request.POST, request.FILES) + if cat_form.is_valid(): current_cart = CartController.for_user(request.user) - with transaction.atomic(): - for form in formset.forms: - data = form.cleaned_data - # TODO set form error instead of failing completely - current_cart.set_quantity( - data["product"], data["quantity"], batched=True) - current_cart.end_batch() + try: + with transaction.atomic(): + for product_id, quantity, field_name \ + in cat_form.product_quantities(): + product = rego.Product.objects.get(pk=product_id) + try: + current_cart.set_quantity( + product, quantity, batched=True) + except ValidationError as ve: + cat_form.add_error(field_name, ve) + if cat_form.errors: + raise ValidationError("Cannot add that stuff") + current_cart.end_batch() + except ValidationError as ve: + pass + else: # Create initial data for each of products in category - initial = [] - products = rego.Product.objects.filter(category=category) items = rego.ProductItem.objects.filter(product__category=category) - products = products.order_by("order") + quantities = [] for product in products: # Only add items that are enabled. prod = ProductController(product) - if not prod.can_add_with_enabling_conditions(request.user, 0): - continue - try: quantity = items.get(product=product).quantity except ObjectDoesNotExist: quantity = 0 - data = {"product": product, "quantity": quantity} - initial.append(data) + quantities.append((product, quantity)) - formset = ProductItemFormSet(initial=initial) + initial = CategoryForm.initial_data(quantities) + cat_form = CategoryForm(initial=initial) + + for product in products: + # Remove fields that do not have an enabling condition. + prod = ProductController(product) + if not prod.can_add_with_enabling_conditions(request.user, 0): + cat_form.disable_product(product) data = { "category": category, - "formset": formset, + "form": cat_form, } return render(request, "product_category.html", data) + @login_required def checkout(request): ''' Runs checkout for the current cart of items, ideally generating an From cc424908321921582f5b6db50b155a7271e6f061 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 13:29:18 +1100 Subject: [PATCH 019/418] Applying a voucher to a cart now uses the voucher code rather than the voucher object. Adds tests for constraints on vouchers. --- registrasion/controllers/cart.py | 8 ++++++-- registrasion/models.py | 5 +++++ registrasion/tests/test_invoice.py | 2 +- registrasion/tests/test_voucher.py | 31 ++++++++++++++++++++++-------- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 5cbe5c8c..59b308ed 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -121,13 +121,17 @@ class CartController(object): old_quantity = 0 self.set_quantity(product, old_quantity + quantity) - def apply_voucher(self, voucher): - ''' Applies the given voucher to this cart. ''' + def apply_voucher(self, voucher_code): + ''' Applies the voucher with the given code to this cart. ''' # TODO: is it valid for a cart to re-add a voucher that they have? # Is voucher exhausted? active_carts = rego.Cart.reserved_carts() + + # Try and find the voucher + voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + carts_with_voucher = active_carts.filter(vouchers=voucher) if len(carts_with_voucher) >= voucher.limit: raise ValidationError("This voucher is no longer available") diff --git a/registrasion/models.py b/registrasion/models.py index c84a6abf..96ecf219 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -99,6 +99,11 @@ class Voucher(models.Model): def __str__(self): return "Voucher for %s" % self.recipient + def save(self, *a, **k): + ''' Normalise the voucher code to be uppercase ''' + self.code = self.code.upper() + super(Voucher, self).save(*a, **k) + recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) code = models.CharField(max_length=16, unique=True, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 4d0360ce..637e82db 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -91,7 +91,7 @@ class InvoiceTestCase(RegistrationCartTestCase): ).save() current_cart = CartController.for_user(self.USER_1) - current_cart.apply_voucher(voucher) + 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) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 169a1b79..50b83417 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -3,6 +3,7 @@ import pytz from decimal import Decimal from django.core.exceptions import ValidationError +from django.db import IntegrityError from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -15,10 +16,10 @@ UTC = pytz.timezone('UTC') class VoucherTestCases(RegistrationCartTestCase): @classmethod - def new_voucher(self): + def new_voucher(self, code="VOUCHER"): voucher = rego.Voucher.objects.create( recipient="Voucher recipient", - code="VOUCHER", + code=code, limit=1 ) voucher.save() @@ -30,18 +31,18 @@ class VoucherTestCases(RegistrationCartTestCase): self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) cart_1 = CartController.for_user(self.USER_1) - cart_1.apply_voucher(voucher) + 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 = CartController.for_user(self.USER_2) with self.assertRaises(ValidationError): - cart_2.apply_voucher(voucher) + cart_2.apply_voucher(voucher.code) # After the reservation duration # user 2 should be able to apply voucher self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) - cart_2.apply_voucher(voucher) + cart_2.apply_voucher(voucher.code) cart_2.cart.active = False cart_2.cart.save() @@ -49,7 +50,7 @@ class VoucherTestCases(RegistrationCartTestCase): # voucher, as user 2 has paid for their cart. self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) with self.assertRaises(ValidationError): - cart_1.apply_voucher(voucher) + cart_1.apply_voucher(voucher.code) def test_voucher_enables_item(self): voucher = self.new_voucher() @@ -69,7 +70,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) # Apply the voucher - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) def test_voucher_enables_discount(self): @@ -89,6 +90,20 @@ class VoucherTestCases(RegistrationCartTestCase): # Having PROD_1 in place should add a discount current_cart = CartController.for_user(self.USER_1) - current_cart.apply_voucher(voucher) + 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())) + + def test_voucher_codes_unique(self): + voucher1 = self.new_voucher(code="VOUCHER") + with self.assertRaises(IntegrityError): + voucher2 = self.new_voucher(code="VOUCHER") + + def test_multiple_vouchers_work(self): + voucher1 = self.new_voucher(code="VOUCHER1") + voucher2 = self.new_voucher(code="VOUCHER2") + + def test_vouchers_case_insensitive(self): + voucher = self.new_voucher(code="VOUCHeR") + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code.lower()) From 2d6b28c5a6daabcdbea819c5af4adc8e67eb5e07 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 13:33:33 +1100 Subject: [PATCH 020/418] Adds mechanism for entering a voucher code --- registrasion/forms.py | 7 ++++++ registrasion/templates/product_category.html | 6 +++++ registrasion/views.py | 25 ++++++++++++++++---- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 34ccc878..54b3115a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -46,3 +46,10 @@ def CategoryForm(category): _CategoryForm.base_fields[field_name(product)] = field return _CategoryForm + +class VoucherForm(forms.Form): + voucher = forms.CharField( + label="Voucher code", + help_text="If you have a voucher code, enter it here", + required=True, + ) diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html index 9822cfcd..0e567bf0 100644 --- a/registrasion/templates/product_category.html +++ b/registrasion/templates/product_category.html @@ -10,6 +10,12 @@
{% csrf_token %} + + {{ voucher_form }} +
+ + + {{ form }}
diff --git a/registrasion/views.py b/registrasion/views.py index 9f54f46e..0c6c5ee6 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,9 @@ from django.shortcuts import render def product_category(request, category_id): ''' Registration selections form for a specific category of items ''' + PRODUCTS_FORM_PREFIX = "products" + VOUCHERS_FORM_PREFIX = "vouchers" + category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) @@ -25,9 +28,19 @@ def product_category(request, category_id): products = products.order_by("order") if request.method == "POST": - cat_form = CategoryForm(request.POST, request.FILES) - if cat_form.is_valid(): - current_cart = CartController.for_user(request.user) + cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) + voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) + current_cart = CartController.for_user(request.user) + + if voucher_form.is_valid(): + # Apply voucher + # leave + voucher = voucher_form.cleaned_data["voucher"] + try: + current_cart.apply_voucher(voucher) + except Exception as e: + voucher_form.add_error("voucher", e) + elif cat_form.is_valid(): try: with transaction.atomic(): for product_id, quantity, field_name \ @@ -58,7 +71,9 @@ def product_category(request, category_id): quantities.append((product, quantity)) initial = CategoryForm.initial_data(quantities) - cat_form = CategoryForm(initial=initial) + cat_form = CategoryForm(prefix=PRODUCTS_FORM_PREFIX, initial=initial) + + voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) for product in products: # Remove fields that do not have an enabling condition. @@ -66,9 +81,11 @@ def product_category(request, category_id): if not prod.can_add_with_enabling_conditions(request.user, 0): cat_form.disable_product(product) + data = { "category": category, "form": cat_form, + "voucher_form": voucher_form, } return render(request, "product_category.html", data) From 4dc150d7348f84d8fb05eabd44f9bff886dc1d1a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 14:50:52 +1100 Subject: [PATCH 021/418] Fills in quantity boxes from the quantities in the current cart, not overall --- registrasion/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 0c6c5ee6..075cd2e7 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -21,6 +21,7 @@ def product_category(request, category_id): category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) + current_cart = CartController.for_user(request.user) CategoryForm = forms.CategoryForm(category) @@ -30,7 +31,6 @@ def product_category(request, category_id): if request.method == "POST": cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) - current_cart = CartController.for_user(request.user) if voucher_form.is_valid(): # Apply voucher @@ -59,7 +59,10 @@ def product_category(request, category_id): else: # Create initial data for each of products in category - items = rego.ProductItem.objects.filter(product__category=category) + items = rego.ProductItem.objects.filter( + product__category=category, + cart=current_cart.cart, + ) quantities = [] for product in products: # Only add items that are enabled. From e118a4e74c8cec8d06637b7dab14344e190b2f5d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 14:51:04 +1100 Subject: [PATCH 022/418] Adds dumb process for paying invoices. --- registrasion/urls.py | 1 + registrasion/views.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/registrasion/urls.py b/registrasion/urls.py index d8a1c6db..746d163d 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -5,4 +5,5 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), + url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 075cd2e7..3957da7d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -118,3 +118,18 @@ def invoice(request, invoice_id): } return render(request, "invoice.html", data) + +@login_required +def pay_invoice(request, invoice_id): + ''' Marks the invoice with the given invoice id as paid. + WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. + + ''' + + invoice_id = int(invoice_id) + inv = rego.Invoice.objects.get(pk=invoice_id) + current_invoice = InvoiceController(inv) + if not inv.paid and not current_invoice.is_valid(): + current_invoice.pay("Demo invoice payment", inv.value) + + return redirect("invoice", current_invoice.invoice.id) From 7086ea87292dc5ac8c5d101b998599493163e202 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 19:36:22 +1100 Subject: [PATCH 023/418] Moves product disabling code into the form class --- registrasion/forms.py | 10 ++++++++++ registrasion/views.py | 8 ++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 54b3115a..5027e068 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -1,5 +1,7 @@ import models as rego +from controllers.product import ProductController + from django import forms @@ -34,6 +36,14 @@ def CategoryForm(category): ''' Removes a given product from this form. ''' del self.fields[field_name(product)] + def disable_products_for_user(self, user): + for product in products: + # Remove fields that do not have an enabling condition. + prod = ProductController(product) + if not prod.can_add_with_enabling_conditions(user, 0): + self.disable_product(product) + + products = rego.Product.objects.filter(category=category).order_by("order") for product in products: diff --git a/registrasion/views.py b/registrasion/views.py index 3957da7d..f7d0f9c5 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -30,6 +30,7 @@ def product_category(request, category_id): if request.method == "POST": cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) + cat_form.disable_products_for_user(request.user) voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) if voucher_form.is_valid(): @@ -75,15 +76,10 @@ def product_category(request, category_id): initial = CategoryForm.initial_data(quantities) cat_form = CategoryForm(prefix=PRODUCTS_FORM_PREFIX, initial=initial) + cat_form.disable_products_for_user(request.user) voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) - for product in products: - # Remove fields that do not have an enabling condition. - prod = ProductController(product) - if not prod.can_add_with_enabling_conditions(request.user, 0): - cat_form.disable_product(product) - data = { "category": category, From d50d6bac482c852cd343f9bc3ac2e0f94fa6bfc2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 19:36:54 +1100 Subject: [PATCH 024/418] Fixes voucher handling form to not be compulsory --- registrasion/forms.py | 2 +- registrasion/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 5027e068..5cada77a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -61,5 +61,5 @@ class VoucherForm(forms.Form): voucher = forms.CharField( label="Voucher code", help_text="If you have a voucher code, enter it here", - required=True, + required=False, ) diff --git a/registrasion/views.py b/registrasion/views.py index f7d0f9c5..5aefbfeb 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -33,7 +33,7 @@ def product_category(request, category_id): cat_form.disable_products_for_user(request.user) voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) - if voucher_form.is_valid(): + if voucher_form.is_valid() and voucher_form.cleaned_data["voucher"].strip(): # Apply voucher # leave voucher = voucher_form.cleaned_data["voucher"] From eb530bd4855ee14344ebd238dcd4a2e89ebb73bb Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 19:39:07 +1100 Subject: [PATCH 025/418] =?UTF-8?q?Adds=20the=20first=20pass=20at=20a=20?= =?UTF-8?q?=E2=80=9Cguided=E2=80=9D=20registration=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/urls.py | 2 ++ registrasion/views.py | 64 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 746d163d..c19b6ab8 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -2,6 +2,8 @@ from django.conf.urls import url, patterns urlpatterns = patterns( "registrasion.views", + url(r"^register$", "guided_registration", name="guided_registration"), + url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), diff --git a/registrasion/views.py b/registrasion/views.py index 5aefbfeb..21e41970 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -12,9 +12,45 @@ from django.shortcuts import redirect from django.shortcuts import render +@login_required +def guided_registration(request, page_id=0): + ''' Goes through the registration process in order, + making sure user sees all valid categories. + + WORK IN PROGRESS: the finalised version of this view will allow + grouping of categories into a specific page. Currently, page_id simply + refers to the category_id. Future versions will have pages containing + categories. + ''' + + page_id = int(page_id) + if page_id != 0: + ret = product_category_inner(request, page_id) + if ret is not True: + return ret + + # Go to next page in the guided registration + cats = rego.Category.objects + cats = cats.filter(id__gt=page_id).order_by("order") + + if len(cats) > 0: + return redirect("guided_registration", cats[0].id) + else: + return redirect("dashboard") + @login_required def product_category(request, category_id): - ''' Registration selections form for a specific category of items ''' + ret = product_category_inner(request, category_id) + if ret is not True: + return ret + else: + return redirect("dashboard") + +def product_category_inner(request, category_id): + ''' Registration selections form for a specific category of items. + It returns a rendered template if this page needs to display stuff, + otherwise it returns True. + ''' PRODUCTS_FORM_PREFIX = "products" VOUCHERS_FORM_PREFIX = "vouchers" @@ -41,20 +77,11 @@ def product_category(request, category_id): current_cart.apply_voucher(voucher) except Exception as e: voucher_form.add_error("voucher", e) + # Re-visit current page. elif cat_form.is_valid(): try: - with transaction.atomic(): - for product_id, quantity, field_name \ - in cat_form.product_quantities(): - product = rego.Product.objects.get(pk=product_id) - try: - current_cart.set_quantity( - product, quantity, batched=True) - except ValidationError as ve: - cat_form.add_error(field_name, ve) - if cat_form.errors: - raise ValidationError("Cannot add that stuff") - current_cart.end_batch() + handle_valid_cat_form(cat_form, current_cart) + return True except ValidationError as ve: pass @@ -89,6 +116,17 @@ def product_category(request, category_id): return render(request, "product_category.html", data) +@transaction.atomic +def handle_valid_cat_form(cat_form, current_cart): + for product_id, quantity, field_name in cat_form.product_quantities(): + product = rego.Product.objects.get(pk=product_id) + try: + current_cart.set_quantity(product, quantity, batched=True) + except ValidationError as ve: + cat_form.add_error(field_name, ve) + if cat_form.errors: + raise ValidationError("Cannot add that stuff") + current_cart.end_batch() @login_required def checkout(request): From 236c61eefa0da3a3fc7d6dbe2a46e04f3adf384b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 24 Mar 2016 11:33:11 +1100 Subject: [PATCH 026/418] Fleshes out badge model, and adds first pass at display of the badge form --- registrasion/forms.py | 9 +++ .../migrations/0002_auto_20160323_2029.py | 54 ++++++++++++++++ .../migrations/0003_auto_20160323_2044.py | 24 +++++++ .../migrations/0004_auto_20160323_2137.py | 55 ++++++++++++++++ .../migrations/0005_auto_20160323_2141.py | 19 ++++++ registrasion/models.py | 64 +++++++++++++++++-- registrasion/templates/profile_form.html | 22 +++++++ registrasion/urls.py | 5 +- registrasion/views.py | 9 +++ 9 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 registrasion/migrations/0002_auto_20160323_2029.py create mode 100644 registrasion/migrations/0003_auto_20160323_2044.py create mode 100644 registrasion/migrations/0004_auto_20160323_2137.py create mode 100644 registrasion/migrations/0005_auto_20160323_2141.py create mode 100644 registrasion/templates/profile_form.html diff --git a/registrasion/forms.py b/registrasion/forms.py index 5cada77a..df363d7a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -57,6 +57,15 @@ def CategoryForm(category): return _CategoryForm + +class ProfileForm(forms.ModelForm): + ''' A form for requesting badge and profile information. ''' + + class Meta: + model = rego.BadgeAndProfile + exclude = ['attendee'] + + class VoucherForm(forms.Form): voucher = forms.CharField( label="Voucher code", diff --git a/registrasion/migrations/0002_auto_20160323_2029.py b/registrasion/migrations/0002_auto_20160323_2029.py new file mode 100644 index 00000000..491c6865 --- /dev/null +++ b/registrasion/migrations/0002_auto_20160323_2029.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0001_squashed_0002_auto_20160304_1723'), + ] + + operations = [ + migrations.AddField( + model_name='badge', + name='accessibility_requirements', + field=models.CharField(max_length=256, blank=True), + ), + migrations.AddField( + model_name='badge', + name='dietary_requirements', + field=models.CharField(max_length=256, blank=True), + ), + migrations.AddField( + model_name='badge', + name='free_text_1', + field=models.CharField(help_text="A line of free text that will appear on your badge. Use this for your Twitter handle, IRC nick, your preferred pronouns or anything else you'd like people to see on your badge.", max_length=64, verbose_name='Free text line 1', blank=True), + ), + migrations.AddField( + model_name='badge', + name='free_text_2', + field=models.CharField(max_length=64, verbose_name='Free text line 2', blank=True), + ), + migrations.AddField( + model_name='badge', + name='gender', + field=models.CharField(max_length=64, blank=True), + ), + migrations.AddField( + model_name='badge', + name='of_legal_age', + field=models.BooleanField(default=False, verbose_name='18+?'), + ), + migrations.AlterField( + model_name='badge', + name='company', + field=models.CharField(help_text="The name of your company, as you'd like it on your badge", max_length=64, blank=True), + ), + migrations.AlterField( + model_name='badge', + name='name', + field=models.CharField(help_text="Your name, as you'd like it on your badge", max_length=64), + ), + ] diff --git a/registrasion/migrations/0003_auto_20160323_2044.py b/registrasion/migrations/0003_auto_20160323_2044.py new file mode 100644 index 00000000..931dc77f --- /dev/null +++ b/registrasion/migrations/0003_auto_20160323_2044.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0002_auto_20160323_2029'), + ] + + operations = [ + migrations.AddField( + model_name='badge', + name='name_per_invoice', + field=models.CharField(help_text="If your legal name is different to the name on your badge, fill this in, and we'll put it on your invoice. Otherwise, leave it blank.", max_length=64, verbose_name='Your legal name (for invoicing purposes)', blank=True), + ), + migrations.AlterField( + model_name='badge', + name='name', + field=models.CharField(help_text="Your name, as you'd like it to appear on your badge. ", max_length=64, verbose_name='Your name (for your conference nametag)'), + ), + ] diff --git a/registrasion/migrations/0004_auto_20160323_2137.py b/registrasion/migrations/0004_auto_20160323_2137.py new file mode 100644 index 00000000..e6514b59 --- /dev/null +++ b/registrasion/migrations/0004_auto_20160323_2137.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('registrasion', '0003_auto_20160323_2044'), + ] + + operations = [ + migrations.CreateModel( + name='Attendee', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('completed_registration', models.BooleanField(default=False)), + ('highest_complete_category', models.IntegerField(default=0)), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='BadgeAndProfile', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(help_text="Your name, as you'd like it to appear on your badge. ", max_length=64, verbose_name='Your name (for your conference nametag)')), + ('company', models.CharField(help_text="The name of your company, as you'd like it on your badge", max_length=64, blank=True)), + ('free_text_1', models.CharField(help_text="A line of free text that will appear on your badge. Use this for your Twitter handle, IRC nick, your preferred pronouns or anything else you'd like people to see on your badge.", max_length=64, verbose_name='Free text line 1', blank=True)), + ('free_text_2', models.CharField(max_length=64, verbose_name='Free text line 2', blank=True)), + ('name_per_invoice', models.CharField(help_text="If your legal name is different to the name on your badge, fill this in, and we'll put it on your invoice. Otherwise, leave it blank.", max_length=64, verbose_name='Your legal name (for invoicing purposes)', blank=True)), + ('of_legal_age', models.BooleanField(default=False, verbose_name='18+?')), + ('dietary_requirements', models.CharField(max_length=256, blank=True)), + ('accessibility_requirements', models.CharField(max_length=256, blank=True)), + ('gender', models.CharField(max_length=64, blank=True)), + ('profile', models.OneToOneField(to='registrasion.Attendee')), + ], + ), + migrations.RemoveField( + model_name='badge', + name='profile', + ), + migrations.RemoveField( + model_name='profile', + name='user', + ), + migrations.DeleteModel( + name='Badge', + ), + migrations.DeleteModel( + name='Profile', + ), + ] diff --git a/registrasion/migrations/0005_auto_20160323_2141.py b/registrasion/migrations/0005_auto_20160323_2141.py new file mode 100644 index 00000000..26124f7d --- /dev/null +++ b/registrasion/migrations/0005_auto_20160323_2141.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0004_auto_20160323_2137'), + ] + + operations = [ + migrations.RenameField( + model_name='badgeandprofile', + old_name='profile', + new_name='attendee', + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 96ecf219..1a8df463 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -15,29 +15,79 @@ from model_utils.managers import InheritanceManager # User models @python_2_unicode_compatible -class Profile(models.Model): +class Attendee(models.Model): ''' Miscellaneous user-related data. ''' def __str__(self): return "%s" % self.user user = models.OneToOneField(User, on_delete=models.CASCADE) - # Badge is linked + # Badge/profile is linked completed_registration = models.BooleanField(default=False) highest_complete_category = models.IntegerField(default=0) @python_2_unicode_compatible -class Badge(models.Model): - ''' Information for an attendee's badge. ''' +class BadgeAndProfile(models.Model): + ''' Information for an attendee's badge and related preferences ''' def __str__(self): return "Badge for: %s of %s" % (self.name, self.company) - profile = models.OneToOneField(Profile, on_delete=models.CASCADE) + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) - name = models.CharField(max_length=256) - company = models.CharField(max_length=256) + # Things that appear on badge + name = models.CharField( + verbose_name="Your name (for your conference nametag)", + max_length=64, + help_text="Your name, as you'd like it to appear on your badge. ", + ) + company = models.CharField( + max_length=64, + help_text="The name of your company, as you'd like it on your badge", + blank=True, + ) + free_text_1 = models.CharField( + max_length=64, + verbose_name="Free text line 1", + help_text="A line of free text that will appear on your badge. Use " + "this for your Twitter handle, IRC nick, your preferred " + "pronouns or anything else you'd like people to see on " + "your badge.", + blank=True, + ) + free_text_2 = models.CharField( + max_length=64, + verbose_name="Free text line 2", + blank=True, + ) + + # Other important Information + name_per_invoice = models.CharField( + verbose_name="Your legal name (for invoicing purposes)", + max_length=64, + help_text="If your legal name is different to the name on your badge, " + "fill this in, and we'll put it on your invoice. Otherwise, " + "leave it blank.", + blank=True, + ) + of_legal_age = models.BooleanField( + default=False, + verbose_name="18+?", + blank=True, + ) + dietary_requirements = models.CharField( + max_length=256, + blank=True, + ) + accessibility_requirements = models.CharField( + max_length=256, + blank=True, + ) + gender = models.CharField( + max_length=64, + blank=True, + ) # Inventory Models diff --git a/registrasion/templates/profile_form.html b/registrasion/templates/profile_form.html new file mode 100644 index 00000000..56f3010c --- /dev/null +++ b/registrasion/templates/profile_form.html @@ -0,0 +1,22 @@ + + +{% extends "site_base.html" %} +{% block body %} + +

Attendee Profile

+ +

Something something fill in your attendee details here!

+ + + {% csrf_token %} + + + {{ form }} +
+ + + + + + +{% endblock %} diff --git a/registrasion/urls.py b/registrasion/urls.py index c19b6ab8..ec0229e1 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -2,10 +2,11 @@ from django.conf.urls import url, patterns urlpatterns = patterns( "registrasion.views", - url(r"^register$", "guided_registration", name="guided_registration"), - url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), + url(r"^profile$", "profile", name="profile"), + url(r"^register$", "guided_registration", name="guided_registration"), + url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 21e41970..976a3e59 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -38,6 +38,15 @@ def guided_registration(request, page_id=0): else: return redirect("dashboard") +@login_required +def profile(request): + + form = forms.ProfileForm() + data = { + "form": form, + } + return render(request, "profile_form.html", data) + @login_required def product_category(request, category_id): ret = product_category_inner(request, category_id) From 05923a9a8f588fa08e9a01b01d1b29e6d1e8887a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 24 Mar 2016 12:58:23 +1100 Subject: [PATCH 027/418] Profile form view now edits the relevant form --- registrasion/models.py | 12 ++++++++++++ registrasion/urls.py | 2 +- registrasion/views.py | 15 +++++++++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index 1a8df463..2cb9ad63 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -21,6 +21,18 @@ class Attendee(models.Model): 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. ''' + attendees = Attendee.objects.filter(user=user) + if len(attendees) > 0: + return attendees[0] + else: + attendee = Attendee(user=user) + attendee.save() + return attendee + user = models.OneToOneField(User, on_delete=models.CASCADE) # Badge/profile is linked completed_registration = models.BooleanField(default=False) diff --git a/registrasion/urls.py b/registrasion/urls.py index ec0229e1..01946839 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -6,7 +6,7 @@ urlpatterns = patterns( url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), - url(r"^profile$", "profile", name="profile"), + url(r"^profile$", "edit_profile", name="profile"), url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 976a3e59..5099e701 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -39,9 +39,20 @@ def guided_registration(request, page_id=0): return redirect("dashboard") @login_required -def profile(request): +def edit_profile(request): + attendee = rego.Attendee.get_instance(request.user) + + try: + profile = rego.BadgeAndProfile.objects.get(attendee=attendee) + except ObjectDoesNotExist: + profile = None + + form = forms.ProfileForm(request.POST or None, instance=profile) + + if request.POST and form.is_valid(): + form.instance.attendee = attendee + form.save() - form = forms.ProfileForm() data = { "form": form, } From dcad2d5f7cb2e58c65c3b747f7ebb631acd32a15 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 24 Mar 2016 13:43:06 +1100 Subject: [PATCH 028/418] Second pass at guided registration, including profile page --- registrasion/models.py | 10 +++++++ registrasion/views.py | 60 ++++++++++++++++++++++++++---------------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index 2cb9ad63..55d7ee4a 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import datetime from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.db import models from django.db.models import F, Q @@ -46,6 +47,15 @@ class BadgeAndProfile(models.Model): def __str__(self): return "Badge for: %s of %s" % (self.name, self.company) + @staticmethod + def get_instance(attendee): + ''' Returns either None, or the instance that belongs + to this attendee. ''' + try: + return BadgeAndProfile.objects.get(attendee=attendee) + except ObjectDoesNotExist: + return None + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) # Things that appear on badge diff --git a/registrasion/views.py b/registrasion/views.py index 5099e701..da32364e 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -18,25 +18,45 @@ def guided_registration(request, page_id=0): making sure user sees all valid categories. WORK IN PROGRESS: the finalised version of this view will allow - grouping of categories into a specific page. Currently, page_id simply - refers to the category_id. Future versions will have pages containing - categories. + grouping of categories into a specific page. Currently, it just goes + through each category one by one ''' - page_id = int(page_id) - if page_id != 0: - ret = product_category_inner(request, page_id) - if ret is not True: + dashboard = redirect("dashboard") + next_step = redirect("guided_registration") + + # Step 1: Fill in a badge + attendee = rego.Attendee.get_instance(request.user) + profile = rego.BadgeAndProfile.get_instance(attendee) + + if profile is None: + ret = edit_profile(request) + profile_new = rego.BadgeAndProfile.get_instance(attendee) + if profile_new is None: + # No new profile was created return ret + else: + return next_step - # Go to next page in the guided registration + # Step 2: Go through each of the categories in order + category = attendee.highest_complete_category + + # Get the next category cats = rego.Category.objects - cats = cats.filter(id__gt=page_id).order_by("order") + cats = cats.filter(id__gt=category).order_by("order") - if len(cats) > 0: - return redirect("guided_registration", cats[0].id) + if len(cats) == 0: + # We've filled in every category + return dashboard + + ret = product_category(request, cats[0].id) + attendee_new = rego.Attendee.get_instance(request.user) + if attendee_new.highest_complete_category == category: + # We've not yet completed this category + return ret else: - return redirect("dashboard") + return next_step + @login_required def edit_profile(request): @@ -60,16 +80,7 @@ def edit_profile(request): @login_required def product_category(request, category_id): - ret = product_category_inner(request, category_id) - if ret is not True: - return ret - else: - return redirect("dashboard") - -def product_category_inner(request, category_id): ''' Registration selections form for a specific category of items. - It returns a rendered template if this page needs to display stuff, - otherwise it returns True. ''' PRODUCTS_FORM_PREFIX = "products" @@ -81,6 +92,8 @@ def product_category_inner(request, category_id): CategoryForm = forms.CategoryForm(category) + attendee = rego.Attendee.get_instance(request.user) + products = rego.Product.objects.filter(category=category) products = products.order_by("order") @@ -101,7 +114,10 @@ def product_category_inner(request, category_id): elif cat_form.is_valid(): try: handle_valid_cat_form(cat_form, current_cart) - return True + if category_id > attendee.highest_complete_category: + attendee.highest_complete_category = category_id + attendee.save() + return redirect("dashboard") except ValidationError as ve: pass From eff5686dcf293f8f3b51d566e34a67a7ff74356a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 24 Mar 2016 14:19:33 +1100 Subject: [PATCH 029/418] Adds logic for required categories --- .../migrations/0006_category_required.py | 20 +++++++++++++++++ registrasion/models.py | 1 + registrasion/views.py | 22 +++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 registrasion/migrations/0006_category_required.py diff --git a/registrasion/migrations/0006_category_required.py b/registrasion/migrations/0006_category_required.py new file mode 100644 index 00000000..46cc6a51 --- /dev/null +++ b/registrasion/migrations/0006_category_required.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0005_auto_20160323_2141'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='required', + field=models.BooleanField(default=False), + preserve_default=False, + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 55d7ee4a..a1101fd9 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -132,6 +132,7 @@ class Category(models.Model): name = models.CharField(max_length=65, verbose_name=_("Name")) description = models.CharField(max_length=255, verbose_name=_("Description")) + required = models.BooleanField(blank=True) order = models.PositiveIntegerField(verbose_name=("Display order")) render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, verbose_name=_("Render type")) diff --git a/registrasion/views.py b/registrasion/views.py index da32364e..6288f4b2 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -114,12 +114,30 @@ def product_category(request, category_id): elif cat_form.is_valid(): try: handle_valid_cat_form(cat_form, current_cart) + except ValidationError as ve: + pass + + # If category is required, the user must have at least one + # in an active+valid cart + + if category.required: + carts = rego.Cart.reserved_carts() + carts = carts.filter(user=request.user) + items = rego.ProductItem.objects.filter( + product__category=category, + cart=carts, + ) + if len(items) == 0: + cat_form.add_error( + None, + "You must have at least one item from this category", + ) + + if not cat_form.errors: if category_id > attendee.highest_complete_category: attendee.highest_complete_category = category_id attendee.save() return redirect("dashboard") - except ValidationError as ve: - pass else: # Create initial data for each of products in category From 83b11cd7224d66ae67ccf6df34d15ff915b2b8a7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 24 Mar 2016 14:20:29 +1100 Subject: [PATCH 030/418] Fixes invoicing payment logic --- registrasion/controllers/invoice.py | 2 +- registrasion/templates/invoice.html | 14 ++++++++++++++ registrasion/views.py | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index e9a654e4..b8087c98 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -122,7 +122,7 @@ class InvoiceController(object): ) payment.save() - payments = rego.Payment.objects .filter(invoice=self.invoice) + payments = rego.Payment.objects.filter(invoice=self.invoice) agg = payments.aggregate(Sum("amount")) total = agg["amount__sum"] diff --git a/registrasion/templates/invoice.html b/registrasion/templates/invoice.html index a66132cf..bd503657 100644 --- a/registrasion/templates/invoice.html +++ b/registrasion/templates/invoice.html @@ -33,5 +33,19 @@ + + + + + + + {% for payment in invoice.payment_set.all %} + + + + + + {% endfor %} +
Payment timeReferenceAmount
{{payment.time}}{{payment.reference}}{{payment.amount}}
{% endblock %} diff --git a/registrasion/views.py b/registrasion/views.py index 6288f4b2..64f4e4b1 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -217,7 +217,7 @@ def pay_invoice(request, invoice_id): invoice_id = int(invoice_id) inv = rego.Invoice.objects.get(pk=invoice_id) current_invoice = InvoiceController(inv) - if not inv.paid and not current_invoice.is_valid(): + if not inv.paid and current_invoice.is_valid(): current_invoice.pay("Demo invoice payment", inv.value) return redirect("invoice", current_invoice.invoice.id) From 8e6364d02adb422d1fda56205eeed7038bd66ac0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 12:50:34 +1100 Subject: [PATCH 031/418] Fixes bug where discount quantity applied to all users rather than specific user. Adds test case. --- registrasion/controllers/cart.py | 1 + registrasion/tests/test_cart.py | 2 ++ registrasion/tests/test_discount.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 59b308ed..d9307b39 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -211,6 +211,7 @@ class CartController(object): # Get the count of past uses of this discount condition # as this affects the total amount we're allowed to use now. past_uses = rego.DiscountItem.objects.filter( + cart__user=self.cart.user, discount=discount.discount, ) agg = past_uses.aggregate(Sum("quantity")) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index c65011b9..d000b946 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -37,6 +37,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): description="This is a test category", order=10, render_type=rego.Category.RENDER_TYPE_RADIO, + required=False, ) cls.CAT_1.save() @@ -45,6 +46,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): description="This is a test category", order=10, render_type=rego.Category.RENDER_TYPE_RADIO, + required=False, ) cls.CAT_2.save() diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 5d325eff..6f943d0f 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -200,3 +200,17 @@ class DiscountTestCase(RegistrationCartTestCase): # 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 = CartController.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)) From 478b328e41a81aee78158bf6672c3247c833bb83 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 12:50:59 +1100 Subject: [PATCH 032/418] Uses the completed_registration flag on the Attendee model --- registrasion/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index 64f4e4b1..73d04150 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -25,8 +25,11 @@ def guided_registration(request, page_id=0): dashboard = redirect("dashboard") next_step = redirect("guided_registration") - # Step 1: Fill in a badge attendee = rego.Attendee.get_instance(request.user) + if attendee.completed_registration: + return dashboard + + # Step 1: Fill in a badge profile = rego.BadgeAndProfile.get_instance(attendee) if profile is None: @@ -47,6 +50,8 @@ def guided_registration(request, page_id=0): if len(cats) == 0: # We've filled in every category + attendee.completed_registration = True + attendee.save() return dashboard ret = product_category(request, cats[0].id) From c192fef491c4b58dfddea21c7fbffbd8b2a9e5bc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 14:16:30 +1100 Subject: [PATCH 033/418] Adds basic template tag for available categories. Currently does not check enabling conditions. --- registrasion/templatetags/__init__.py | 0 registrasion/templatetags/registrasion_tags.py | 10 ++++++++++ 2 files changed, 10 insertions(+) create mode 100644 registrasion/templatetags/__init__.py create mode 100644 registrasion/templatetags/registrasion_tags.py diff --git a/registrasion/templatetags/__init__.py b/registrasion/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py new file mode 100644 index 00000000..3193a017 --- /dev/null +++ b/registrasion/templatetags/registrasion_tags.py @@ -0,0 +1,10 @@ +from registrasion import models as rego + +from django import template + +register = template.Library() + +@register.assignment_tag(takes_context=True) +def available_categories(context): + ''' Returns all of the available product categories ''' + return rego.Category.objects.all() From 8d66ed57150daae390703ce53bec848cdacaedaf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 14:51:39 +1100 Subject: [PATCH 034/418] Fix flake8 warnings --- registrasion/forms.py | 1 - .../templatetags/registrasion_tags.py | 1 + registrasion/tests/test_discount.py | 2 +- registrasion/tests/test_voucher.py | 8 +++---- registrasion/urls.py | 3 ++- registrasion/views.py | 21 ++++++++++++------- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index df363d7a..79e6d95a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -43,7 +43,6 @@ def CategoryForm(category): if not prod.can_add_with_enabling_conditions(user, 0): self.disable_product(product) - products = rego.Product.objects.filter(category=category).order_by("order") for product in products: diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 3193a017..4357d16a 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -4,6 +4,7 @@ from django import template register = template.Library() + @register.assignment_tag(takes_context=True) def available_categories(context): ''' Returns all of the available product categories ''' diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 6f943d0f..bb1c4bfe 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -208,7 +208,7 @@ class DiscountTestCase(RegistrationCartTestCase): # in the same way for user in (self.USER_1, self.USER_2): cart = CartController.for_user(user) - cart.add_to_cart(self.PROD_1, 1) # Enable the discount + 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()) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 50b83417..f0d1be61 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -95,13 +95,13 @@ class VoucherTestCases(RegistrationCartTestCase): self.assertEqual(1, len(current_cart.cart.discountitem_set.all())) def test_voucher_codes_unique(self): - voucher1 = self.new_voucher(code="VOUCHER") + self.new_voucher(code="VOUCHER") with self.assertRaises(IntegrityError): - voucher2 = self.new_voucher(code="VOUCHER") + self.new_voucher(code="VOUCHER") def test_multiple_vouchers_work(self): - voucher1 = self.new_voucher(code="VOUCHER1") - voucher2 = self.new_voucher(code="VOUCHER2") + self.new_voucher(code="VOUCHER1") + self.new_voucher(code="VOUCHER2") def test_vouchers_case_insensitive(self): voucher = self.new_voucher(code="VOUCHeR") diff --git a/registrasion/urls.py b/registrasion/urls.py index 01946839..132e7d78 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -8,5 +8,6 @@ urlpatterns = patterns( url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), url(r"^profile$", "edit_profile", name="profile"), url(r"^register$", "guided_registration", name="guided_registration"), - url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), + url(r"^register/([0-9]+)$", "guided_registration", + name="guided_registration"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 73d04150..070f645a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -2,7 +2,6 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController -from registrasion.controllers.product import ProductController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist @@ -83,6 +82,7 @@ def edit_profile(request): } return render(request, "profile_form.html", data) + @login_required def product_category(request, category_id): ''' Registration selections form for a specific category of items. @@ -103,11 +103,17 @@ def product_category(request, category_id): products = products.order_by("order") if request.method == "POST": - cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) + cat_form = CategoryForm( + request.POST, + request.FILES, + prefix=PRODUCTS_FORM_PREFIX) cat_form.disable_products_for_user(request.user) - voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) + voucher_form = forms.VoucherForm( + request.POST, + prefix=VOUCHERS_FORM_PREFIX) - if voucher_form.is_valid() and voucher_form.cleaned_data["voucher"].strip(): + if (voucher_form.is_valid() and + voucher_form.cleaned_data["voucher"].strip()): # Apply voucher # leave voucher = voucher_form.cleaned_data["voucher"] @@ -119,7 +125,7 @@ def product_category(request, category_id): elif cat_form.is_valid(): try: handle_valid_cat_form(cat_form, current_cart) - except ValidationError as ve: + except ValidationError: pass # If category is required, the user must have at least one @@ -153,7 +159,6 @@ def product_category(request, category_id): quantities = [] for product in products: # Only add items that are enabled. - prod = ProductController(product) try: quantity = items.get(product=product).quantity except ObjectDoesNotExist: @@ -166,7 +171,6 @@ def product_category(request, category_id): voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) - data = { "category": category, "form": cat_form, @@ -175,6 +179,7 @@ def product_category(request, category_id): return render(request, "product_category.html", data) + @transaction.atomic def handle_valid_cat_form(cat_form, current_cart): for product_id, quantity, field_name in cat_form.product_quantities(): @@ -187,6 +192,7 @@ def handle_valid_cat_form(cat_form, current_cart): raise ValidationError("Cannot add that stuff") current_cart.end_batch() + @login_required def checkout(request): ''' Runs checkout for the current cart of items, ideally generating an @@ -212,6 +218,7 @@ def invoice(request, invoice_id): return render(request, "invoice.html", data) + @login_required def pay_invoice(request, invoice_id): ''' Marks the invoice with the given invoice id as paid. From fb3878ce2e44904be7a26929304c5126d9d4324c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 18:09:24 +1100 Subject: [PATCH 035/418] Adds available_discounts, which allows enumeration of the discounts that are available for a given set of products and categories --- registrasion/controllers/discount.py | 83 +++++++++++++ registrasion/tests/test_discount.py | 169 ++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 registrasion/controllers/discount.py diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py new file mode 100644 index 00000000..dcb83dfd --- /dev/null +++ b/registrasion/controllers/discount.py @@ -0,0 +1,83 @@ +import itertools + +from conditions import ConditionController +from registrasion import models as rego + +from django.db.models import Sum + + +class DiscountAndQuantity(object): + def __init__(self, discount, clause, quantity): + self.discount = discount + self.clause = clause + self.quantity = quantity + + +def available_discounts(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. ''' + + # discounts that match provided categories + category_discounts = rego.DiscountForCategory.objects.filter( + category__in=categories + ) + # discounts that match provided products + product_discounts = rego.DiscountForProduct.objects.filter( + product__in=products + ) + # discounts that match categories for provided products + product_category_discounts = rego.DiscountForCategory.objects.filter( + category__in=(product.category for product in products) + ) + # (Not relevant: discounts that match products in provided categories) + + # The set of all potential discounts + potential_discounts = set(itertools.chain( + product_discounts, + category_discounts, + product_category_discounts, + )) + + discounts = [] + + # Markers so that we don't need to evaluate given conditions more than once + accepted_discounts = set() + failed_discounts = set() + + for discount in potential_discounts: + real_discount = rego.DiscountBase.objects.get_subclass( + pk=discount.discount.pk, + ) + cond = ConditionController.for_condition(real_discount) + + # Count the past uses of the given discount item. + # If this user has exceeded the limit for the clause, this clause + # is not available any more. + past_uses = rego.DiscountItem.objects.filter( + cart__user=user, + cart__active=False, # Only past carts count + discount=discount.discount, + ) + agg = past_uses.aggregate(Sum("quantity")) + past_use_count = agg["quantity__sum"] + if past_use_count is None: + past_use_count = 0 + + if past_use_count >= discount.quantity: + # This clause has exceeded its use count + pass + elif real_discount not in failed_discounts: + # This clause is still available + if real_discount in accepted_discounts or cond.is_met(user, 0): + # This clause is valid for this user + discounts.append(DiscountAndQuantity( + discount=real_discount, + clause=discount, + quantity=discount.quantity - past_use_count, + )) + accepted_discounts.add(real_discount) + else: + # This clause is not valid for this user + failed_discounts.add(real_discount) + return discounts diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index bb1c4bfe..222afc09 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -3,7 +3,9 @@ import pytz from decimal import Decimal from registrasion import models as rego +from registrasion.controllers import discount from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -13,7 +15,11 @@ UTC = pytz.timezone('UTC') class DiscountTestCase(RegistrationCartTestCase): @classmethod - def add_discount_prod_1_includes_prod_2(cls, amount=Decimal(100)): + def add_discount_prod_1_includes_prod_2( + cls, + amount=Decimal(100), + quantity=2, + ): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes PROD_2 " + str(amount) + "%", ) @@ -24,7 +30,7 @@ class DiscountTestCase(RegistrationCartTestCase): discount=discount, product=cls.PROD_2, percentage=amount, - quantity=2 + quantity=quantity, ).save() return discount @@ -32,7 +38,8 @@ class DiscountTestCase(RegistrationCartTestCase): def add_discount_prod_1_includes_cat_2( cls, amount=Decimal(100), - quantity=2): + quantity=2, + ): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) @@ -47,6 +54,33 @@ class DiscountTestCase(RegistrationCartTestCase): ).save() return discount + @classmethod + def add_discount_prod_1_includes_prod_3_and_prod_4( + cls, + amount=Decimal(100), + quantity=2, + ): + discount = rego.IncludedProductDiscount.objects.create( + description="PROD_1 includes PROD_3 and PROD_4 " + + str(amount) + "%", + ) + discount.save() + discount.enabling_products.add(cls.PROD_1) + discount.save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=cls.PROD_3, + percentage=amount, + quantity=quantity, + ).save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=cls.PROD_4, + percentage=amount, + quantity=quantity, + ).save() + return discount + def test_discount_is_applied(self): self.add_discount_prod_1_includes_prod_2() @@ -214,3 +248,132 @@ class DiscountTestCase(RegistrationCartTestCase): discount_items = list(cart.cart.discountitem_set.all()) # The discount is applied. self.assertEqual(1, len(discount_items)) + + # Tests for the discount.available_discounts enumerator + def test_enumerate_no_discounts_for_no_input(self): + discounts = discount.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 = discount.available_discounts( + self.USER_1, + [], + [self.PROD_3], + ) + self.assertEqual(0, len(discounts)) + + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.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 = CartController.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 = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(2, discounts[0].quantity) + inv = InvoiceController.for_cart(cart.cart) + inv.pay("Dummy reference", inv.invoice.value) + self.assertTrue(inv.invoice.paid) + + def test_discount_quantity_is_correct_after_first_purchase(self): + self.test_discount_quantity_is_correct_before_first_purchase() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity + + discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(1, discounts[0].quantity) + inv = InvoiceController.for_cart(cart.cart) + inv.pay("Dummy reference", inv.invoice.value) + self.assertTrue(inv.invoice.paid) + + def test_discount_is_gone_after_quantity_exhausted(self): + self.test_discount_quantity_is_correct_after_first_purchase() + discounts = discount.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 = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_3, self.PROD_4], + ) + self.assertEqual(2, len(discounts)) From c41a9cadffb98330f13989af6e7c1b0f02b1193d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 18:59:19 +1100 Subject: [PATCH 036/418] recalculate_discounts now uses the available_discounts function from controllers.discount. --- registrasion/controllers/cart.py | 60 +++++++++++++++++------------ registrasion/controllers/product.py | 46 ---------------------- 2 files changed, 36 insertions(+), 70 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index d9307b39..ad680225 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,8 +1,9 @@ import datetime +import discount from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError -from django.db.models import Max, Sum +from django.db.models import Max from django.utils import timezone from registrasion import models as rego @@ -187,38 +188,47 @@ class CartController(object): # Delete the existing entries. rego.DiscountItem.objects.filter(cart=self.cart).delete() + product_items = self.cart.productitem_set.all() + + products = [i.product for i in product_items] + discounts = discount.available_discounts(self.cart.user, [], products) + # The highest-value discounts will apply to the highest-value # products first. product_items = self.cart.productitem_set.all() product_items = product_items.order_by('product__price') product_items = reversed(product_items) for item in product_items: - self._add_discount(item.product, item.quantity) + self._add_discount(item.product, item.quantity, discounts) - def _add_discount(self, product, quantity): - ''' Calculates the best available discounts for this product. - NB this will be super-inefficient in aggregate because discounts will - be re-tested for each product. We should work on that.''' + def _add_discount(self, product, quantity, discounts): + ''' Applies the best discounts on the given product, from the given + discounts.''' - prod = ProductController(product) - discounts = prod.available_discounts(self.cart.user) - discounts.sort(key=lambda discount: discount.value) + def matches(discount): + ''' Returns True if and only if the given discount apples to + our product. ''' + if isinstance(discount.clause, rego.DiscountForCategory): + return discount.clause.category == product.category + else: + return discount.clause.product == product - for discount in reversed(discounts): + 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 - - # Get the count of past uses of this discount condition - # as this affects the total amount we're allowed to use now. - past_uses = rego.DiscountItem.objects.filter( - cart__user=self.cart.user, - discount=discount.discount, - ) - agg = past_uses.aggregate(Sum("quantity")) - past_uses = agg["quantity__sum"] - if past_uses is None: - past_uses = 0 - if past_uses == discount.condition.quantity: + elif candidate.quantity == 0: + # This discount clause has been exhausted by this cart continue # Get a provisional instance for this DiscountItem @@ -226,13 +236,13 @@ class CartController(object): discount_item = rego.DiscountItem.objects.create( product=product, cart=self.cart, - discount=discount.discount, + discount=candidate.discount, quantity=quantity, ) # Truncate the quantity for this DiscountItem if we exceed quantity ours = discount_item.quantity - allowed = discount.condition.quantity - past_uses + allowed = candidate.quantity if ours > allowed: discount_item.quantity = allowed # Update the remaining quantity. @@ -240,4 +250,6 @@ class CartController(object): else: quantity = 0 + candidate.quantity -= discount_item.quantity + discount_item.save() diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 8a1f402e..4d8cf419 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,18 +1,8 @@ -import itertools - -from collections import namedtuple - from django.db.models import Q from registrasion import models as rego from conditions import ConditionController -DiscountEnabler = namedtuple( - "DiscountEnabler", ( - "discount", - "condition", - "value")) - class ProductController(object): @@ -68,39 +58,3 @@ class ProductController(object): return False return True - - def get_enabler(self, condition): - if condition.percentage is not None: - value = condition.percentage * self.product.price - else: - value = condition.price - return DiscountEnabler( - discount=condition.discount, - condition=condition, - value=value - ) - - def available_discounts(self, user): - ''' Returns the set of available discounts for this user, for this - product. ''' - - product_discounts = rego.DiscountForProduct.objects.filter( - product=self.product) - category_discounts = rego.DiscountForCategory.objects.filter( - category=self.product.category - ) - - potential_discounts = set(itertools.chain( - (self.get_enabler(i) for i in product_discounts), - (self.get_enabler(i) for i in category_discounts), - )) - - discounts = [] - for discount in potential_discounts: - real_discount = rego.DiscountBase.objects.get_subclass( - pk=discount.discount.pk) - cond = ConditionController.for_condition(real_discount) - if cond.is_met(user, 0): - discounts.append(discount) - - return discounts From 45aa83f854cb70c15d08896b6c5352bd8dbc5310 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 13:22:47 +1100 Subject: [PATCH 037/418] Adds available_products as a method on ProductController --- registrasion/controllers/product.py | 25 ++++++ registrasion/tests/test_enabling_condition.py | 78 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 4d8cf419..2d0f2963 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,3 +1,5 @@ +import itertools + from django.db.models import Q from registrasion import models as rego @@ -9,6 +11,29 @@ 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 + enabling conditions from the given categories. + TODO: refactor so that all conditions are tested here and + can_add_with_enabling_conditions calls this method. ''' + if category is None and products is None: + raise ValueError("You must provide products or a category") + + if category is not None: + all_products = rego.Product.objects.filter(category=category) + else: + all_products = [] + + if products is not None: + all_products = itertools.chain(all_products, products) + + return [ + product + for product in all_products + if cls(product).can_add_with_enabling_conditions(user, 0) + ] + def user_can_add_within_limit(self, user, quantity): ''' Return true if the user is able to add _quantity_ to their count of this Product without exceeding _limit_per_user_.''' diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 09433cdd..e7155dea 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from registrasion.controllers.cart import CartController +from registrasion.controllers.product import ProductController from test_cart import RegistrationCartTestCase @@ -155,3 +156,80 @@ class EnablingConditionTestCases(RegistrationCartTestCase): 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_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_enabling_condition(mandatory=False) + + 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_enabling_condition(mandatory=False) + + cart_1 = CartController.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_enabling_condition(mandatory=False) + + 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_enabling_condition(mandatory=False) + + cart_1 = CartController.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) From fc279b1922d370ec1fd5ae148f0ba0e78c4a471e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 13:30:46 +1100 Subject: [PATCH 038/418] Replaces CategoryForm with ProductsForm (makes the form slightly dumber) --- registrasion/forms.py | 33 +++++++++++++-------------------- registrasion/views.py | 18 +++++++++++------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 79e6d95a..fd0359bb 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -1,21 +1,26 @@ import models as rego -from controllers.product import ProductController - from django import forms -def CategoryForm(category): +def ProductsForm(products): PREFIX = "product_" def field_name(product): return PREFIX + ("%d" % product.id) - class _CategoryForm(forms.Form): + class _ProductsForm(forms.Form): - @staticmethod - def initial_data(product_quantities): + def __init__(self, *a, **k): + if "product_quantities" in k: + initial = _ProductsForm.initial_data(k["product_quantities"]) + k["initial"] = initial + del k["product_quantities"] + super(_ProductsForm, self).__init__(*a, **k) + + @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 ''' initial = {} @@ -32,18 +37,6 @@ def CategoryForm(category): product_id = int(name[len(PREFIX):]) yield (product_id, value, name) - def disable_product(self, product): - ''' Removes a given product from this form. ''' - del self.fields[field_name(product)] - - def disable_products_for_user(self, user): - for product in products: - # Remove fields that do not have an enabling condition. - prod = ProductController(product) - if not prod.can_add_with_enabling_conditions(user, 0): - self.disable_product(product) - - products = rego.Product.objects.filter(category=category).order_by("order") for product in products: help_text = "$%d -- %s" % (product.price, product.description) @@ -52,9 +45,9 @@ def CategoryForm(category): label=product.name, help_text=help_text, ) - _CategoryForm.base_fields[field_name(product)] = field + _ProductsForm.base_fields[field_name(product)] = field - return _CategoryForm + return _ProductsForm class ProfileForm(forms.ModelForm): diff --git a/registrasion/views.py b/registrasion/views.py index 070f645a..da0eb657 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -2,6 +2,7 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController +from registrasion.controllers.product import ProductController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist @@ -95,19 +96,21 @@ def product_category(request, category_id): category = rego.Category.objects.get(pk=category_id) current_cart = CartController.for_user(request.user) - CategoryForm = forms.CategoryForm(category) - attendee = rego.Attendee.get_instance(request.user) products = rego.Product.objects.filter(category=category) products = products.order_by("order") + products = ProductController.available_products( + request.user, + products=products, + ) + ProductsForm = forms.ProductsForm(products) if request.method == "POST": - cat_form = CategoryForm( + cat_form = ProductsForm( request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) - cat_form.disable_products_for_user(request.user) voucher_form = forms.VoucherForm( request.POST, prefix=VOUCHERS_FORM_PREFIX) @@ -165,9 +168,10 @@ def product_category(request, category_id): quantity = 0 quantities.append((product, quantity)) - initial = CategoryForm.initial_data(quantities) - cat_form = CategoryForm(prefix=PRODUCTS_FORM_PREFIX, initial=initial) - cat_form.disable_products_for_user(request.user) + cat_form = ProductsForm( + prefix=PRODUCTS_FORM_PREFIX, + product_quantities=quantities, + ) voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) From 941b0578655479570cae2d4329fe3e4fbc748905 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 14:03:25 +1100 Subject: [PATCH 039/418] Shows the available discounts on the registration form --- registrasion/templates/product_category.html | 30 +++++++++++++++++--- registrasion/views.py | 3 ++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html index 0e567bf0..fb54a58d 100644 --- a/registrasion/templates/product_category.html +++ b/registrasion/templates/product_category.html @@ -5,8 +5,6 @@

Product Category: {{ category.name }}

-

{{ category.description }}

-
{% csrf_token %} @@ -14,13 +12,37 @@ {{ voucher_form }} - +

+ {% if discounts %} +

Available Discounts

+
    + {% for discount in discounts %} +
  • {{ discount.quantity }} x + {% if discount.clause.percentage %} + {{ discount.clause.percentage|floatformat:"2" }}% + {% else %} + ${{ discount.clause.price|floatformat:"2" }} + {% endif %} + off + {% if discount.clause.category %} + {{ discount.clause.category }} + {% else %} + {{ discount.clause.product.category }} + - {{ discount.clause.product }} + {% endif %} +
  • + {% endfor %} +
+ {% endif %} + +

Available Products

+

{{ category.description }}

{{ form }}
- +

diff --git a/registrasion/views.py b/registrasion/views.py index da0eb657..83e80a0d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,5 +1,6 @@ from registrasion import forms from registrasion import models as rego +from registrasion.controllers import discount from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.product import ProductController @@ -175,8 +176,10 @@ def product_category(request, category_id): voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) + discounts = discount.available_discounts(request.user, [], products) data = { "category": category, + "discounts": discounts, "form": cat_form, "voucher_form": voucher_form, } From 36ecf7fd5489e225df58bc79a5d6bb50a3ff4782 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 15:14:28 +1100 Subject: [PATCH 040/418] Adds more tags for the dashboard. --- .../templatetags/registrasion_tags.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 4357d16a..a6741c0e 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -1,11 +1,47 @@ from registrasion import models as rego +from collections import namedtuple from django import template +from django.db.models import Sum register = template.Library() +ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) @register.assignment_tag(takes_context=True) def available_categories(context): ''' Returns all of the available product categories ''' return rego.Category.objects.all() + +@register.assignment_tag(takes_context=True) +def invoices(context): + ''' Returns all of the invoices that this user has. ''' + return rego.Invoice.objects.filter(cart__user=context.request.user) + +@register.assignment_tag(takes_context=True) +def items_pending(context): + ''' Returns all of the items that this user has in their current cart, + and is awaiting payment. ''' + + all_items = rego.ProductItem.objects.filter( + cart__user=context.request.user, + cart__active=True, + ) + return all_items + +@register.assignment_tag(takes_context=True) +def items_purchased(context): + ''' Returns all of the items that this user has purchased ''' + + all_items = rego.ProductItem.objects.filter( + cart__user=context.request.user, + cart__active=False, + ) + + products = set(item.product for item in all_items) + out = [] + for product in products: + pp = all_items.filter(product=product) + quantity = pp.aggregate(Sum("quantity"))["quantity__sum"] + out.append(ProductAndQuantity(product, quantity)) + return out From 2d5cd622c5174ec307bbaf7f2de29ec04ab22bf7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 19:47:01 +1100 Subject: [PATCH 041/418] Makes it invalid for a user to re-enter a voucher code they already have. --- registrasion/controllers/cart.py | 11 +++++++++-- registrasion/tests/test_voucher.py | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index ad680225..06eef3ef 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -125,18 +125,25 @@ class CartController(object): def apply_voucher(self, voucher_code): ''' Applies the voucher with the given code to this cart. ''' - # TODO: is it valid for a cart to re-add a voucher that they have? - # Is voucher exhausted? active_carts = rego.Cart.reserved_carts() # Try and find the voucher voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + # It's invalid for a user to enter a voucher that's exhausted carts_with_voucher = active_carts.filter(vouchers=voucher) if len(carts_with_voucher) >= voucher.limit: raise ValidationError("This voucher is no longer available") + # It's not valid for users to re-enter a voucher they already have + user_carts_with_voucher = rego.Cart.objects.filter( + user=self.cart.user, + vouchers=voucher, + ) + if len(user_carts_with_voucher) > 0: + raise ValidationError("You have already entered this voucher.") + # If successful... self.cart.vouchers.add(voucher) self.end_batch() diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index f0d1be61..db59d094 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -7,6 +7,7 @@ from django.db import IntegrityError from registrasion import models as rego from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -16,11 +17,11 @@ UTC = pytz.timezone('UTC') class VoucherTestCases(RegistrationCartTestCase): @classmethod - def new_voucher(self, code="VOUCHER"): + def new_voucher(self, code="VOUCHER", limit=1): voucher = rego.Voucher.objects.create( recipient="Voucher recipient", code=code, - limit=1 + limit=limit, ) voucher.save() return voucher @@ -107,3 +108,21 @@ class VoucherTestCases(RegistrationCartTestCase): voucher = self.new_voucher(code="VOUCHeR") current_cart = CartController.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 = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code) + with self.assertRaises(ValidationError): + current_cart.apply_voucher(voucher.code) + + def test_voucher_can_only_be_applied_once_across_multiple_carts(self): + voucher = self.new_voucher(limit=2) + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code) + + inv = InvoiceController.for_cart(current_cart.cart) + inv.pay("Hello!", inv.invoice.value) + + with self.assertRaises(ValidationError): + current_cart.apply_voucher(voucher.code) From b13e6f7ce2c2c4dcc8563bd2b8a3ca5da4ce2c6d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 20:01:46 +1100 Subject: [PATCH 042/418] Factors out voucher form handling into its own function --- registrasion/models.py | 6 ++++- registrasion/views.py | 53 ++++++++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index a1101fd9..75949e24 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -172,9 +172,13 @@ class Voucher(models.Model): 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.code.upper() + self.code = self.normalise_code(self.code) super(Voucher, self).save(*a, **k) recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) diff --git a/registrasion/views.py b/registrasion/views.py index 83e80a0d..b75c3e71 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -99,12 +99,20 @@ def product_category(request, category_id): attendee = rego.Attendee.get_instance(request.user) + # Handle the voucher form *before* listing products. + v = handle_voucher(request, VOUCHERS_FORM_PREFIX) + voucher_form, voucher_handled = v + if voucher_handled: + # Do not handle product form + pass + products = rego.Product.objects.filter(category=category) products = products.order_by("order") products = ProductController.available_products( request.user, products=products, ) + ProductsForm = forms.ProductsForm(products) if request.method == "POST": @@ -112,20 +120,10 @@ def product_category(request, category_id): request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) - voucher_form = forms.VoucherForm( - request.POST, - prefix=VOUCHERS_FORM_PREFIX) - if (voucher_form.is_valid() and - voucher_form.cleaned_data["voucher"].strip()): - # Apply voucher - # leave - voucher = voucher_form.cleaned_data["voucher"] - try: - current_cart.apply_voucher(voucher) - except Exception as e: - voucher_form.add_error("voucher", e) - # Re-visit current page. + if voucher_handled: + # The voucher form was handled here. + pass elif cat_form.is_valid(): try: handle_valid_cat_form(cat_form, current_cart) @@ -174,8 +172,6 @@ def product_category(request, category_id): product_quantities=quantities, ) - voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) - discounts = discount.available_discounts(request.user, [], products) data = { "category": category, @@ -199,6 +195,33 @@ def handle_valid_cat_form(cat_form, current_cart): raise ValidationError("Cannot add that stuff") current_cart.end_batch() +def handle_voucher(request, prefix): + ''' Handles a voucher form in the given request. Returns the voucher + form instance, and whether the voucher code was handled. ''' + + voucher_form = forms.VoucherForm(request.POST or None, prefix=prefix) + current_cart = CartController.for_user(request.user) + + if (voucher_form.is_valid() and + voucher_form.cleaned_data["voucher"].strip()): + + voucher = voucher_form.cleaned_data["voucher"] + voucher = rego.Voucher.normalise_code(voucher) + + if len(current_cart.cart.vouchers.filter(code=voucher)) > 0: + # This voucher has already been applied to this cart. + # Do not apply code + handled = False + else: + try: + current_cart.apply_voucher(voucher) + except Exception as e: + voucher_form.add_error("voucher", e) + handled = True + else: + handled = False + + return (voucher_form, handled) @login_required def checkout(request): From 464684f13e7bc4c3ed2cb094e775b97fc73bfeac Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 20:21:54 +1100 Subject: [PATCH 043/418] Refactors the product_category view to be much simpler --- registrasion/views.py | 109 ++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index b75c3e71..3efe658a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -93,19 +93,18 @@ def product_category(request, category_id): PRODUCTS_FORM_PREFIX = "products" VOUCHERS_FORM_PREFIX = "vouchers" + # Handle the voucher form *before* listing products. + # Products can change as vouchers are entered. + v = handle_voucher(request, VOUCHERS_FORM_PREFIX) + voucher_form, voucher_handled = v + + # Handle the products form category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) current_cart = CartController.for_user(request.user) attendee = rego.Attendee.get_instance(request.user) - # Handle the voucher form *before* listing products. - v = handle_voucher(request, VOUCHERS_FORM_PREFIX) - voucher_form, voucher_handled = v - if voucher_handled: - # Do not handle product form - pass - products = rego.Product.objects.filter(category=category) products = products.order_by("order") products = ProductController.available_products( @@ -115,64 +114,60 @@ def product_category(request, category_id): ProductsForm = forms.ProductsForm(products) - if request.method == "POST": - cat_form = ProductsForm( - request.POST, - request.FILES, - prefix=PRODUCTS_FORM_PREFIX) + # Create initial data for each of products in category + items = rego.ProductItem.objects.filter( + product__category=category, + cart=current_cart.cart, + ) + quantities = [] + for product in products: + # Only add items that are enabled. + try: + quantity = items.get(product=product).quantity + except ObjectDoesNotExist: + quantity = 0 + quantities.append((product, quantity)) - if voucher_handled: - # The voucher form was handled here. - pass - elif cat_form.is_valid(): - try: + cat_form = ProductsForm( + request.POST or None, + product_quantities=quantities, + prefix=PRODUCTS_FORM_PREFIX, + ) + + if ( + not voucher_handled and + request.method == "POST" and + cat_form.is_valid()): + + try: + if cat_form.has_changed(): handle_valid_cat_form(cat_form, current_cart) - except ValidationError: - pass + except ValidationError: + pass - # If category is required, the user must have at least one - # in an active+valid cart + # If category is required, the user must have at least one + # in an active+valid cart - if category.required: - carts = rego.Cart.reserved_carts() - carts = carts.filter(user=request.user) - items = rego.ProductItem.objects.filter( - product__category=category, - cart=carts, + if category.required: + carts = rego.Cart.reserved_carts().filter(user=request.user) + items = rego.ProductItem.objects.filter( + product__category=category, + cart=carts, + ) + if len(items) == 0: + cat_form.add_error( + None, + "You must have at least one item from this category", ) - if len(items) == 0: - cat_form.add_error( - None, - "You must have at least one item from this category", - ) - if not cat_form.errors: - if category_id > attendee.highest_complete_category: - attendee.highest_complete_category = category_id - attendee.save() - return redirect("dashboard") - - else: - # Create initial data for each of products in category - items = rego.ProductItem.objects.filter( - product__category=category, - cart=current_cart.cart, - ) - quantities = [] - for product in products: - # Only add items that are enabled. - try: - quantity = items.get(product=product).quantity - except ObjectDoesNotExist: - quantity = 0 - quantities.append((product, quantity)) - - cat_form = ProductsForm( - prefix=PRODUCTS_FORM_PREFIX, - product_quantities=quantities, - ) + if not cat_form.errors: + if category_id > attendee.highest_complete_category: + attendee.highest_complete_category = category_id + attendee.save() + return redirect("dashboard") discounts = discount.available_discounts(request.user, [], products) + data = { "category": category, "discounts": discounts, From 834233cd72a973b9bd7953ffb7830ae9daae9a01 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 20:43:20 +1100 Subject: [PATCH 044/418] Factors ProductsForm handling into its own function --- registrasion/controllers/product.py | 4 +- registrasion/views.py | 85 ++++++++++++++++------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 2d0f2963..d91a2541 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -28,11 +28,13 @@ class ProductController(object): if products is not None: all_products = itertools.chain(all_products, products) - return [ + out = [ product for product in all_products if cls(product).can_add_with_enabling_conditions(user, 0) ] + out.sort(key=lambda product: product.order) + return out def user_can_add_within_limit(self, user, quantity): ''' Return true if the user is able to add _quantity_ to their count of diff --git a/registrasion/views.py b/registrasion/views.py index 3efe658a..04c63ae2 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -98,25 +98,49 @@ def product_category(request, category_id): v = handle_voucher(request, VOUCHERS_FORM_PREFIX) voucher_form, voucher_handled = v - # Handle the products form category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) - current_cart = CartController.for_user(request.user) - attendee = rego.Attendee.get_instance(request.user) - - products = rego.Product.objects.filter(category=category) - products = products.order_by("order") products = ProductController.available_products( request.user, - products=products, + category=category, ) + p = handle_products(request, category, products, PRODUCTS_FORM_PREFIX) + products_form, discounts, products_handled = p + + if request.POST and not voucher_handled and not products_form.errors: + # Only return to the dashboard if we didn't add a voucher code + # and if there's no errors in the products form + + attendee = rego.Attendee.get_instance(request.user) + if category_id > attendee.highest_complete_category: + attendee.highest_complete_category = category_id + attendee.save() + return redirect("dashboard") + + data = { + "category": category, + "discounts": discounts, + "form": products_form, + "voucher_form": voucher_form, + } + + return render(request, "product_category.html", data) + + +def handle_products(request, category, products, prefix): + ''' Handles a products list form in the given request. Returns the + form instance, the discounts applicable to this form, and whether the + contents were handled. ''' + + current_cart = CartController.for_user(request.user) + ProductsForm = forms.ProductsForm(products) # Create initial data for each of products in category items = rego.ProductItem.objects.filter( - product__category=category, + product__in=products, cart=current_cart.cart, ) quantities = [] @@ -128,65 +152,48 @@ def product_category(request, category_id): quantity = 0 quantities.append((product, quantity)) - cat_form = ProductsForm( + products_form = ProductsForm( request.POST or None, product_quantities=quantities, - prefix=PRODUCTS_FORM_PREFIX, + prefix=prefix, ) - if ( - not voucher_handled and - request.method == "POST" and - cat_form.is_valid()): - + if request.method == "POST" and products_form.is_valid(): try: - if cat_form.has_changed(): - handle_valid_cat_form(cat_form, current_cart) + if products_form.has_changed(): + set_quantities_from_products_form(products_form, current_cart) except ValidationError: + # There were errors, but they've already been added to the form. pass # If category is required, the user must have at least one # in an active+valid cart - if category.required: - carts = rego.Cart.reserved_carts().filter(user=request.user) + carts = rego.Cart.objects.filter(user=request.user) items = rego.ProductItem.objects.filter( product__category=category, cart=carts, ) if len(items) == 0: - cat_form.add_error( + products_form.add_error( None, "You must have at least one item from this category", ) - - if not cat_form.errors: - if category_id > attendee.highest_complete_category: - attendee.highest_complete_category = category_id - attendee.save() - return redirect("dashboard") + handled = False if products_form.errors else True discounts = discount.available_discounts(request.user, [], products) - data = { - "category": category, - "discounts": discounts, - "form": cat_form, - "voucher_form": voucher_form, - } - - return render(request, "product_category.html", data) - + return products_form, discounts, handled @transaction.atomic -def handle_valid_cat_form(cat_form, current_cart): - for product_id, quantity, field_name in cat_form.product_quantities(): +def set_quantities_from_products_form(products_form, current_cart): + for product_id, quantity, field_name in products_form.product_quantities(): product = rego.Product.objects.get(pk=product_id) try: current_cart.set_quantity(product, quantity, batched=True) except ValidationError as ve: - cat_form.add_error(field_name, ve) - if cat_form.errors: + products_form.add_error(field_name, ve) + if products_form.errors: raise ValidationError("Cannot add that stuff") current_cart.end_batch() From 0ae005a5f541ce0aba4d381b9be6035cd7cdf6d9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 10:23:59 +1100 Subject: [PATCH 045/418] Factors _QuantityBoxForm out of _ProductsForm --- registrasion/forms.py | 116 +++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index fd0359bb..68ed9041 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,51 +3,83 @@ import models as rego from django import forms +# Products forms -- none of these have any fields: they are to be subclassed +# and the fields added as needs be. + +class _ProductsForm(forms.Form): + + 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(_ProductsForm, self).__init__(*a, **k) + + @classmethod + def field_name(cls, product): + return cls.PRODUCT_PREFIX + ("%d" % product.id) + + @classmethod + def set_fields(cls, 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([]) + + +class _QuantityBoxProductsForm(_ProductsForm): + ''' Products entry form that allows users to enter quantities + of desired products. ''' + + @classmethod + def set_fields(cls, products): + for product in products: + help_text = "$%d -- %s" % (product.price, product.description) + + field = forms.IntegerField( + label=product.name, + help_text=help_text, + ) + 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, name) + + def ProductsForm(products): + ''' Produces an appropriate _ProductsForm subclass for the given render + type. ''' - PREFIX = "product_" + if True: + class ProductsForm(_QuantityBoxProductsForm): + pass - def field_name(product): - return PREFIX + ("%d" % product.id) - - class _ProductsForm(forms.Form): - - def __init__(self, *a, **k): - if "product_quantities" in k: - initial = _ProductsForm.initial_data(k["product_quantities"]) - k["initial"] = initial - del k["product_quantities"] - super(_ProductsForm, self).__init__(*a, **k) - - @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 ''' - initial = {} - for product, quantity in product_quantities: - initial[field_name(product)] = quantity - - return initial - - def product_quantities(self): - ''' Yields a sequence of (product, quantity) tuples from the - cleaned form data. ''' - for name, value in self.cleaned_data.items(): - if name.startswith(PREFIX): - product_id = int(name[len(PREFIX):]) - yield (product_id, value, name) - - for product in products: - - help_text = "$%d -- %s" % (product.price, product.description) - - field = forms.IntegerField( - label=product.name, - help_text=help_text, - ) - _ProductsForm.base_fields[field_name(product)] = field - - return _ProductsForm + ProductsForm.set_fields(products) + return ProductsForm class ProfileForm(forms.ModelForm): From 3562772c13cc0583cbdf23d4cda682e78cccb383 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 11:18:26 +1100 Subject: [PATCH 046/418] Adds RadioBoxProductsForm --- registrasion/forms.py | 64 ++++++++++++++++++++++++++++++++++++++----- registrasion/views.py | 2 +- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 68ed9041..cc4b1b4e 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -23,7 +23,7 @@ class _ProductsForm(forms.Form): return cls.PRODUCT_PREFIX + ("%d" % product.id) @classmethod - def set_fields(cls, products): + def set_fields(cls, category, products): ''' Sets the base_fields on this _ProductsForm to allow selecting from the provided products. ''' pass @@ -45,7 +45,7 @@ class _QuantityBoxProductsForm(_ProductsForm): of desired products. ''' @classmethod - def set_fields(cls, products): + def set_fields(cls, category, products): for product in products: help_text = "$%d -- %s" % (product.price, product.description) @@ -70,15 +70,65 @@ class _QuantityBoxProductsForm(_ProductsForm): yield (product_id, value, name) -def ProductsForm(products): +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)) + + 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: + yield ( + choice_value, + 1 if ours == choice_value else 0, + self.FIELD, + ) + + +def ProductsForm(category, products): ''' Produces an appropriate _ProductsForm subclass for the given render type. ''' - if True: - class ProductsForm(_QuantityBoxProductsForm): - pass + # Each Category.RENDER_TYPE value has a subclass here. + RENDER_TYPES = { + rego.Category.RENDER_TYPE_QUANTITY : _QuantityBoxProductsForm, + rego.Category.RENDER_TYPE_RADIO : _RadioButtonProductsForm, + } - ProductsForm.set_fields(products) + # 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) return ProductsForm diff --git a/registrasion/views.py b/registrasion/views.py index 04c63ae2..6d42b2b3 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -136,7 +136,7 @@ def handle_products(request, category, products, prefix): current_cart = CartController.for_user(request.user) - ProductsForm = forms.ProductsForm(products) + ProductsForm = forms.ProductsForm(category, products) # Create initial data for each of products in category items = rego.ProductItem.objects.filter( From db332da9584d4b9dfb71b72aedc298d8d4f9d1dd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 11:48:17 +1100 Subject: [PATCH 047/418] flake8 --- registrasion/forms.py | 4 ++-- registrasion/templatetags/registrasion_tags.py | 4 ++++ registrasion/views.py | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index cc4b1b4e..eb6f2949 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -120,8 +120,8 @@ def ProductsForm(category, products): # Each Category.RENDER_TYPE value has a subclass here. RENDER_TYPES = { - rego.Category.RENDER_TYPE_QUANTITY : _QuantityBoxProductsForm, - rego.Category.RENDER_TYPE_RADIO : _RadioButtonProductsForm, + rego.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, + rego.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index a6741c0e..a583f1ea 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -8,16 +8,19 @@ register = template.Library() ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) + @register.assignment_tag(takes_context=True) def available_categories(context): ''' Returns all of the available product categories ''' return rego.Category.objects.all() + @register.assignment_tag(takes_context=True) def invoices(context): ''' Returns all of the invoices that this user has. ''' return rego.Invoice.objects.filter(cart__user=context.request.user) + @register.assignment_tag(takes_context=True) def items_pending(context): ''' Returns all of the items that this user has in their current cart, @@ -29,6 +32,7 @@ def items_pending(context): ) return all_items + @register.assignment_tag(takes_context=True) def items_purchased(context): ''' Returns all of the items that this user has purchased ''' diff --git a/registrasion/views.py b/registrasion/views.py index 6d42b2b3..2a82db1d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -185,6 +185,7 @@ def handle_products(request, category, products, prefix): return products_form, discounts, handled + @transaction.atomic def set_quantities_from_products_form(products_form, current_cart): for product_id, quantity, field_name in products_form.product_quantities(): @@ -197,6 +198,7 @@ def set_quantities_from_products_form(products_form, current_cart): raise ValidationError("Cannot add that stuff") current_cart.end_batch() + def handle_voucher(request, prefix): ''' Handles a voucher form in the given request. Returns the voucher form instance, and whether the voucher code was handled. ''' @@ -225,6 +227,7 @@ def handle_voucher(request, prefix): return (voucher_form, handled) + @login_required def checkout(request): ''' Runs checkout for the current cart of items, ideally generating an From 7c99750f3ab2ebedfdee4405d34b5518b80ab8c0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 12:08:17 +1100 Subject: [PATCH 048/418] Simplifies creation of test data in test_cart, adds an extra product category and two new products --- registrasion/tests/test_cart.py | 95 +++++++++++++-------------------- 1 file changed, 36 insertions(+), 59 deletions(-) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d000b946..b4ff3919 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -32,69 +32,46 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', password='top_secret') - cls.CAT_1 = rego.Category.objects.create( - name="Category 1", - description="This is a test category", - order=10, - render_type=rego.Category.RENDER_TYPE_RADIO, - required=False, - ) - cls.CAT_1.save() - - cls.CAT_2 = rego.Category.objects.create( - name="Category 2", - description="This is a test category", - order=10, - render_type=rego.Category.RENDER_TYPE_RADIO, - required=False, - ) - cls.CAT_2.save() - cls.RESERVATION = datetime.timedelta(hours=1) - cls.PROD_1 = rego.Product.objects.create( - name="Product 1", - description="This is a test product. It costs $10. " - "A user may have 10 of them.", - category=cls.CAT_1, - price=Decimal("10.00"), - reservation_duration=cls.RESERVATION, - limit_per_user=10, - order=10, - ) - cls.PROD_1.save() + cls.categories = [] + for i in xrange(3): + cat = rego.Category.objects.create( + name="Category " + str(i + 1), + description="This is a test category", + order=i, + render_type=rego.Category.RENDER_TYPE_RADIO, + required=False, + ) + cat.save() + cls.categories.append(cat) - cls.PROD_2 = rego.Product.objects.create( - name="Product 2", - description="This is a test product. It costs $10. " - "A user may have 10 of them.", - category=cls.CAT_1, - price=Decimal("10.00"), - limit_per_user=10, - order=10, - ) - cls.PROD_2.save() + cls.CAT_1 = cls.categories[0] + cls.CAT_2 = cls.categories[1] + cls.CAT_3 = cls.categories[2] - cls.PROD_3 = rego.Product.objects.create( - name="Product 3", - description="This is a test product. It costs $10. " - "A user may have 10 of them.", - category=cls.CAT_2, - price=Decimal("10.00"), - limit_per_user=10, - order=10, - ) - cls.PROD_3.save() + cls.products = [] + for i in xrange(6): + prod = rego.Product.objects.create( + name="Product 1", + description="This is a test product." + category=cls.categories[i / 2], # 2 products per category + price=Decimal("10.00"), + reservation_duration=cls.RESERVATION, + limit_per_user=10, + order=1, + ) + prod.save() + cls.products.append(prod) - cls.PROD_4 = rego.Product.objects.create( - name="Product 4", - description="This is a test product. It costs $5. " - "A user may have 10 of them.", - category=cls.CAT_2, - price=Decimal("5.00"), - limit_per_user=10, - order=10, - ) + 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_5 = cls.products[4] + cls.PROD_6 = cls.products[5] + + cls.PROD_4.price = Decimal("5.00") cls.PROD_4.save() @classmethod @@ -205,7 +182,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.set_quantity(self.PROD_1, 2) self.assertEqual(2, get_item().quantity) - def test_add_to_cart_per_user_limit(self): + def test_add_to_cart_product_per_user_limit(self): current_cart = CartController.for_user(self.USER_1) # User should be able to add 1 of PROD_1 to the current cart. From 0d458bea068e0b0c849859c4f010ef5c3b55e09c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 12:24:48 +1100 Subject: [PATCH 049/418] Allows Product.limit_per_user to be blank and null. Adds Category.limit_per_user. Adds functionality and tests to verify that this is legal. --- registrasion/controllers/product.py | 25 +++-- .../migrations/0007_auto_20160326_2105.py | 24 +++++ registrasion/models.py | 5 + registrasion/tests/test_cart.py | 92 +++++++++++++++++-- 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 registrasion/migrations/0007_auto_20160326_2105.py diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index d91a2541..88beb8f8 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,6 +1,7 @@ import itertools from django.db.models import Q +from django.db.models import Sum from registrasion import models as rego from conditions import ConditionController @@ -42,17 +43,25 @@ class ProductController(object): carts = rego.Cart.objects.filter(user=user) items = rego.ProductItem.objects.filter( - product=self.product, - cart=carts) + cart=carts, + ) - count = 0 - for item in items: - count += item.quantity + prod_items = items.filter(product=self.product) + cat_items = items.filter(product__category=self.product.category) - if quantity + count > self.product.limit_per_user: - return False - else: + prod_count = prod_items.aggregate(Sum("quantity"))["quantity__sum"] + cat_count = cat_items.aggregate(Sum("quantity"))["quantity__sum"] + + prod_limit = self.product.limit_per_user + prod_met = prod_limit is None or quantity + prod_count <= prod_limit + + cat_limit = self.product.category.limit_per_user + cat_met = cat_limit is None or quantity + cat_count <= cat_limit + + if prod_met and cat_met: return True + else: + return False def can_add_with_enabling_conditions(self, user, quantity): ''' Returns true if the user is able to add _quantity_ to their count diff --git a/registrasion/migrations/0007_auto_20160326_2105.py b/registrasion/migrations/0007_auto_20160326_2105.py new file mode 100644 index 00000000..dbcf2ac7 --- /dev/null +++ b/registrasion/migrations/0007_auto_20160326_2105.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0006_category_required'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='limit_per_user', + field=models.PositiveIntegerField(null=True, verbose_name='Limit per user', blank=True), + ), + migrations.AlterField( + model_name='product', + name='limit_per_user', + field=models.PositiveIntegerField(null=True, verbose_name='Limit per user', blank=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 75949e24..c95e1740 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -132,6 +132,10 @@ class Category(models.Model): name = models.CharField(max_length=65, verbose_name=_("Name")) description = models.CharField(max_length=255, verbose_name=_("Description")) + limit_per_user = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit per user")) required = models.BooleanField(blank=True) order = models.PositiveIntegerField(verbose_name=("Display order")) render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, @@ -153,6 +157,7 @@ class Product(models.Model): decimal_places=2, verbose_name=_("Price")) limit_per_user = models.PositiveIntegerField( + null=True, blank=True, verbose_name=_("Limit per user")) reservation_duration = models.DurationField( diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index b4ff3919..5989f6e2 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -35,7 +35,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] - for i in xrange(3): + for i in xrange(2): cat = rego.Category.objects.create( name="Category " + str(i + 1), description="This is a test category", @@ -48,13 +48,12 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.CAT_1 = cls.categories[0] cls.CAT_2 = cls.categories[1] - cls.CAT_3 = cls.categories[2] cls.products = [] - for i in xrange(6): + for i in xrange(4): prod = rego.Product.objects.create( name="Product 1", - description="This is a test product." + description="This is a test product.", category=cls.categories[i / 2], # 2 products per category price=Decimal("10.00"), reservation_duration=cls.RESERVATION, @@ -68,8 +67,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_2 = cls.products[1] cls.PROD_3 = cls.products[2] cls.PROD_4 = cls.products[3] - cls.PROD_5 = cls.products[4] - cls.PROD_6 = cls.products[5] cls.PROD_4.price = Decimal("5.00") cls.PROD_4.save() @@ -208,3 +205,86 @@ class BasicCartTests(RegistrationCartTestCase): # Second user should not be affected by first user's limits second_user_cart = CartController.for_user(self.USER_2) second_user_cart.add_to_cart(self.PROD_1, 10) + + 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 = CartController.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 = CartController.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 = CartController.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.cart.active = False + current_cart.cart.save() + + current_cart = CartController.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 = CartController.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.cart.active = False + current_cart.cart.save() + + current_cart = CartController.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) From 8080d7851bda44796c644bf4e6f5930826a4f8a1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 14:04:47 +1100 Subject: [PATCH 050/418] Invoices now automatically void themselves if their cart is out of date --- registrasion/controllers/invoice.py | 17 ++++++++------- registrasion/tests/test_invoice.py | 32 +++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index b8087c98..47765358 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -11,6 +11,7 @@ class InvoiceController(object): def __init__(self, invoice): self.invoice = invoice + self.update_validity() # Make sure this invoice is up-to-date @classmethod def for_cart(cls, cart): @@ -24,6 +25,10 @@ class InvoiceController(object): except ObjectDoesNotExist: cart_controller = CartController(cart) cart_controller.validate_cart() # Raises ValidationError on fail. + + # Void past invoices for this cart + invoices = rego.Invoice.objects.filter(cart=cart).update(void=True) + invoice = cls._generate(cart) return InvoiceController(invoice) @@ -91,19 +96,17 @@ class InvoiceController(object): return invoice - def is_valid(self): - ''' Returns true if the attached invoice is not void and it represents - a valid cart. ''' - if self.invoice.void: - return False + def update_validity(self): + ''' Updates the validity of this invoice if the cart it is attached to + has updated. ''' if self.invoice.cart is not None: if self.invoice.cart.revision != self.invoice.cart_revision: - return False - return True + self.void() def void(self): ''' Voids the invoice. ''' self.invoice.void = True + self.invoice.save() def pay(self, reference, amount): ''' Pays the invoice by the given amount. If the payment diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 637e82db..b3f80314 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -27,11 +27,17 @@ class InvoiceTestCase(RegistrationCartTestCase): # That invoice should have a value equal to cost of PROD_1 self.assertEqual(self.PROD_1.price, invoice_1.invoice.value) - # Adding item to cart should void all active invoices and produce - # a new invoice + # Adding item to cart should produce a new invoice current_cart.add_to_cart(self.PROD_2, 1) invoice_2 = InvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) + + # The old invoice should automatically be voided + invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id) + invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id) + self.assertTrue(invoice_1_new.void) + self.assertFalse(invoice_2_new.void) + # Invoice should have two line items line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) self.assertEqual(2, len(line_items)) @@ -104,3 +110,25 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEqual( self.PROD_1.price * Decimal("0.5"), invoice_1.invoice.value) + + def test_invoice_voids_self_if_cart_is_invalid(self): + current_cart = CartController.for_user(self.USER_1) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + self.assertFalse(invoice_1.invoice.void) + + # Adding item to cart should produce a new invoice + current_cart.add_to_cart(self.PROD_2, 1) + invoice_2 = InvoiceController.for_cart(current_cart.cart) + self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) + + # Viewing invoice_1's invoice should show it as void + invoice_1_new = InvoiceController(invoice_1.invoice) + self.assertTrue(invoice_1.invoice.void) + + # Viewing invoice_2's invoice should *not* show it as void + invoice_2_new = InvoiceController(invoice_2.invoice) + self.assertFalse(invoice_2.invoice.void) From 3e4e52b165b3379f6d85405e8dbc8d2463ef77dc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 14:41:43 +1100 Subject: [PATCH 051/418] Adds more constraints around payment and voiding of invoices --- registrasion/controllers/invoice.py | 21 ++++++++++++--- registrasion/tests/test_invoice.py | 41 +++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 47765358..19da7525 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -1,5 +1,6 @@ from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError from django.db.models import Sum from registrasion import models as rego @@ -11,7 +12,7 @@ class InvoiceController(object): def __init__(self, invoice): self.invoice = invoice - self.update_validity() # Make sure this invoice is up-to-date + self.update_validity() # Make sure this invoice is up-to-date @classmethod def for_cart(cls, cart): @@ -21,13 +22,16 @@ class InvoiceController(object): try: invoice = rego.Invoice.objects.get( - cart=cart, cart_revision=cart.revision) + cart=cart, + cart_revision=cart.revision, + void=False, + ) except ObjectDoesNotExist: cart_controller = CartController(cart) cart_controller.validate_cart() # Raises ValidationError on fail. # Void past invoices for this cart - invoices = rego.Invoice.objects.filter(cart=cart).update(void=True) + rego.Invoice.objects.filter(cart=cart).update(void=True) invoice = cls._generate(cart) @@ -104,7 +108,10 @@ class InvoiceController(object): self.void() def void(self): - ''' Voids the invoice. ''' + ''' Voids the invoice if it is valid to do so. ''' + if self.invoice.paid: + raise ValidationError("Paid invoices cannot be voided, " + "only refunded.") self.invoice.void = True self.invoice.save() @@ -117,6 +124,12 @@ class InvoiceController(object): cart = CartController(self.invoice.cart) cart.validate_cart() # Raises ValidationError if invalid + if self.invoice.void: + raise ValidationError("Void invoices cannot be paid") + + if self.invoice.paid: + raise ValidationError("Paid invoices cannot be paid again") + ''' Adds a payment ''' payment = rego.Payment.objects.create( invoice=self.invoice, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index b3f80314..37265a2a 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -127,8 +127,45 @@ class InvoiceTestCase(RegistrationCartTestCase): # Viewing invoice_1's invoice should show it as void invoice_1_new = InvoiceController(invoice_1.invoice) - self.assertTrue(invoice_1.invoice.void) + self.assertTrue(invoice_1_new.invoice.void) # Viewing invoice_2's invoice should *not* show it as void invoice_2_new = InvoiceController(invoice_2.invoice) - self.assertFalse(invoice_2.invoice.void) + self.assertFalse(invoice_2_new.invoice.void) + + def test_voiding_invoice_creates_new_invoice(self): + current_cart = CartController.for_user(self.USER_1) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + self.assertFalse(invoice_1.invoice.void) + invoice_1.void() + + invoice_2 = InvoiceController.for_cart(current_cart.cart) + self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) + + def test_cannot_pay_void_invoice(self): + current_cart = CartController.for_user(self.USER_1) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + invoice_1.void() + + with self.assertRaises(ValidationError): + invoice_1.pay("Reference", invoice_1.invoice.value) + + def test_cannot_void_paid_invoice(self): + current_cart = CartController.for_user(self.USER_1) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + invoice_1.pay("Reference", invoice_1.invoice.value) + + with self.assertRaises(ValidationError): + invoice_1.void() From b65223aaa1eab38c07b663153dda729611b06c2c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 19:13:05 +1100 Subject: [PATCH 052/418] Adds model for released carts --- registrasion/migrations/0008_cart_released.py | 19 +++++++++++++++++++ registrasion/models.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 registrasion/migrations/0008_cart_released.py diff --git a/registrasion/migrations/0008_cart_released.py b/registrasion/migrations/0008_cart_released.py new file mode 100644 index 00000000..1d805c94 --- /dev/null +++ b/registrasion/migrations/0008_cart_released.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0007_auto_20160326_2105'), + ] + + operations = [ + migrations.AddField( + model_name='cart', + name='released', + field=models.BooleanField(default=False), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index c95e1740..2421adf9 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -413,6 +413,7 @@ class Cart(models.Model): reservation_duration = models.DurationField() revision = models.PositiveIntegerField(default=1) active = models.BooleanField(default=True) + released = models.BooleanField(default=False) # Refunds etc @classmethod def reserved_carts(cls): From cf85af771983c6be09c7bf88e39a7834ad54979b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 19:25:24 +1100 Subject: [PATCH 053/418] Adds refund function, adds tests, makes sure that refunds are obeyed elsewhere in the codebase --- registrasion/controllers/cart.py | 1 + registrasion/controllers/conditions.py | 4 +- registrasion/controllers/discount.py | 6 +++ registrasion/controllers/invoice.py | 38 +++++++++++++++++-- registrasion/models.py | 2 +- registrasion/tests/test_cart.py | 2 +- registrasion/tests/test_ceilings.py | 18 +++++++++ registrasion/tests/test_discount.py | 24 ++++++++++++ registrasion/tests/test_enabling_condition.py | 38 +++++++++++++++++++ registrasion/tests/test_refund.py | 33 ++++++++++++++++ registrasion/tests/test_voucher.py | 17 +++++++++ 11 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 registrasion/tests/test_refund.py diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 06eef3ef..45796781 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -139,6 +139,7 @@ class CartController(object): # It's not valid for users to re-enter a voucher they already have user_carts_with_voucher = rego.Cart.objects.filter( user=self.cart.user, + released=False, vouchers=voucher, ) if len(user_carts_with_voucher) > 0: diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 914a2092..20320218 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -44,7 +44,7 @@ class CategoryConditionController(ConditionController): ''' returns True if the user has a product from a category that invokes this condition in one of their carts ''' - carts = rego.Cart.objects.filter(user=user) + carts = rego.Cart.objects.filter(user=user, released=False) enabling_products = rego.Product.objects.filter( category=self.condition.enabling_category) products = rego.ProductItem.objects.filter( @@ -64,7 +64,7 @@ class ProductConditionController(ConditionController): ''' returns True if the user has a product that invokes this condition in one of their carts ''' - carts = rego.Cart.objects.filter(user=user) + carts = rego.Cart.objects.filter(user=user, released=False) products = rego.ProductItem.objects.filter( cart=carts, product=self.condition.enabling_products.all()) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index dcb83dfd..7d6a959a 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -12,6 +12,11 @@ class DiscountAndQuantity(object): self.clause = clause self.quantity = quantity + def __repr__(self): + print "(discount=%s, clause=%s, quantity=%d)" % ( + self.discount, self.clause, self.quantity, + ) + def available_discounts(user, categories, products): ''' Returns all discounts available to this user for the given categories @@ -57,6 +62,7 @@ def available_discounts(user, categories, products): past_uses = rego.DiscountItem.objects.filter( cart__user=user, cart__active=False, # Only past carts count + cart__released=False, # You can reuse refunded discounts discount=discount.discount, ) agg = past_uses.aggregate(Sum("quantity")) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 19da7525..fdea324b 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -1,6 +1,7 @@ from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError +from django.db import transaction from django.db.models import Sum from registrasion import models as rego @@ -115,12 +116,13 @@ class InvoiceController(object): self.invoice.void = True self.invoice.save() + @transaction.atomic def pay(self, reference, amount): ''' Pays the invoice by the given amount. If the payment equals the total on the invoice, finalise the invoice. (NB should be transactional.) ''' - if self.invoice.cart is not None: + if self.invoice.cart: cart = CartController(self.invoice.cart) cart.validate_cart() # Raises ValidationError if invalid @@ -145,8 +147,36 @@ class InvoiceController(object): if total == self.invoice.value: self.invoice.paid = True - cart = self.invoice.cart - cart.active = False - cart.save() + if self.invoice.cart: + cart = self.invoice.cart + cart.active = False + cart.save() self.invoice.save() + + @transaction.atomic + def refund(self, reference, amount): + ''' Refunds the invoice by the given amount. The invoice is + marked as unpaid, and the underlying cart is marked as released. + ''' + + if self.invoice.void: + raise ValidationError("Void invoices cannot be refunded") + + ''' Adds a payment ''' + payment = rego.Payment.objects.create( + invoice=self.invoice, + reference=reference, + amount=0 - amount, + ) + payment.save() + + self.invoice.paid = False + self.invoice.void = True + + if self.invoice.cart: + cart = self.invoice.cart + cart.released = True + cart.save() + + self.invoice.save() diff --git a/registrasion/models.py b/registrasion/models.py index 2421adf9..2e1f95d1 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -423,7 +423,7 @@ class Cart(models.Model): Q(time_last_updated__gt=( timezone.now()-F('reservation_duration') ))) | - Q(active=False) + (Q(active=False) & Q(released=False)) ) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 5989f6e2..03d31b54 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -52,7 +52,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.products = [] for i in xrange(4): prod = rego.Product.objects.create( - name="Product 1", + name="Product " + str(i + 1), description="This is a test product.", category=cls.categories[i / 2], # 2 products per category price=Decimal("10.00"), diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 0bbd6e9e..c39df39a 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -132,3 +132,21 @@ class CeilingsTestCases(RegistrationCartTestCase): self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1)) with self.assertRaises(ValidationError): first_cart.validate_cart() + + def test_items_released_from_ceiling_by_refund(self): + self.make_ceiling("Limit ceiling", limit=1) + + first_cart = CartController.for_user(self.USER_1) + first_cart.add_to_cart(self.PROD_1, 1) + + first_cart.cart.active = False + first_cart.cart.save() + + second_cart = CartController.for_user(self.USER_2) + with self.assertRaises(ValidationError): + second_cart.add_to_cart(self.PROD_1, 1) + + first_cart.cart.released = True + first_cart.cart.save() + + second_cart.add_to_cart(self.PROD_1, 1) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 222afc09..5f536e9b 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -377,3 +377,27 @@ class DiscountTestCase(RegistrationCartTestCase): [self.PROD_3, self.PROD_4], ) self.assertEqual(2, len(discounts)) + + def test_discounts_are_released_by_refunds(self): + self.add_discount_prod_1_includes_prod_2(quantity=2) + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2]) + self.assertEqual(1, len(discounts)) + + cart.cart.active = False # Keep discount enabled + cart.cart.save() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted + cart.cart.active = False + cart.cart.save() + + discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2]) + self.assertEqual(0, len(discounts)) + + cart.cart.released = True + cart.cart.save() + + discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2]) + self.assertEqual(1, len(discounts)) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index e7155dea..2861e8db 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -233,3 +233,41 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_1 in prods) self.assertTrue(self.PROD_2 in prods) + + def test_category_enabling_condition_fails_if_cart_refunded(self): + self.add_category_enabling_condition(mandatory=False) + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_3, 1) + + cart.cart.active = False + cart.cart.save() + + cart_2 = CartController.for_user(self.USER_1) + cart_2.add_to_cart(self.PROD_1, 1) + cart_2.set_quantity(self.PROD_1, 0) + + cart.cart.released = True + cart.cart.save() + + with self.assertRaises(ValidationError): + cart_2.set_quantity(self.PROD_1, 1) + + def test_product_enabling_condition_fails_if_cart_refunded(self): + self.add_product_enabling_condition(mandatory=False) + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_2, 1) + + cart.cart.active = False + cart.cart.save() + + cart_2 = CartController.for_user(self.USER_1) + cart_2.add_to_cart(self.PROD_1, 1) + cart_2.set_quantity(self.PROD_1, 0) + + cart.cart.released = True + cart.cart.save() + + with self.assertRaises(ValidationError): + cart_2.set_quantity(self.PROD_1, 1) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py new file mode 100644 index 00000000..14811b78 --- /dev/null +++ b/registrasion/tests/test_refund.py @@ -0,0 +1,33 @@ +import datetime +import pytz + +from decimal import Decimal +from django.core.exceptions import ValidationError + +from registrasion import models as rego +from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + + +class RefundTestCase(RegistrationCartTestCase): + + def test_refund_marks_void_and_unpaid_and_cart_released(self): + current_cart = CartController.for_user(self.USER_1) + + # Should be able to create an invoice after the product is added + current_cart.add_to_cart(self.PROD_1, 1) + invoice = InvoiceController.for_cart(current_cart.cart) + + invoice.pay("A Payment!", invoice.invoice.value) + self.assertFalse(invoice.invoice.void) + self.assertTrue(invoice.invoice.paid) + self.assertFalse(invoice.invoice.cart.released) + + invoice.refund("A Refund!", invoice.invoice.value) + self.assertTrue(invoice.invoice.void) + self.assertFalse(invoice.invoice.paid) + self.assertTrue(invoice.invoice.cart.released) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index db59d094..abc7c8c3 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -126,3 +126,20 @@ class VoucherTestCases(RegistrationCartTestCase): 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 = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code) + + inv = InvoiceController.for_cart(current_cart.cart) + inv.pay("Hello!", inv.invoice.value) + + current_cart = CartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): + current_cart.apply_voucher(voucher.code) + + inv.refund("Hello!", inv.invoice.value) + current_cart.apply_voucher(voucher.code) From ba0682a5f9324a2baf18e1ce23a7cce6f09397ff Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 12:04:30 +1100 Subject: [PATCH 054/418] Fleshes out the admin interface and adds help_text attributes to the model fields that need it --- registrasion/admin.py | 115 ++++++++++- .../migrations/0009_auto_20160330_2336.py | 113 +++++++++++ registrasion/models.py | 182 ++++++++++++++---- 3 files changed, 364 insertions(+), 46 deletions(-) create mode 100644 registrasion/migrations/0009_auto_20160330_2336.py diff --git a/registrasion/admin.py b/registrasion/admin.py index 17969cd1..7155e177 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_lazy as _ import nested_admin @@ -6,21 +7,36 @@ import nested_admin from registrasion import models as rego +class EffectsDisplayMixin(object): + def effects(self, obj): + return list(obj.effects()) + # Inventory admin + class ProductInline(admin.TabularInline): model = rego.Product + ordering = ("order", ) @admin.register(rego.Category) class CategoryAdmin(admin.ModelAdmin): model = rego.Category - verbose_name_plural = _("Categories") + fields = ("name", "description", "required", "render_type", + "limit_per_user", "order",) + list_display = ("name", "description") + ordering = ("order", ) inlines = [ ProductInline, ] -admin.site.register(rego.Product) + +@admin.register(rego.Product) +class ProductAdmin(admin.ModelAdmin): + model = rego.Product + list_display = ("name", "category", "description") + list_filter = ("category", ) + ordering = ("category__order", "order", ) # Discounts @@ -37,11 +53,34 @@ class DiscountForCategoryInline(admin.TabularInline): verbose_name_plural = _("Categories included in discount") -@admin.register( - rego.TimeOrStockLimitDiscount, - rego.IncludedProductDiscount, -) -class DiscountAdmin(admin.ModelAdmin): +@admin.register(rego.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(rego.IncludedProductDiscount) +class IncludedProductDiscountAdmin(admin.ModelAdmin): + + def enablers(self, obj): + return list(obj.enabling_products.all()) + + def effects(self, obj): + return list(obj.effects()) + + list_display = ("description", "enablers", "effects") + inlines = [ DiscountForProductInline, DiscountForCategoryInline, @@ -75,7 +114,30 @@ class VoucherEnablingConditionInline(nested_admin.NestedStackedInline): @admin.register(rego.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.voucherenablingcondition.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 = rego.Voucher + list_display = ("recipient", "code", "effects") inlines = [ VoucherDiscountInline, VoucherEnablingConditionInline, @@ -84,11 +146,46 @@ class VoucherAdmin(nested_admin.NestedAdmin): # Enabling conditions @admin.register(rego.ProductEnablingCondition) -class ProductEnablingConditionAdmin(nested_admin.NestedAdmin): +class ProductEnablingConditionAdmin( + nested_admin.NestedAdmin, + EffectsDisplayMixin): + + def enablers(self, obj): + return list(obj.enabling_products.all()) + model = rego.ProductEnablingCondition + fields = ("description", "enabling_products", "mandatory", "products", + "categories"), + + list_display = ("description", "enablers", "effects") # Enabling conditions @admin.register(rego.CategoryEnablingCondition) -class CategoryEnablingConditionAdmin(nested_admin.NestedAdmin): +class CategoryEnablingConditionAdmin( + nested_admin.NestedAdmin, + EffectsDisplayMixin): + model = rego.CategoryEnablingCondition + fields = ("description", "enabling_category", "mandatory", "products", + "categories"), + + list_display = ("description", "enabling_category", "effects") + ordering = ("enabling_category",) + + +# Enabling conditions +@admin.register(rego.TimeOrStockLimitEnablingCondition) +class TimeOrStockLimitEnablingConditionAdmin( + nested_admin.NestedAdmin, + EffectsDisplayMixin): + model = rego.TimeOrStockLimitEnablingCondition + + list_display = ( + "description", + "start_time", + "end_time", + "limit", + "effects", + ) + ordering = ("start_time", "end_time", "limit") diff --git a/registrasion/migrations/0009_auto_20160330_2336.py b/registrasion/migrations/0009_auto_20160330_2336.py new file mode 100644 index 00000000..9d81a3b6 --- /dev/null +++ b/registrasion/migrations/0009_auto_20160330_2336.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0008_cart_released'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={'verbose_name_plural': 'categories'}, + ), + migrations.AlterModelOptions( + name='timeorstocklimitenablingcondition', + options={'verbose_name': 'ceiling'}, + ), + migrations.AlterField( + model_name='category', + name='limit_per_user', + field=models.PositiveIntegerField(help_text='The total number of items from this category one attendee may purchase.', null=True, verbose_name='Limit per user', blank=True), + ), + migrations.AlterField( + model_name='category', + name='render_type', + field=models.IntegerField(help_text='The registration form will render this category in this style.', verbose_name='Render type', choices=[(1, 'Radio button'), (2, 'Quantity boxes')]), + ), + migrations.AlterField( + model_name='category', + name='required', + field=models.BooleanField(help_text='If enabled, a user must select an item from this category.'), + ), + migrations.AlterField( + model_name='categoryenablingcondition', + name='enabling_category', + field=models.ForeignKey(help_text='If a product from this category is purchased, this condition is met.', to='registrasion.Category'), + ), + migrations.AlterField( + model_name='discountbase', + name='description', + field=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.AlterField( + model_name='enablingconditionbase', + name='categories', + field=models.ManyToManyField(help_text='Categories whose products are enabled if this condition is met.', to='registrasion.Category', blank=True), + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='mandatory', + field=models.BooleanField(default=False, help_text='If there is at least one mandatory condition defined on a product or category, all such conditions must be met. Otherwise, at least one non-mandatory condition must be met.'), + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='products', + field=models.ManyToManyField(help_text='Products that are enabled if this condition is met.', to='registrasion.Product', blank=True), + ), + migrations.AlterField( + model_name='includedproductdiscount', + name='enabling_products', + field=models.ManyToManyField(help_text='If one of these products are purchased, the discounts below will be enabled.', to='registrasion.Product', verbose_name='Including product'), + ), + migrations.AlterField( + model_name='product', + name='description', + field=models.CharField(max_length=255, null=True, verbose_name='Description', blank=True), + ), + migrations.AlterField( + model_name='product', + name='reservation_duration', + field=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'), + ), + migrations.AlterField( + model_name='productenablingcondition', + name='enabling_products', + field=models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product'), + ), + migrations.AlterField( + model_name='timeorstocklimitdiscount', + name='end_time', + field=models.DateTimeField(help_text='This discount will only be available before this time.', null=True, verbose_name='End time', blank=True), + ), + migrations.AlterField( + model_name='timeorstocklimitdiscount', + name='limit', + field=models.PositiveIntegerField(help_text='This discount may only be applied this many times.', null=True, verbose_name='Limit', blank=True), + ), + migrations.AlterField( + model_name='timeorstocklimitdiscount', + name='start_time', + field=models.DateTimeField(help_text='This discount will only be available after this time.', null=True, verbose_name='Start time', blank=True), + ), + migrations.AlterField( + model_name='timeorstocklimitenablingcondition', + name='end_time', + field=models.DateTimeField(help_text='Products included in this condition will only be available before this time.', null=True), + ), + migrations.AlterField( + model_name='timeorstocklimitenablingcondition', + name='limit', + field=models.PositiveIntegerField(help_text='The number of items under this grouping that can be purchased.', null=True), + ), + migrations.AlterField( + model_name='timeorstocklimitenablingcondition', + name='start_time', + field=models.DateTimeField(help_text='Products included in this condition will only be available after this time.', null=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 2e1f95d1..5887394c 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import datetime +import itertools from django.core.exceptions import ValidationError from django.core.exceptions import ObjectDoesNotExist @@ -118,6 +119,9 @@ class BadgeAndProfile(models.Model): class Category(models.Model): ''' Registration product categories ''' + class Meta: + verbose_name_plural = _("categories") + def __str__(self): return self.name @@ -129,17 +133,35 @@ class Category(models.Model): (RENDER_TYPE_QUANTITY, _("Quantity boxes")), ] - name = models.CharField(max_length=65, verbose_name=_("Name")) - description = models.CharField(max_length=255, - verbose_name=_("Description")) + name = models.CharField( + max_length=65, + verbose_name=_("Name"), + ) + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + ) limit_per_user = models.PositiveIntegerField( null=True, blank=True, - verbose_name=_("Limit per user")) - required = models.BooleanField(blank=True) - order = models.PositiveIntegerField(verbose_name=("Display order")) - render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, - verbose_name=_("Render type")) + 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"), + ) + 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 @@ -147,23 +169,41 @@ class Product(models.Model): ''' Registration products ''' def __str__(self): - return self.name + return "%s - %s" % (self.category.name, self.name) - name = models.CharField(max_length=65, verbose_name=_("Name")) - description = models.CharField(max_length=255, - verbose_name=_("Description")) - category = models.ForeignKey(Category, verbose_name=_("Product category")) - price = models.DecimalField(max_digits=8, - decimal_places=2, - verbose_name=_("Price")) + name = models.CharField( + max_length=65, + verbose_name=_("Name"), + ) + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + null=True, + blank=True, + ) + category = models.ForeignKey( + Category, + verbose_name=_("Product category") + ) + price = models.DecimalField( + max_digits=8, + decimal_places=2, + verbose_name=_("Price"), + ) limit_per_user = models.PositiveIntegerField( null=True, blank=True, - verbose_name=_("Limit per user")) + verbose_name=_("Limit per user"), + ) reservation_duration = models.DurationField( default=datetime.timedelta(hours=1), - verbose_name=_("Reservation duration")) - order = models.PositiveIntegerField(verbose_name=("Display order")) + 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"), + ) @python_2_unicode_compatible @@ -206,8 +246,18 @@ class DiscountBase(models.Model): def __str__(self): return "Discount: " + self.description - description = models.CharField(max_length=255, - verbose_name=_("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 @@ -292,11 +342,23 @@ class TimeOrStockLimitDiscount(DiscountBase): verbose_name = _("Promotional discount") start_time = models.DateTimeField( - null=True, blank=True, verbose_name=_("Start time")) + null=True, + blank=True, + verbose_name=_("Start time"), + help_text=_("This discount will only be available after this time."), + ) end_time = models.DateTimeField( - null=True, blank=True, verbose_name=_("End time")) + null=True, + blank=True, + verbose_name=_("End time"), + help_text=_("This discount will only be available before this time."), + ) limit = models.PositiveIntegerField( - null=True, blank=True, verbose_name=_("Limit")) + null=True, + blank=True, + verbose_name=_("Limit"), + help_text=_("This discount may only be applied this many times."), + ) class VoucherDiscount(DiscountBase): @@ -306,7 +368,8 @@ class VoucherDiscount(DiscountBase): voucher = models.OneToOneField( Voucher, on_delete=models.CASCADE, - verbose_name=_("Voucher")) + verbose_name=_("Voucher"), + ) class IncludedProductDiscount(DiscountBase): @@ -318,7 +381,10 @@ class IncludedProductDiscount(DiscountBase): enabling_products = models.ManyToManyField( Product, - verbose_name=_("Including product")) + verbose_name=_("Including product"), + help_text=_("If one of these products are purchased, the discounts " + "below will be enabled."), + ) class RoleDiscount(object): @@ -340,20 +406,54 @@ class EnablingConditionBase(models.Model): objects = InheritanceManager() def __str__(self): - return self.name + return self.description + + def effects(self): + ''' Returns all of the items enabled by this condition. ''' + return itertools.chain(self.products.all(), self.categories.all()) description = models.CharField(max_length=255) - mandatory = models.BooleanField(default=False) - products = models.ManyToManyField(Product, blank=True) - categories = models.ManyToManyField(Category, blank=True) + mandatory = models.BooleanField( + default=False, + help_text=_("If there is at least one mandatory condition defined on " + "a product or category, all such conditions must be met. " + "Otherwise, at least one non-mandatory condition must be " + "met."), + ) + products = models.ManyToManyField( + Product, + blank=True, + help_text=_("Products that are enabled if this condition is met."), + ) + categories = models.ManyToManyField( + Category, + blank=True, + help_text=_("Categories whose products are enabled if this condition " + "is met."), + ) class TimeOrStockLimitEnablingCondition(EnablingConditionBase): ''' Registration product ceilings ''' - start_time = models.DateTimeField(null=True, verbose_name=_("Start time")) - end_time = models.DateTimeField(null=True, verbose_name=_("End time")) - limit = models.PositiveIntegerField(null=True, verbose_name=_("Limit")) + class Meta: + verbose_name = _("ceiling") + + start_time = models.DateTimeField( + null=True, + help_text=_("Products included in this condition will only be " + "available after this time."), + ) + end_time = models.DateTimeField( + null=True, + help_text=_("Products included in this condition will only be " + "available before this time."), + ) + limit = models.PositiveIntegerField( + null=True, + help_text=_("The number of items under this grouping that can be " + "purchased."), + ) @python_2_unicode_compatible @@ -361,9 +461,13 @@ class ProductEnablingCondition(EnablingConditionBase): ''' The condition is met because a specific product is purchased. ''' def __str__(self): - return "Enabled by product: " + return "Enabled by products: " + str(self.enabling_products.all()) - enabling_products = models.ManyToManyField(Product) + enabling_products = models.ManyToManyField( + Product, + help_text=_("If one of these products are purchased, this condition " + "is met."), + ) @python_2_unicode_compatible @@ -372,9 +476,13 @@ class CategoryEnablingCondition(EnablingConditionBase): purchased. ''' def __str__(self): - return "Enabled by product in category: " + return "Enabled by product in category: " + str(self.enabling_category) - enabling_category = models.ForeignKey(Category) + enabling_category = models.ForeignKey( + Category, + help_text=_("If a product from this category is purchased, this " + "condition is met."), + ) @python_2_unicode_compatible From c7b6c81071fed4debb6f5bc8c67da6449b339c3b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 14:34:34 +1100 Subject: [PATCH 055/418] =?UTF-8?q?adds=20setuptools=E2=80=99=20build=20di?= =?UTF-8?q?rectory=20to=20the=20flake8=20ignore=20path.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index c9dcb437..257d3cd6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,2 @@ [flake8] -exclude = registrasion/migrations/* - +exclude = registrasion/migrations/*, build/* From eebf9e81f5b79d3a9f57a2067577d44aad627a95 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 14:45:39 +1100 Subject: [PATCH 056/418] =?UTF-8?q?Resolves=20#3=20=E2=80=94=20start=5Ftim?= =?UTF-8?q?e,=20end=5Ftime,=20and=20limit=20can=20now=20be=20blank.=20Test?= =?UTF-8?q?s=20already=20dealt=20with=20the=20null=20case.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0010_auto_20160330_2342.py | 29 +++++++++++++++++++ registrasion/models.py | 3 ++ 2 files changed, 32 insertions(+) create mode 100644 registrasion/migrations/0010_auto_20160330_2342.py diff --git a/registrasion/migrations/0010_auto_20160330_2342.py b/registrasion/migrations/0010_auto_20160330_2342.py new file mode 100644 index 00000000..f689504b --- /dev/null +++ b/registrasion/migrations/0010_auto_20160330_2342.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0009_auto_20160330_2336'), + ] + + operations = [ + migrations.AlterField( + model_name='timeorstocklimitenablingcondition', + name='end_time', + field=models.DateTimeField(help_text='Products included in this condition will only be available before this time.', null=True, blank=True), + ), + migrations.AlterField( + model_name='timeorstocklimitenablingcondition', + name='limit', + field=models.PositiveIntegerField(help_text='The number of items under this grouping that can be purchased.', null=True, blank=True), + ), + migrations.AlterField( + model_name='timeorstocklimitenablingcondition', + name='start_time', + field=models.DateTimeField(help_text='Products included in this condition will only be available after this time.', null=True, blank=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 5887394c..c841ffae 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -441,16 +441,19 @@ class TimeOrStockLimitEnablingCondition(EnablingConditionBase): start_time = models.DateTimeField( null=True, + blank=True, help_text=_("Products included in this condition will only be " "available after this time."), ) end_time = models.DateTimeField( null=True, + blank=True, help_text=_("Products included in this condition will only be " "available before this time."), ) limit = models.PositiveIntegerField( null=True, + blank=True, help_text=_("The number of items under this grouping that can be " "purchased."), ) From 96c4998a3486354e5c79331aceba5610d13e3250 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 28 Mar 2016 20:16:08 +1100 Subject: [PATCH 057/418] Initial Setuptools bits. --- requirements/base.txt | 2 ++ setup.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 requirements/base.txt create mode 100644 setup.py diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 00000000..33492418 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,2 @@ +django-nested-admin==2.1.8 +#-e git+https://github.com/pinax/symposion.git#egg=SymposionMaster # Needs Symposion diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..77082b55 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +import os +from setuptools import setup, find_packages + +import registrasion + + +def read_file(filename): + """Read a file into a string.""" + path = os.path.abspath(os.path.dirname(__file__)) + filepath = os.path.join(path, filename) + try: + return open(filepath).read() + except IOError: + return '' + + +setup( + name="registrasion", + author="Christopher Neugebauer", + author_email="_@chrisjrn.com", + version=registrasion.__version__, + description="A registration app for the Symposion conference management system.", + url="http://github.com/chrisjrn/registrasion/", + packages=find_packages(), + include_package_data=True, + classifiers=( + "Development Status :: 2 - Pre-Alpha", + "Programming Language :: Python", + "Framework :: Django", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + ), + install_requires=read_file("requirements/base.txt").splitlines(), +) From 9ec9e68ee684061e8e7eb0ae50d5181b11881fe6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 11:01:03 +1100 Subject: [PATCH 058/418] Registrasion URLs now include django-nested-admin --- registrasion/urls.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 132e7d78..0a6cd40e 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url, patterns +from django.conf.urls import url, include, patterns urlpatterns = patterns( "registrasion.views", @@ -10,4 +10,8 @@ urlpatterns = patterns( url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), + + + # Required by django-nested-admin. + url(r'^nested_admin/', include('nested_admin.urls')), ) From 62d5c5b2bf33a228938924a44e229f2f2cb4e02c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 11:10:17 +1100 Subject: [PATCH 059/418] Revert "Registrasion URLs now include django-nested-admin" This reverts commit 58eed33c429c1035801e840b41aa7104c02b9b5a. --- registrasion/urls.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 0a6cd40e..132e7d78 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url, include, patterns +from django.conf.urls import url, patterns urlpatterns = patterns( "registrasion.views", @@ -10,8 +10,4 @@ urlpatterns = patterns( url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), - - - # Required by django-nested-admin. - url(r'^nested_admin/', include('nested_admin.urls')), ) From e0e0d4bf3bb04978efcdc4af13f8e035cc669e14 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 15:44:20 +1100 Subject: [PATCH 060/418] views.py renders from registrasion/ --- registrasion/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 2a82db1d..d1c4d722 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -82,7 +82,7 @@ def edit_profile(request): data = { "form": form, } - return render(request, "profile_form.html", data) + return render(request, "registrasion/profile_form.html", data) @login_required @@ -126,7 +126,7 @@ def product_category(request, category_id): "voucher_form": voucher_form, } - return render(request, "product_category.html", data) + return render(request, "registrasion/product_category.html", data) def handle_products(request, category, products, prefix): @@ -251,7 +251,7 @@ def invoice(request, invoice_id): "invoice": current_invoice.invoice, } - return render(request, "invoice.html", data) + return render(request, "registrasion/invoice.html", data) @login_required From c6394ecf4a476cae4f6c9e0fa3c06367823d0f2e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 15:44:54 +1100 Subject: [PATCH 061/418] Increments version number of django_nested_admin so that you can use Django 1.9 --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 33492418..29d5ff62 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ -django-nested-admin==2.1.8 +django-nested-admin==2.2.6 #-e git+https://github.com/pinax/symposion.git#egg=SymposionMaster # Needs Symposion From 2d5caa329935adac10fa1b4345387273b76a73ab Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 16:01:59 +1100 Subject: [PATCH 062/418] Renames edit_profile to attendee_edit --- registrasion/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 132e7d78..0620dafb 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -6,7 +6,7 @@ urlpatterns = patterns( url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), - url(r"^profile$", "edit_profile", name="profile"), + url(r"^profile$", "edit_profile", name="attendee_edit"), url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", name="guided_registration"), From 5e0c87c435769d06727647b1805b3f1609b2be2b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 18:59:43 +1100 Subject: [PATCH 063/418] =?UTF-8?q?Deletes=20templates=20=E2=80=94=20moved?= =?UTF-8?q?=20to=20registrasion-demo=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/templates/invoice.html | 51 -------------------- registrasion/templates/product_category.html | 50 ------------------- registrasion/templates/profile_form.html | 22 --------- 3 files changed, 123 deletions(-) delete mode 100644 registrasion/templates/invoice.html delete mode 100644 registrasion/templates/product_category.html delete mode 100644 registrasion/templates/profile_form.html diff --git a/registrasion/templates/invoice.html b/registrasion/templates/invoice.html deleted file mode 100644 index bd503657..00000000 --- a/registrasion/templates/invoice.html +++ /dev/null @@ -1,51 +0,0 @@ - - -{% extends "site_base.html" %} -{% block body %} - -

Invoice {{ invoice.id }}

- -
    -
  • Void: {{ invoice.void }}
  • -
  • Paid: {{ invoice.paid }}
  • -
- - - - - - - - - {% for line_item in invoice.lineitem_set.all %} - - - - - - - {% endfor %} - - - - - - -
DescriptionQuantityPrice/UnitTotal
{{line_item.description}}{{line_item.quantity}}{{line_item.price}} FIXME
TOTAL{{ invoice.value }}
- - - - - - - - {% for payment in invoice.payment_set.all %} - - - - - - {% endfor %} -
Payment timeReferenceAmount
{{payment.time}}{{payment.reference}}{{payment.amount}}
- -{% endblock %} diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html deleted file mode 100644 index fb54a58d..00000000 --- a/registrasion/templates/product_category.html +++ /dev/null @@ -1,50 +0,0 @@ - - -{% extends "site_base.html" %} -{% block body %} - -

Product Category: {{ category.name }}

- -
- {% csrf_token %} - - - {{ voucher_form }} -
- -

- - {% if discounts %} -

Available Discounts

-
    - {% for discount in discounts %} -
  • {{ discount.quantity }} x - {% if discount.clause.percentage %} - {{ discount.clause.percentage|floatformat:"2" }}% - {% else %} - ${{ discount.clause.price|floatformat:"2" }} - {% endif %} - off - {% if discount.clause.category %} - {{ discount.clause.category }} - {% else %} - {{ discount.clause.product.category }} - - {{ discount.clause.product }} - {% endif %} -
  • - {% endfor %} -
- {% endif %} - -

Available Products

-

{{ category.description }}

- - {{ form }} -
- -

- -
- - -{% endblock %} diff --git a/registrasion/templates/profile_form.html b/registrasion/templates/profile_form.html deleted file mode 100644 index 56f3010c..00000000 --- a/registrasion/templates/profile_form.html +++ /dev/null @@ -1,22 +0,0 @@ - - -{% extends "site_base.html" %} -{% block body %} - -

Attendee Profile

- -

Something something fill in your attendee details here!

- -
- {% csrf_token %} - - - {{ form }} -
- - - -
- - -{% endblock %} From 8b796706089af952d47a871da4f2a581184ccce7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 31 Mar 2016 19:13:31 +1100 Subject: [PATCH 064/418] Fixes issue #8 --- registrasion/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index d1c4d722..b0f7093e 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -188,7 +188,10 @@ def handle_products(request, category, products, prefix): @transaction.atomic def set_quantities_from_products_form(products_form, current_cart): - for product_id, quantity, field_name in products_form.product_quantities(): + # TODO: issue #8 is a problem here. + quantities = list(products_form.product_quantities()) + quantities.sort(key=lambda item: item[1]) + for product_id, quantity, field_name in quantities: product = rego.Product.objects.get(pk=product_id) try: current_cart.set_quantity(product, quantity, batched=True) From 466c664b680ab6ae9008b3ef384901495fc3e12f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 11:41:59 +1100 Subject: [PATCH 065/418] factor out handle_profile --- registrasion/views.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index b0f7093e..a5db062a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -66,6 +66,16 @@ def guided_registration(request, page_id=0): @login_required def edit_profile(request): + form, handled = handle_profile(request, "profile") + + data = { + "form": form, + } + return render(request, "registrasion/profile_form.html", data) + +def handle_profile(request, prefix): + ''' Returns a profile form instance, and a boolean which is true if the + form was handled. ''' attendee = rego.Attendee.get_instance(request.user) try: @@ -73,17 +83,21 @@ def edit_profile(request): except ObjectDoesNotExist: profile = None - form = forms.ProfileForm(request.POST or None, instance=profile) + # TODO: pull down the speaker's real name from the Speaker profile + + form = forms.ProfileForm( + request.POST or None, + instance=profile, + prefix=prefix + ) + + handled = True if request.POST else False if request.POST and form.is_valid(): form.instance.attendee = attendee form.save() - data = { - "form": form, - } - return render(request, "registrasion/profile_form.html", data) - + return form, handled @login_required def product_category(request, category_id): From 8324b510947446d2d0adf03a2bf46fb2360c4575 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 11:43:19 +1100 Subject: [PATCH 066/418] Adds new guided registration process. --- registrasion/views.py | 137 ++++++++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 32 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index a5db062a..1d6eb260 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -5,7 +5,10 @@ from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.product import ProductController +from collections import namedtuple + from django.contrib.auth.decorators import login_required +from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.db import transaction @@ -13,6 +16,19 @@ from django.shortcuts import redirect from django.shortcuts import render +GuidedRegistrationSection = namedtuple( + "GuidedRegistrationSection", + ( + "title", + "discounts", + "description", + "form", + ) +) +GuidedRegistrationSection.__new__.__defaults__ = ( + (None,) * len(GuidedRegistrationSection._fields) +) + @login_required def guided_registration(request, page_id=0): ''' Goes through the registration process in order, @@ -26,42 +42,104 @@ def guided_registration(request, page_id=0): dashboard = redirect("dashboard") next_step = redirect("guided_registration") - attendee = rego.Attendee.get_instance(request.user) - if attendee.completed_registration: - return dashboard + sections = [] - # Step 1: Fill in a badge + attendee = rego.Attendee.get_instance(request.user) + + if attendee.completed_registration: + return render( + request, + "registrasion/guided_registration_complete.html", + {}, + ) + + # Step 1: Fill in a badge and collect a voucher code profile = rego.BadgeAndProfile.get_instance(attendee) if profile is None: - ret = edit_profile(request) - profile_new = rego.BadgeAndProfile.get_instance(attendee) - if profile_new is None: - # No new profile was created - return ret - else: + # TODO: if voucherform is invalid, make sure that profileform does not save + voucher_form, voucher_handled = handle_voucher(request, "voucher") + profile_form, profile_handled = handle_profile(request, "profile") + + voucher_section = GuidedRegistrationSection( + title="Voucher Code", + form=voucher_form, + ) + + profile_section = GuidedRegistrationSection( + title="Profile and Personal Information", + form=profile_form, + ) + + title = "Attendee information" + current_step = 1 + sections.append(voucher_section) + sections.append(profile_section) + else: + # We're selling products + + last_category = attendee.highest_complete_category + + # Get the next category + cats = rego.Category.objects + cats = cats.filter(id__gt=last_category).order_by("order") + + if cats.count() == 0: + # We've filled in every category + attendee.completed_registration = True + attendee.save() return next_step - # Step 2: Go through each of the categories in order - category = attendee.highest_complete_category + if last_category == 0: + # Only display the first Category + title = "Select ticket type" + current_step = 2 + cats = [cats[0]] + else: + # Set title appropriately for remaining categories + current_step = 3 + title = "Additional items" - # Get the next category - cats = rego.Category.objects - cats = cats.filter(id__gt=category).order_by("order") + for category in cats: + products = ProductController.available_products( + request.user, + category=category, + ) - if len(cats) == 0: - # We've filled in every category - attendee.completed_registration = True - attendee.save() - return dashboard + prefix = "category_" + str(category.id) + p = handle_products(request, category, products, prefix) + products_form, discounts, products_handled = p - ret = product_category(request, cats[0].id) - attendee_new = rego.Attendee.get_instance(request.user) - if attendee_new.highest_complete_category == category: - # We've not yet completed this category - return ret - else: - return next_step + section = GuidedRegistrationSection( + title=category.name, + description=category.description, + discounts=discounts, + form=products_form, + ) + sections.append(section) + + if request.method == "POST" and not products_form.errors: + if category.id > attendee.highest_complete_category: + # This is only saved if we pass each form with no errors. + attendee.highest_complete_category = category.id + + + if sections and request.method == "POST": + for section in sections: + if section.form.errors: + break + else: + attendee.save() + # We've successfully processed everything + return next_step + + data = { + "current_step": current_step, + "sections": sections, + "title" : title, + "total_steps": 3, + } + return render(request, "registrasion/guided_registration.html", data) @login_required @@ -126,11 +204,6 @@ def product_category(request, category_id): if request.POST and not voucher_handled and not products_form.errors: # Only return to the dashboard if we didn't add a voucher code # and if there's no errors in the products form - - attendee = rego.Attendee.get_instance(request.user) - if category_id > attendee.highest_complete_category: - attendee.highest_complete_category = category_id - attendee.save() return redirect("dashboard") data = { From aa6377f4ce88b2d4a713a5d0f492edc453739dac Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 14:58:29 +1100 Subject: [PATCH 067/418] Adds multiply as a template filter (for invoices) --- registrasion/templatetags/registrasion_tags.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index a583f1ea..b7ae5dd5 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -49,3 +49,8 @@ def items_purchased(context): quantity = pp.aggregate(Sum("quantity"))["quantity__sum"] out.append(ProductAndQuantity(product, quantity)) return out + +@register.filter +def multiply(value, arg): + ''' Multiplies value by arg ''' + return value * arg From d2d2a1b0ecddfeb59c62e64610449031aa81e4fd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 16:17:46 +1100 Subject: [PATCH 068/418] Work for making invoices contain complete profile information --- registrasion/models.py | 5 +++++ registrasion/views.py | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index c841ffae..d9ddfc1e 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -57,6 +57,11 @@ class BadgeAndProfile(models.Model): except ObjectDoesNotExist: return None + def save(self): + if not self.name_per_invoice: + self.name_per_invoice = self.name + super(BadgeAndProfile, self).save() + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) # Things that appear on badge diff --git a/registrasion/views.py b/registrasion/views.py index 1d6eb260..230227c7 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -12,6 +12,7 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.db import transaction +from django.http import Http404 from django.shortcuts import redirect from django.shortcuts import render @@ -335,6 +336,10 @@ def invoice(request, invoice_id): invoice_id = int(invoice_id) inv = rego.Invoice.objects.get(pk=invoice_id) + + if request.user != inv.cart.user and not request.user.is_staff: + raise Http404() + current_invoice = InvoiceController(inv) data = { @@ -350,11 +355,10 @@ def pay_invoice(request, invoice_id): WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. ''' - invoice_id = int(invoice_id) inv = rego.Invoice.objects.get(pk=invoice_id) current_invoice = InvoiceController(inv) - if not inv.paid and current_invoice.is_valid(): + if not current_invoice.invoice.paid and not current_invoice.invoice.void: current_invoice.pay("Demo invoice payment", inv.value) return redirect("invoice", current_invoice.invoice.id) From 660e8cb75f6753f0bf2b0062a05d5e67c06ae298 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 16:58:55 +1100 Subject: [PATCH 069/418] Removes BadgeAndProfile.get_instance --- registrasion/models.py | 9 --------- registrasion/views.py | 9 ++++++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index d9ddfc1e..92babf1b 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -48,15 +48,6 @@ class BadgeAndProfile(models.Model): def __str__(self): return "Badge for: %s of %s" % (self.name, self.company) - @staticmethod - def get_instance(attendee): - ''' Returns either None, or the instance that belongs - to this attendee. ''' - try: - return BadgeAndProfile.objects.get(attendee=attendee) - except ObjectDoesNotExist: - return None - def save(self): if not self.name_per_invoice: self.name_per_invoice = self.name diff --git a/registrasion/views.py b/registrasion/views.py index 230227c7..1539c846 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -55,9 +55,12 @@ def guided_registration(request, page_id=0): ) # Step 1: Fill in a badge and collect a voucher code - profile = rego.BadgeAndProfile.get_instance(attendee) + try: + profile = attendee.badgeandprofile + except ObjectDoesNotExist: + profile = None - if profile is None: + if not profile: # TODO: if voucherform is invalid, make sure that profileform does not save voucher_form, voucher_handled = handle_voucher(request, "voucher") profile_form, profile_handled = handle_profile(request, "profile") @@ -158,7 +161,7 @@ def handle_profile(request, prefix): attendee = rego.Attendee.get_instance(request.user) try: - profile = rego.BadgeAndProfile.objects.get(attendee=attendee) + profile = attendee.badgeandprofile except ObjectDoesNotExist: profile = None From be277c17d22d7827b4b3aee743552f594e765886 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 21:21:09 +1100 Subject: [PATCH 070/418] =?UTF-8?q?BadgeAndProfile=20is=20replaced=20with?= =?UTF-8?q?=20AttendeeProfileBase=20=E2=80=94=20consumer=20apps=20should?= =?UTF-8?q?=20subclass=20AttendeeProfileBase=20to=20make=20the=20registrat?= =?UTF-8?q?ion=20process=20work=20:)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/forms.py | 8 --- .../migrations/0011_auto_20160401_0943.py | 30 ++++++++ registrasion/models.py | 69 ++----------------- registrasion/views.py | 17 ++++- 4 files changed, 49 insertions(+), 75 deletions(-) create mode 100644 registrasion/migrations/0011_auto_20160401_0943.py diff --git a/registrasion/forms.py b/registrasion/forms.py index eb6f2949..f1527aa5 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -132,14 +132,6 @@ def ProductsForm(category, products): return ProductsForm -class ProfileForm(forms.ModelForm): - ''' A form for requesting badge and profile information. ''' - - class Meta: - model = rego.BadgeAndProfile - exclude = ['attendee'] - - class VoucherForm(forms.Form): voucher = forms.CharField( label="Voucher code", diff --git a/registrasion/migrations/0011_auto_20160401_0943.py b/registrasion/migrations/0011_auto_20160401_0943.py new file mode 100644 index 00000000..a8b2deac --- /dev/null +++ b/registrasion/migrations/0011_auto_20160401_0943.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-01 09:43 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0010_auto_20160330_2342'), + ] + + operations = [ + 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.RemoveField( + model_name='badgeandprofile', + name='attendee', + ), + migrations.DeleteModel( + name='BadgeAndProfile', + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 92babf1b..1e7d04c2 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -41,73 +41,14 @@ class Attendee(models.Model): highest_complete_category = models.IntegerField(default=0) -@python_2_unicode_compatible -class BadgeAndProfile(models.Model): - ''' Information for an attendee's badge and related preferences ''' - - def __str__(self): - return "Badge for: %s of %s" % (self.name, self.company) - - def save(self): - if not self.name_per_invoice: - self.name_per_invoice = self.name - super(BadgeAndProfile, self).save() +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. + ''' attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) - # Things that appear on badge - name = models.CharField( - verbose_name="Your name (for your conference nametag)", - max_length=64, - help_text="Your name, as you'd like it to appear on your badge. ", - ) - company = models.CharField( - max_length=64, - help_text="The name of your company, as you'd like it on your badge", - blank=True, - ) - free_text_1 = models.CharField( - max_length=64, - verbose_name="Free text line 1", - help_text="A line of free text that will appear on your badge. Use " - "this for your Twitter handle, IRC nick, your preferred " - "pronouns or anything else you'd like people to see on " - "your badge.", - blank=True, - ) - free_text_2 = models.CharField( - max_length=64, - verbose_name="Free text line 2", - blank=True, - ) - - # Other important Information - name_per_invoice = models.CharField( - verbose_name="Your legal name (for invoicing purposes)", - max_length=64, - help_text="If your legal name is different to the name on your badge, " - "fill this in, and we'll put it on your invoice. Otherwise, " - "leave it blank.", - blank=True, - ) - of_legal_age = models.BooleanField( - default=False, - verbose_name="18+?", - blank=True, - ) - dietary_requirements = models.CharField( - max_length=256, - blank=True, - ) - accessibility_requirements = models.CharField( - max_length=256, - blank=True, - ) - gender = models.CharField( - max_length=64, - blank=True, - ) - # Inventory Models diff --git a/registrasion/views.py b/registrasion/views.py index 1539c846..9c109ae9 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,3 +1,5 @@ +import sys + from registrasion import forms from registrasion import models as rego from registrasion.controllers import discount @@ -7,6 +9,7 @@ from registrasion.controllers.product import ProductController from collections import namedtuple +from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist @@ -30,6 +33,12 @@ GuidedRegistrationSection.__new__.__defaults__ = ( (None,) * len(GuidedRegistrationSection._fields) ) +def get_form(name): + dot = name.rindex(".") + mod_name, form_name = name[:dot], name[dot + 1:] + __import__(mod_name) + return getattr(sys.modules[mod_name], form_name) + @login_required def guided_registration(request, page_id=0): ''' Goes through the registration process in order, @@ -56,7 +65,7 @@ def guided_registration(request, page_id=0): # Step 1: Fill in a badge and collect a voucher code try: - profile = attendee.badgeandprofile + profile = attendee.attendeeprofilebase except ObjectDoesNotExist: profile = None @@ -161,13 +170,15 @@ def handle_profile(request, prefix): attendee = rego.Attendee.get_instance(request.user) try: - profile = attendee.badgeandprofile + profile = attendee.attendeeprofilebase except ObjectDoesNotExist: profile = None # TODO: pull down the speaker's real name from the Speaker profile - form = forms.ProfileForm( + ProfileForm = get_form(settings.ATTENDEE_PROFILE_FORM) + + form = ProfileForm( request.POST or None, instance=profile, prefix=prefix From 89cba55807c19026563297d48803c3021d0bb2f2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 21:39:54 +1100 Subject: [PATCH 071/418] Pre-fills the attendee name from a speaker profile, if there is one. Resolves #8. --- registrasion/models.py | 6 ++++++ registrasion/views.py | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index 1e7d04c2..80e0141c 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -47,6 +47,12 @@ class AttendeeProfileBase(models.Model): registration progess. ''' + @classmethod + def name_field(cls): + ''' This is used to pre-fill the attendee's name from the + speaker profile. If it's None, that functionality is disabled. ''' + return None + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) diff --git a/registrasion/views.py b/registrasion/views.py index 9c109ae9..edd619ff 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,3 +1,4 @@ +import symposion.speakers import sys from registrasion import forms @@ -174,12 +175,25 @@ def handle_profile(request, prefix): except ObjectDoesNotExist: profile = None - # TODO: pull down the speaker's real name from the Speaker profile - ProfileForm = get_form(settings.ATTENDEE_PROFILE_FORM) + # Load a pre-entered name from the speaker's profile, + # if they have one. + try: + speaker_profile = request.user.speaker_profile + speaker_name = speaker_profile.name + except ObjectDoesNotExist: + speaker_name = None + + + name_field = ProfileForm.Meta.model.name_field() + initial = {} + if name_field is not None: + initial[name_field] = speaker_name + form = ProfileForm( request.POST or None, + initial=initial, instance=profile, prefix=prefix ) From 12e4d0a3cb24f27a35c0e5a3f23c8483b936348b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 12:14:39 +0100 Subject: [PATCH 072/418] flake8 --- registrasion/models.py | 1 - .../templatetags/registrasion_tags.py | 1 + registrasion/tests/test_discount.py | 20 +++++++++++++++---- registrasion/tests/test_refund.py | 5 ----- registrasion/views.py | 13 ++++++------ setup.py | 3 ++- 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index 80e0141c..8d9a7127 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -4,7 +4,6 @@ import datetime import itertools from django.core.exceptions import ValidationError -from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.db import models from django.db.models import F, Q diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index b7ae5dd5..ac9fa0ca 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -50,6 +50,7 @@ def items_purchased(context): out.append(ProductAndQuantity(product, quantity)) return out + @register.filter def multiply(value, arg): ''' Multiplies value by arg ''' diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 5f536e9b..f2eb36bb 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -382,22 +382,34 @@ class DiscountTestCase(RegistrationCartTestCase): self.add_discount_prod_1_includes_prod_2(quantity=2) cart = CartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2]) + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_2], + ) self.assertEqual(1, len(discounts)) cart.cart.active = False # Keep discount enabled cart.cart.save() cart = CartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted + cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted cart.cart.active = False cart.cart.save() - discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2]) + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_2], + ) self.assertEqual(0, len(discounts)) cart.cart.released = True cart.cart.save() - discounts = discount.available_discounts(self.USER_1, [], [self.PROD_2]) + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_2], + ) self.assertEqual(1, len(discounts)) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 14811b78..0fe0648f 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,10 +1,5 @@ -import datetime import pytz -from decimal import Decimal -from django.core.exceptions import ValidationError - -from registrasion import models as rego from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController diff --git a/registrasion/views.py b/registrasion/views.py index edd619ff..16733840 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,4 +1,3 @@ -import symposion.speakers import sys from registrasion import forms @@ -34,12 +33,14 @@ GuidedRegistrationSection.__new__.__defaults__ = ( (None,) * len(GuidedRegistrationSection._fields) ) + def get_form(name): dot = name.rindex(".") mod_name, form_name = name[:dot], name[dot + 1:] __import__(mod_name) return getattr(sys.modules[mod_name], form_name) + @login_required def guided_registration(request, page_id=0): ''' Goes through the registration process in order, @@ -50,7 +51,6 @@ def guided_registration(request, page_id=0): through each category one by one ''' - dashboard = redirect("dashboard") next_step = redirect("guided_registration") sections = [] @@ -71,7 +71,8 @@ def guided_registration(request, page_id=0): profile = None if not profile: - # TODO: if voucherform is invalid, make sure that profileform does not save + # TODO: if voucherform is invalid, make sure + # that profileform does not save voucher_form, voucher_handled = handle_voucher(request, "voucher") profile_form, profile_handled = handle_profile(request, "profile") @@ -137,7 +138,6 @@ def guided_registration(request, page_id=0): # This is only saved if we pass each form with no errors. attendee.highest_complete_category = category.id - if sections and request.method == "POST": for section in sections: if section.form.errors: @@ -150,7 +150,7 @@ def guided_registration(request, page_id=0): data = { "current_step": current_step, "sections": sections, - "title" : title, + "title": title, "total_steps": 3, } return render(request, "registrasion/guided_registration.html", data) @@ -165,6 +165,7 @@ def edit_profile(request): } return render(request, "registrasion/profile_form.html", data) + def handle_profile(request, prefix): ''' Returns a profile form instance, and a boolean which is true if the form was handled. ''' @@ -185,7 +186,6 @@ def handle_profile(request, prefix): except ObjectDoesNotExist: speaker_name = None - name_field = ProfileForm.Meta.model.name_field() initial = {} if name_field is not None: @@ -206,6 +206,7 @@ def handle_profile(request, prefix): return form, handled + @login_required def product_category(request, category_id): ''' Registration selections form for a specific category of items. diff --git a/setup.py b/setup.py index 77082b55..6686b6fb 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,8 @@ setup( author="Christopher Neugebauer", author_email="_@chrisjrn.com", version=registrasion.__version__, - description="A registration app for the Symposion conference management system.", + description="A registration app for the Symposion conference management " + "system.", url="http://github.com/chrisjrn/registrasion/", packages=find_packages(), include_package_data=True, From 3a6b4125e974c4444dd9815707f50e384a57112f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 1 Apr 2016 12:34:06 +0100 Subject: [PATCH 073/418] Bugfix --- registrasion/models.py | 2 ++ registrasion/views.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/registrasion/models.py b/registrasion/models.py index 8d9a7127..1e0e23de 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -46,6 +46,8 @@ class AttendeeProfileBase(models.Model): registration progess. ''' + objects = InheritanceManager() + @classmethod def name_field(cls): ''' This is used to pre-fill the attendee's name from the diff --git a/registrasion/views.py b/registrasion/views.py index 16733840..e443dc10 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -173,6 +173,7 @@ def handle_profile(request, prefix): try: profile = attendee.attendeeprofilebase + profile = rego.AttendeeProfileBase.objects.get_subclass(pk=profile.id) except ObjectDoesNotExist: profile = None @@ -188,7 +189,7 @@ def handle_profile(request, prefix): name_field = ProfileForm.Meta.model.name_field() initial = {} - if name_field is not None: + if profile is None and name_field is not None: initial[name_field] = speaker_name form = ProfileForm( From 69a65ac3ed90fa32cd15a1c25ccd5afc24293424 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 11:02:01 +1100 Subject: [PATCH 074/418] Fixes tests on Django 1.9 --- registrasion/controllers/conditions.py | 18 +++++++++++------- registrasion/controllers/product.py | 7 ++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 20320218..a176522d 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -46,10 +46,12 @@ class CategoryConditionController(ConditionController): carts = rego.Cart.objects.filter(user=user, released=False) enabling_products = rego.Product.objects.filter( - category=self.condition.enabling_category) + category=self.condition.enabling_category, + ) products = rego.ProductItem.objects.filter( cart=carts, - product=enabling_products) + product__in=enabling_products, + ) return len(products) > 0 @@ -67,7 +69,8 @@ class ProductConditionController(ConditionController): carts = rego.Cart.objects.filter(user=user, released=False) products = rego.ProductItem.objects.filter( cart=carts, - product=self.condition.enabling_products.all()) + product__in=self.condition.enabling_products.all(), + ) return len(products) > 0 @@ -111,12 +114,12 @@ class TimeOrStockLimitConditionController(ConditionController): list products differently to discounts. ''' if isinstance(self.ceiling, rego.TimeOrStockLimitEnablingCondition): category_products = rego.Product.objects.filter( - category=self.ceiling.categories.all() + category=self.ceiling.categories.all(), ) return self.ceiling.products.all() | category_products else: categories = rego.Category.objects.filter( - discountforcategory__discount=self.ceiling + discountforcategory__discount=self.ceiling, ) return rego.Product.objects.filter( Q(discountforproduct__discount=self.ceiling) | @@ -129,7 +132,7 @@ class TimeOrStockLimitConditionController(ConditionController): reserved_carts = rego.Cart.reserved_carts() product_items = rego.ProductItem.objects.filter( - product=self._products().all() + product__in=self._products().all(), ) product_items = product_items.filter(cart=reserved_carts) @@ -154,5 +157,6 @@ class VoucherConditionController(ConditionController): ''' returns True if the user has the given voucher attached. ''' carts = rego.Cart.objects.filter( user=user, - vouchers=self.condition.voucher) + vouchers=self.condition.voucher, + ) return len(carts) > 0 diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 88beb8f8..235031e8 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -43,7 +43,7 @@ class ProductController(object): carts = rego.Cart.objects.filter(user=user) items = rego.ProductItem.objects.filter( - cart=carts, + cart__in=carts, ) prod_items = items.filter(product=self.product) @@ -52,6 +52,11 @@ class ProductController(object): prod_count = prod_items.aggregate(Sum("quantity"))["quantity__sum"] cat_count = cat_items.aggregate(Sum("quantity"))["quantity__sum"] + if prod_count == None: + prod_count = 0 + if cat_count == None: + cat_count = 0 + prod_limit = self.product.limit_per_user prod_met = prod_limit is None or quantity + prod_count <= prod_limit From 26af6e86720f9721c907ec2c6c7ffc0d2f5cd55c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 11:33:20 +1100 Subject: [PATCH 075/418] Adds messages when items are updated; disables product forms when there are no products available. --- registrasion/views.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/registrasion/views.py b/registrasion/views.py index e443dc10..812689f7 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -121,6 +121,10 @@ def guided_registration(request, page_id=0): category=category, ) + if not products: + # This product category does not exist for this user + continue + prefix = "category_" + str(category.id) p = handle_products(request, category, products, prefix) products_form, discounts, products_handled = p @@ -160,6 +164,13 @@ def guided_registration(request, page_id=0): def edit_profile(request): form, handled = handle_profile(request, "profile") + if handled and not form.errors: + messages.success( + request, + "Your attendee profile was updated.", + ) + return redirect("dashboard") + data = { "form": form, } @@ -229,12 +240,23 @@ def product_category(request, category_id): category=category, ) + if not products: + messages.warning( + request, + "There are no products available from category: "+ category.name, + ) + return redirect("dashboard") + p = handle_products(request, category, products, PRODUCTS_FORM_PREFIX) products_form, discounts, products_handled = p if request.POST and not voucher_handled and not products_form.errors: # Only return to the dashboard if we didn't add a voucher code # and if there's no errors in the products form + messages.success( + request, + "Your reservations have been updated.", + ) return redirect("dashboard") data = { From bdd3714f4751e4ab57615a6f9b84308157d1101b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 11:34:19 +1100 Subject: [PATCH 076/418] flake8 style issue --- registrasion/controllers/product.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 235031e8..caeb0d54 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -52,9 +52,9 @@ class ProductController(object): prod_count = prod_items.aggregate(Sum("quantity"))["quantity__sum"] cat_count = cat_items.aggregate(Sum("quantity"))["quantity__sum"] - if prod_count == None: + if prod_count is None: prod_count = 0 - if cat_count == None: + if cat_count is None: cat_count = 0 prod_limit = self.product.limit_per_user From f7289c21019b87efa069beffba201701f9179c1f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 11:56:03 +1100 Subject: [PATCH 077/418] =?UTF-8?q?Adds=20=E2=80=98available=5Fcategories?= =?UTF-8?q?=E2=80=99=20as=20something=20that=20actually=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/controllers/category.py | 26 +++++++++++++++++++ .../templatetags/registrasion_tags.py | 3 ++- registrasion/tests/test_enabling_condition.py | 22 ++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 registrasion/controllers/category.py diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py new file mode 100644 index 00000000..53fe2872 --- /dev/null +++ b/registrasion/controllers/category.py @@ -0,0 +1,26 @@ +from .product import ProductController + +from registrasion import models as rego + +class AllProducts(object): + pass + +class CategoryController(object): + + @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. ''' + + if products is AllProducts: + products = rego.Product.objects.all() + + available = ProductController.available_products( + user, + products=products, + ) + + print available + + return set(i.category for i in available) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index ac9fa0ca..6de13e74 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -1,4 +1,5 @@ from registrasion import models as rego +from registrasion.controllers.category import CategoryController from collections import namedtuple from django import template @@ -12,7 +13,7 @@ ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) @register.assignment_tag(takes_context=True) def available_categories(context): ''' Returns all of the available product categories ''' - return rego.Category.objects.all() + return CategoryController.available_categories(context.request.user) @register.assignment_tag(takes_context=True) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 2861e8db..aeec4708 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -3,6 +3,7 @@ import pytz from django.core.exceptions import ValidationError from registrasion import models as rego +from registrasion.controllers.category import CategoryController from registrasion.controllers.cart import CartController from registrasion.controllers.product import ProductController @@ -271,3 +272,24 @@ class EnablingConditionTestCases(RegistrationCartTestCase): with self.assertRaises(ValidationError): cart_2.set_quantity(self.PROD_1, 1) + + def test_available_categories(self): + self.add_product_enabling_condition_on_category(mandatory=False) + + cart_1 = CartController.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) From 8f233c79430f6ee024cba7f7ef2cc422af8879cf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 13:14:34 +1100 Subject: [PATCH 078/418] =?UTF-8?q?available=5Fproducts=20now=20refers=20t?= =?UTF-8?q?o=20the=20user=E2=80=99s=20product=20limits=20as=20well=20as=20?= =?UTF-8?q?enabling=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/controllers/category.py | 2 -- registrasion/controllers/product.py | 11 +++++++++-- registrasion/tests/test_cart.py | 29 ++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 53fe2872..7bd08283 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -21,6 +21,4 @@ class CategoryController(object): products=products, ) - print available - return set(i.category for i in available) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index caeb0d54..abb5947d 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -32,16 +32,23 @@ class ProductController(object): out = [ product for product in all_products + if cls(product).user_can_add_within_limit(user, 1, past_carts=True) if cls(product).can_add_with_enabling_conditions(user, 0) ] out.sort(key=lambda product: product.order) return out - def user_can_add_within_limit(self, user, quantity): + def user_can_add_within_limit(self, user, quantity, past_carts=False): ''' Return true if the user is able to add _quantity_ to their count of this Product without exceeding _limit_per_user_.''' - carts = rego.Cart.objects.filter(user=user) + carts = rego.Cart.objects.filter( + user=user, + released=False, + ) + if past_carts: + carts = carts.filter(active=False) + items = rego.ProductItem.objects.filter( cart__in=carts, ) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 03d31b54..7c51c131 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -9,6 +9,7 @@ from django.test import TestCase from registrasion import models as rego from registrasion.controllers.cart import CartController +from registrasion.controllers.product import ProductController from patch_datetime import SetTimeMixin @@ -288,3 +289,31 @@ class BasicCartTests(RegistrationCartTestCase): with self.assertRaises(ValidationError): current_cart.set_quantity(self.PROD_4, 1) + + def __available_products_test(self, item, quantity): + self.set_limits() + + get_prods = lambda: ProductController.available_products( + self.USER_1, + products=[self.PROD_2, self.PROD_3, self.PROD_4], + ) + + current_cart = CartController.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.cart.active = False + current_cart.cart.save() + + current_cart = CartController.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) From 2e0144effe145d4b1b42cec047198c0c20be3460 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 13:29:53 +1100 Subject: [PATCH 079/418] flake8 --- registrasion/controllers/category.py | 2 ++ registrasion/tests/test_cart.py | 9 +++++---- registrasion/views.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 7bd08283..5a6b4a68 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -2,9 +2,11 @@ from .product import ProductController from registrasion import models as rego + class AllProducts(object): pass + class CategoryController(object): @classmethod diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 7c51c131..bc58d5fb 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -293,10 +293,11 @@ class BasicCartTests(RegistrationCartTestCase): def __available_products_test(self, item, quantity): self.set_limits() - get_prods = lambda: ProductController.available_products( - self.USER_1, - products=[self.PROD_2, self.PROD_3, self.PROD_4], - ) + def get_prods(): + return ProductController.available_products( + self.USER_1, + products=[self.PROD_2, self.PROD_3, self.PROD_4], + ) current_cart = CartController.for_user(self.USER_1) prods = get_prods() diff --git a/registrasion/views.py b/registrasion/views.py index 812689f7..2485b438 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -243,7 +243,7 @@ def product_category(request, category_id): if not products: messages.warning( request, - "There are no products available from category: "+ category.name, + "There are no products available from category: " + category.name, ) return redirect("dashboard") From 39021cd3dd3e03047ba602ec16822eff52759c89 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 13:39:42 +1100 Subject: [PATCH 080/418] Adds set_quantities, refactors set_quantity in terms of set_quantities --- registrasion/controllers/cart.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 45796781..aec77c07 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -3,6 +3,7 @@ import discount from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError +from django.db import transaction from django.db.models import Max from django.utils import timezone @@ -57,17 +58,40 @@ class CartController(object): def end_batch(self): ''' Performs operations that occur occur at the end of a batch of - product changes/voucher applications etc. ''' + product changes/voucher applications etc. + THIS SHOULD BE PRIVATE + ''' + self.recalculate_discounts() self.extend_reservation() self.cart.revision += 1 self.cart.save() + @transaction.atomic + def set_quantities(self, product_quantities): + + # Remove all items that we're updating + rego.ProductItem.objects.filter( + cart=self.cart, + product__in=(i[0] for i in product_quantities), + ).delete() + + for product, quantity in product_quantities: + self._set_quantity_old(product, quantity) + + self.end_batch() + 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 _set_quantity_old(self, product, quantity): + ''' Sets the _quantity_ of the given _product_ in the cart to the given + _quantity_. ''' + if quantity < 0: raise ValidationError("Cannot have fewer than 0 items in cart.") @@ -106,9 +130,6 @@ class CartController(object): product_item.quantity = quantity product_item.save() - if not batched: - self.end_batch() - def add_to_cart(self, product, quantity): ''' Adds _quantity_ of the given _product_ to the cart. Raises ValidationError if constraints are violated.''' From 576dddcaad19e66ae55b693444ef45194836d24e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 14:03:25 +1100 Subject: [PATCH 081/418] Adds user_quantity_remaining to CategoryController --- registrasion/controllers/cart.py | 38 +++++++++++++++++++++++--- registrasion/controllers/category.py | 40 ++++++++++++++++++++++++++-- registrasion/controllers/product.py | 15 +++-------- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index aec77c07..f3bcabf1 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,14 +1,16 @@ +import collections import datetime import discount 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 Max, Sum from django.utils import timezone from registrasion import models as rego +from category import CategoryController from conditions import ConditionController from product import ProductController @@ -71,12 +73,40 @@ class CartController(object): @transaction.atomic def set_quantities(self, product_quantities): + items_in_cart = rego.ProductItem.objects.filter(cart=self.cart) + # Remove all items that we're updating - rego.ProductItem.objects.filter( - cart=self.cart, + items_in_cart.filter( product__in=(i[0] for i in product_quantities), ).delete() + # Collect by category + by_cat = collections.defaultdict(list) + for product, quantity in product_quantities: + by_cat[product.category].append((product, quantity)) + + # Test each category limit here + for cat in by_cat: + ctrl = CategoryController(cat) + limit = ctrl.user_quantity_remaining(self.cart.user) + + # Get the amount so far in the cart + cat_items = items_in_cart.filter(product__category=cat) + so_far = cat_items.aggregate(Sum("quantity"))["quantity__sum"] or 0 + to_add = sum(i[1] for i in by_cat[cat]) + + if so_far + to_add > limit: + # TODO: batch errors + raise ValidationError( + "You may only have %d items in category: %s" % ( + limit, cat.name, + ) + ) + + # Test each product limit here + + # Test each enabling condition here + for product, quantity in product_quantities: self._set_quantity_old(product, quantity) @@ -86,7 +116,7 @@ class CartController(object): ''' Sets the _quantity_ of the given _product_ in the cart to the given _quantity_. ''' - self.set_quantities( ((product,quantity),) ) + self.set_quantities(((product, quantity),)) def _set_quantity_old(self, product, quantity): ''' Sets the _quantity_ of the given _product_ in the cart to the given diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 5a6b4a68..7f244263 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -1,7 +1,7 @@ -from .product import ProductController - from registrasion import models as rego +from django.db.models import Sum + class AllProducts(object): pass @@ -9,12 +9,18 @@ class AllProducts(object): 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 product import ProductController + if products is AllProducts: products = rego.Product.objects.all() @@ -24,3 +30,33 @@ class CategoryController(object): ) return set(i.category for i in available) + + def user_quantity_remaining(self, user): + ''' Returns the number of items from this category that the user may + add in the current cart. ''' + + cat_limit = self.category.limit_per_user + + if cat_limit is None: + # We don't need to waste the following queries + return 99999999 + + carts = rego.Cart.objects.filter( + user=user, + active=False, + released=False, + ) + + items = rego.ProductItem.objects.filter( + cart__in=carts, + product__category=self.category, + ) + + cat_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 + + cat_limit = self.category.limit_per_user + + if cat_limit is None: + return 999999 # We should probably work on this. + else: + return cat_limit - cat_count diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index abb5947d..f92ecb10 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -4,6 +4,7 @@ from django.db.models import Q from django.db.models import Sum from registrasion import models as rego +from category import CategoryController from conditions import ConditionController @@ -32,6 +33,7 @@ class ProductController(object): out = [ product for product in all_products + if CategoryController(product.category).user_quantity_remaining(user) > 0 if cls(product).user_can_add_within_limit(user, 1, past_carts=True) if cls(product).can_add_with_enabling_conditions(user, 0) ] @@ -54,23 +56,14 @@ class ProductController(object): ) prod_items = items.filter(product=self.product) - cat_items = items.filter(product__category=self.product.category) prod_count = prod_items.aggregate(Sum("quantity"))["quantity__sum"] - cat_count = cat_items.aggregate(Sum("quantity"))["quantity__sum"] - - if prod_count is None: - prod_count = 0 - if cat_count is None: - cat_count = 0 + prod_count = prod_count or 0 prod_limit = self.product.limit_per_user prod_met = prod_limit is None or quantity + prod_count <= prod_limit - cat_limit = self.product.category.limit_per_user - cat_met = cat_limit is None or quantity + cat_count <= cat_limit - - if prod_met and cat_met: + if prod_met: return True else: return False From 1c6dc1278147acfd7b9b322be134197b5707ca11 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 14:20:43 +1100 Subject: [PATCH 082/418] Replaces user_can_add_within_limit with user_quantity_remaining --- registrasion/controllers/cart.py | 15 +++++++++++--- registrasion/controllers/product.py | 31 +++++++++++++---------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index f3bcabf1..98fb9ba8 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -70,6 +70,7 @@ class CartController(object): self.cart.revision += 1 self.cart.save() + @transaction.atomic def set_quantities(self, product_quantities): @@ -104,6 +105,17 @@ class CartController(object): ) # Test each product limit here + for product, quantity in product_quantities: + prod = ProductController(product) + limit = prod.user_quantity_remaining(self.cart.user) + + if quantity > limit: + # TODO: batch errors + raise ValidationError( + "You may only have %d of product: %s" % ( + limit, cat.name, + ) + ) # Test each enabling condition here @@ -154,9 +166,6 @@ class CartController(object): self.cart.user, adjustment): raise ValidationError("Not enough of that product left (ec)") - if not prod.user_can_add_within_limit(self.cart.user, adjustment): - raise ValidationError("Not enough of that product left (user)") - product_item.quantity = quantity product_item.save() diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index f92ecb10..929f7290 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -34,39 +34,36 @@ class ProductController(object): product for product in all_products if CategoryController(product.category).user_quantity_remaining(user) > 0 - if cls(product).user_can_add_within_limit(user, 1, past_carts=True) + if cls(product).user_quantity_remaining(user) > 0 if cls(product).can_add_with_enabling_conditions(user, 0) ] out.sort(key=lambda product: product.order) return out - def user_can_add_within_limit(self, user, quantity, past_carts=False): - ''' Return true if the user is able to add _quantity_ to their count of - this Product without exceeding _limit_per_user_.''' + def user_quantity_remaining(self, user): + ''' Returns the quantity of this product that the user add in the + current cart. ''' + + prod_limit = self.product.limit_per_user + + if prod_limit is None: + # Don't need to run the remaining queries + return 999999 # We can do better carts = rego.Cart.objects.filter( user=user, + active=False, released=False, ) - if past_carts: - carts = carts.filter(active=False) items = rego.ProductItem.objects.filter( cart__in=carts, + product=self.product, ) - prod_items = items.filter(product=self.product) + prod_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - prod_count = prod_items.aggregate(Sum("quantity"))["quantity__sum"] - prod_count = prod_count or 0 - - prod_limit = self.product.limit_per_user - prod_met = prod_limit is None or quantity + prod_count <= prod_limit - - if prod_met: - return True - else: - return False + return prod_limit - prod_count def can_add_with_enabling_conditions(self, user, quantity): ''' Returns true if the user is able to add _quantity_ to their count From 5716af0afa77139dec6148e62f66d594e45d8994 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 15:56:27 +1100 Subject: [PATCH 083/418] Replaces a bunch of len(queryset) with queryset.count() --- registrasion/controllers/conditions.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index a176522d..ebd43df9 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -48,11 +48,11 @@ class CategoryConditionController(ConditionController): enabling_products = rego.Product.objects.filter( category=self.condition.enabling_category, ) - products = rego.ProductItem.objects.filter( + products_count = rego.ProductItem.objects.filter( cart=carts, product__in=enabling_products, - ) - return len(products) > 0 + ).count() + return products_count > 0 class ProductConditionController(ConditionController): @@ -67,11 +67,11 @@ class ProductConditionController(ConditionController): condition in one of their carts ''' carts = rego.Cart.objects.filter(user=user, released=False) - products = rego.ProductItem.objects.filter( + products_count = rego.ProductItem.objects.filter( cart=carts, product__in=self.condition.enabling_products.all(), - ) - return len(products) > 0 + ).count() + return products_count > 0 class TimeOrStockLimitConditionController(ConditionController): @@ -155,8 +155,8 @@ class VoucherConditionController(ConditionController): def is_met(self, user, quantity): ''' returns True if the user has the given voucher attached. ''' - carts = rego.Cart.objects.filter( + carts_count = rego.Cart.objects.filter( user=user, vouchers=self.condition.voucher, - ) - return len(carts) > 0 + ).count() + return carts_count > 0 From 1e7a2abc7f9292328593a5325313ed12bad32ad4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 16:26:27 +1100 Subject: [PATCH 084/418] Refactors testing of enabling conditions so that they are done in bulk in ConditionsController, rather than one product at a time. --- registrasion/controllers/cart.py | 17 ++- registrasion/controllers/conditions.py | 167 ++++++++++++++++++++----- registrasion/controllers/discount.py | 2 +- registrasion/controllers/product.py | 2 +- 4 files changed, 151 insertions(+), 37 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 98fb9ba8..f62e134b 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -117,7 +117,18 @@ class CartController(object): ) ) + product_quantities_all = list(product_quantities) + [ + (i.product, i.quantity) for i in items_in_cart.all() + ] + # Test each enabling condition here + errs = ConditionController.test_enabling_conditions( + self.cart.user, + product_quantities=product_quantities_all, + ) + + if errs: + raise ValidationError("Whoops") for product, quantity in product_quantities: self._set_quantity_old(product, quantity) @@ -162,10 +173,6 @@ class CartController(object): adjustment = quantity - old_quantity prod = ProductController(product) - if not prod.can_add_with_enabling_conditions( - self.cart.user, adjustment): - raise ValidationError("Not enough of that product left (ec)") - product_item.quantity = quantity product_item.save() @@ -245,7 +252,7 @@ class CartController(object): quantity = 0 if is_reserved else discount_item.quantity - if not cond.is_met(self.cart.user, quantity): + if not cond.is_met(self.cart.user): # TODO: REPLACE WITH QUANTITY CHECKER WHEN FIXING CEILINGS raise ValidationError("Discounts are no longer available") def recalculate_discounts(self): diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index ebd43df9..63362a28 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,3 +1,8 @@ +import itertools + +from collections import defaultdict +from collections import namedtuple + from django.db.models import Q from django.db.models import Sum from django.utils import timezone @@ -5,6 +10,15 @@ from django.utils import timezone from registrasion import models as rego +ConditionAndRemainder = namedtuple( + "ConditionAndRemainder", + ( + "condition", + "remainder", + ), +) + + class ConditionController(object): ''' Base class for testing conditions that activate EnablingCondition or Discount objects. ''' @@ -31,8 +45,103 @@ class ConditionController(object): except KeyError: return ConditionController() - def is_met(self, user, quantity): - return True + @classmethod + def test_enabling_conditions( + cls, user, products=None, product_quantities=None): + ''' Evaluates all of the enabling 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 enabling 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 = {} + + # Get the conditions covered by the products themselves + all_conditions = [ + product.enablingconditionbase_set.select_subclasses() | + product.category.enablingconditionbase_set.select_subclasses() + for product in products + ] + all_conditions = set(itertools.chain(*all_conditions)) + + # All mandatory conditions on a product need to be met + mandatory = defaultdict(lambda: True) + # At least one non-mandatory condition on a product must be met + # if there are no mandatory conditions + non_mandatory = defaultdict(lambda: False) + + remainders = [] + for condition in all_conditions: + cond = cls.for_condition(condition) + remainder = cond.user_quantity_remaining(user) + + # Get all products covered by this condition, and the products + # from the categories covered by this condition + cond_products = condition.products.all() + from_category = rego.Product.objects.filter( + category__in=condition.categories.all(), + ).all() + all_products = set(itertools.chain(cond_products, from_category)) + + # Remove the products that we aren't asking about + all_products = all_products & products + + if quantities: + consumed = sum(quantities[i] for i in all_products) + else: + consumed = 1 + met = consumed <= remainder + + for product in all_products: + if condition.mandatory: + mandatory[product] &= met + else: + non_mandatory[product] |= met + + valid = defaultdict(lambda: True) + for product in itertools.chain(mandatory, non_mandatory): + if product in mandatory: + # If there's a mandatory condition, all must be met + valid[product] = mandatory[product] + else: + # Otherwise, we need just one non-mandatory condition met + valid[product] = non_mandatory[product] + + error_fields = [product for product in valid if not valid[product]] + return error_fields + + def user_quantity_remaining(self, user): + ''' Returns the number of items covered by this enabling 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 99999999 if self.is_met(user) else 0 + + def is_met(self, user): + ''' Returns True if this enabling condition is met, otherwise returns + False. + + Either this method, or user_quantity_remaining() must be overridden + in subclasses. + ''' + return self.user_quantity_remaining(user) > 0 class CategoryConditionController(ConditionController): @@ -40,7 +149,7 @@ class CategoryConditionController(ConditionController): def __init__(self, condition): self.condition = condition - def is_met(self, user, quantity): + def is_met(self, user): ''' returns True if the user has a product from a category that invokes this condition in one of their carts ''' @@ -62,7 +171,7 @@ class ProductConditionController(ConditionController): def __init__(self, condition): self.condition = condition - def is_met(self, user, quantity): + def is_met(self, user): ''' returns True if the user has a product that invokes this condition in one of their carts ''' @@ -81,22 +190,17 @@ class TimeOrStockLimitConditionController(ConditionController): def __init__(self, ceiling): self.ceiling = ceiling - def is_met(self, user, quantity): - ''' returns True if adding _quantity_ of _product_ will not vioilate - this ceiling. ''' + def user_quantity_remaining(self, user): + ''' returns 0 if the date range is violated, otherwise, it will return + the quantity remaining under the stock limit. ''' # Test date range - if not self.test_date_range(): - return False + if not self._test_date_range(): + return 0 - # Test limits - if not self.test_limits(quantity): - return False + return self._get_remaining_stock(user) - # All limits have been met - return True - - def test_date_range(self): + def _test_date_range(self): now = timezone.now() if self.ceiling.start_time is not None: @@ -114,7 +218,7 @@ class TimeOrStockLimitConditionController(ConditionController): list products differently to discounts. ''' if isinstance(self.ceiling, rego.TimeOrStockLimitEnablingCondition): category_products = rego.Product.objects.filter( - category=self.ceiling.categories.all(), + category__in=self.ceiling.categories.all(), ) return self.ceiling.products.all() | category_products else: @@ -123,28 +227,31 @@ class TimeOrStockLimitConditionController(ConditionController): ) return rego.Product.objects.filter( Q(discountforproduct__discount=self.ceiling) | - Q(category=categories.all()) + Q(category__in=categories.all()) ) - def test_limits(self, quantity): - if self.ceiling.limit is None: - return True + def _get_remaining_stock(self, user): + ''' Returns the stock that remains under this ceiling, excluding the + user's current cart. ''' + if self.ceiling.limit is None: + return 99999999 + + # We care about all reserved carts, but not the user's current cart reserved_carts = rego.Cart.reserved_carts() + reserved_carts = reserved_carts.exclude( + user=user, + active=True, + ) + product_items = rego.ProductItem.objects.filter( product__in=self._products().all(), ) product_items = product_items.filter(cart=reserved_carts) - agg = product_items.aggregate(Sum("quantity")) - count = agg["quantity__sum"] - if count is None: - count = 0 + count = product_items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - if count + quantity > self.ceiling.limit: - return False - - return True + return self.ceiling.limit - count class VoucherConditionController(ConditionController): @@ -153,7 +260,7 @@ class VoucherConditionController(ConditionController): def __init__(self, condition): self.condition = condition - def is_met(self, user, quantity): + def is_met(self, user): ''' returns True if the user has the given voucher attached. ''' carts_count = rego.Cart.objects.filter( user=user, diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 7d6a959a..084584ee 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -75,7 +75,7 @@ def available_discounts(user, categories, products): pass elif real_discount not in failed_discounts: # This clause is still available - if real_discount in accepted_discounts or cond.is_met(user, 0): + if real_discount in accepted_discounts or cond.is_met(user): # This clause is valid for this user discounts.append(DiscountAndQuantity( discount=real_discount, diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 929f7290..ec9a73e5 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -79,7 +79,7 @@ class ProductController(object): for condition in conditions: cond = ConditionController.for_condition(condition) - met = cond.is_met(user, quantity) + met = cond.is_met(user) if condition.mandatory and not met: mandatory_violated = True From 194f98bcc4d02cf80a0bab389421ba10ca450bed Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 18:03:42 +1100 Subject: [PATCH 085/418] Refactors available_products to use test_enabling_conditions --- registrasion/controllers/product.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index ec9a73e5..dd1536fc 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -30,14 +30,20 @@ class ProductController(object): if products is not None: all_products = itertools.chain(all_products, products) - out = [ + passed_limits = set( product for product in all_products if CategoryController(product.category).user_quantity_remaining(user) > 0 if cls(product).user_quantity_remaining(user) > 0 - if cls(product).can_add_with_enabling_conditions(user, 0) - ] + ) + + failed_conditions = set(ConditionController.test_enabling_conditions( + user, products=passed_limits + )) + + out = list(passed_limits - failed_conditions) out.sort(key=lambda product: product.order) + return out def user_quantity_remaining(self, user): From e3ec1281477925b99cc1e58337d5544d3c23fbe3 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 18:10:33 +1100 Subject: [PATCH 086/418] Factors limits testing in set_quantities into _test_limits() --- registrasion/controllers/cart.py | 47 ++++++++++++----------------- registrasion/controllers/product.py | 34 +-------------------- 2 files changed, 20 insertions(+), 61 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index f62e134b..db9c912a 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -81,6 +81,17 @@ class CartController(object): product__in=(i[0] for i in product_quantities), ).delete() + all_product_quantities = list(product_quantities) + [ + (i.product, i.quantity) for i in items_in_cart.all() + ] + self._test_limits(all_product_quantities) + + for product, quantity in product_quantities: + self._set_quantity_old(product, quantity) + + self.end_batch() + + def _test_limits(self, product_quantities): # Collect by category by_cat = collections.defaultdict(list) for product, quantity in product_quantities: @@ -92,11 +103,9 @@ class CartController(object): limit = ctrl.user_quantity_remaining(self.cart.user) # Get the amount so far in the cart - cat_items = items_in_cart.filter(product__category=cat) - so_far = cat_items.aggregate(Sum("quantity"))["quantity__sum"] or 0 to_add = sum(i[1] for i in by_cat[cat]) - if so_far + to_add > limit: + if to_add > limit: # TODO: batch errors raise ValidationError( "You may only have %d items in category: %s" % ( @@ -117,23 +126,15 @@ class CartController(object): ) ) - product_quantities_all = list(product_quantities) + [ - (i.product, i.quantity) for i in items_in_cart.all() - ] - - # Test each enabling condition here + # Test the enabling conditions errs = ConditionController.test_enabling_conditions( self.cart.user, - product_quantities=product_quantities_all, + product_quantities=product_quantities, ) if errs: - raise ValidationError("Whoops") - - for product, quantity in product_quantities: - self._set_quantity_old(product, quantity) - - self.end_batch() + # TODO: batch errors + raise ValidationError("An enabling condition failed") def set_quantity(self, product, quantity, batched=False): ''' Sets the _quantity_ of the given _product_ in the cart to the given @@ -225,17 +226,9 @@ class CartController(object): # TODO: validate vouchers items = rego.ProductItem.objects.filter(cart=self.cart) - for item in items: - # NOTE: per-user limits are tested at add time - # and are unliklely to change - prod = ProductController(item.product) - # If the cart is not reserved, we need to see if we can re-reserve - quantity = 0 if is_reserved else item.quantity - - if not prod.can_add_with_enabling_conditions( - self.cart.user, quantity): - raise ValidationError("Products are no longer available") + product_quantities = list((i.product, i.quantity) for i in items) + self._test_limits(product_quantities) # Validate the discounts discount_items = rego.DiscountItem.objects.filter(cart=self.cart) @@ -250,9 +243,7 @@ class CartController(object): pk=discount.pk) cond = ConditionController.for_condition(real_discount) - quantity = 0 if is_reserved else discount_item.quantity - - if not cond.is_met(self.cart.user): # TODO: REPLACE WITH QUANTITY CHECKER WHEN FIXING CEILINGS + if not cond.is_met(self.cart.user): raise ValidationError("Discounts are no longer available") def recalculate_discounts(self): diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index dd1536fc..349d01d7 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -43,7 +43,7 @@ class ProductController(object): out = list(passed_limits - failed_conditions) out.sort(key=lambda product: product.order) - + return out def user_quantity_remaining(self, user): @@ -70,35 +70,3 @@ class ProductController(object): prod_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 return prod_limit - prod_count - - def can_add_with_enabling_conditions(self, user, quantity): - ''' Returns true if the user is able to add _quantity_ to their count - of this Product without exceeding the ceilings the product is attached - to. ''' - - conditions = rego.EnablingConditionBase.objects.filter( - Q(products=self.product) | Q(categories=self.product.category) - ).select_subclasses() - - mandatory_violated = False - non_mandatory_met = False - - for condition in conditions: - cond = ConditionController.for_condition(condition) - met = cond.is_met(user) - - if condition.mandatory and not met: - mandatory_violated = True - break - if met: - non_mandatory_met = True - - if mandatory_violated: - # All mandatory conditions must be met - return False - - if len(conditions) > 0 and not non_mandatory_met: - # If there's any non-mandatory conditions, one must be met - return False - - return True From 8796670328dc162846329bea64ce1ab4a3ad42de Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 19:57:17 +1100 Subject: [PATCH 087/418] handle_products now uses the transactional set_quantities. --- registrasion/views.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 2485b438..3efdc0ad 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -299,12 +299,8 @@ def handle_products(request, category, products, prefix): ) if request.method == "POST" and products_form.is_valid(): - try: - if products_form.has_changed(): - set_quantities_from_products_form(products_form, current_cart) - except ValidationError: - # There were errors, but they've already been added to the form. - pass + if products_form.has_changed(): + set_quantities_from_products_form(products_form, current_cart) # If category is required, the user must have at least one # in an active+valid cart @@ -326,20 +322,19 @@ def handle_products(request, category, products, prefix): return products_form, discounts, handled -@transaction.atomic def set_quantities_from_products_form(products_form, current_cart): # TODO: issue #8 is a problem here. quantities = list(products_form.product_quantities()) quantities.sort(key=lambda item: item[1]) - for product_id, quantity, field_name in quantities: - product = rego.Product.objects.get(pk=product_id) - try: - current_cart.set_quantity(product, quantity, batched=True) - except ValidationError as ve: - products_form.add_error(field_name, ve) - if products_form.errors: - raise ValidationError("Cannot add that stuff") - current_cart.end_batch() + + product_quantities = [ + (rego.Product.objects.get(pk=i[0]), i[1]) for i in quantities + ] + + try: + current_cart.set_quantities(product_quantities) + except ValidationError as ve: + products_form.add_error(None, ve) def handle_voucher(request, prefix): From 2cbda9172f1e69362f14ec527dbac8b23ac3565f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 20:11:31 +1100 Subject: [PATCH 088/418] Fixes bug in product and category, and ceiling enabling conditions --- registrasion/controllers/conditions.py | 6 +++--- registrasion/tests/test_cart.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 63362a28..7243737a 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -158,7 +158,7 @@ class CategoryConditionController(ConditionController): category=self.condition.enabling_category, ) products_count = rego.ProductItem.objects.filter( - cart=carts, + cart__in=carts, product__in=enabling_products, ).count() return products_count > 0 @@ -177,7 +177,7 @@ class ProductConditionController(ConditionController): carts = rego.Cart.objects.filter(user=user, released=False) products_count = rego.ProductItem.objects.filter( - cart=carts, + cart__in=carts, product__in=self.condition.enabling_products.all(), ).count() return products_count > 0 @@ -247,7 +247,7 @@ class TimeOrStockLimitConditionController(ConditionController): product_items = rego.ProductItem.objects.filter( product__in=self._products().all(), ) - product_items = product_items.filter(cart=reserved_carts) + product_items = product_items.filter(cart__in=reserved_carts) count = product_items.aggregate(Sum("quantity"))["quantity__sum"] or 0 diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index bc58d5fb..c203c9b7 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -72,6 +72,15 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_4.price = Decimal("5.00") cls.PROD_4.save() + # Burn through some carts -- this made some past EC tests fail + current_cart = CartController.for_user(cls.USER_1) + current_cart.cart.active = False + current_cart.cart.save() + + current_cart = CartController.for_user(cls.USER_2) + current_cart.cart.active = False + current_cart.cart.save() + @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( From 6c9a68dc5b2a3e4d874f4eb73bf198b8f8115dd7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 20:11:40 +1100 Subject: [PATCH 089/418] Fixes #8 properly --- registrasion/views.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 3efdc0ad..f7e87694 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -121,10 +121,6 @@ def guided_registration(request, page_id=0): category=category, ) - if not products: - # This product category does not exist for this user - continue - prefix = "category_" + str(category.id) p = handle_products(request, category, products, prefix) products_form, discounts, products_handled = p @@ -135,7 +131,9 @@ def guided_registration(request, page_id=0): discounts=discounts, form=products_form, ) - sections.append(section) + if products: + # This product category does not exist for this user + sections.append(section) if request.method == "POST" and not products_form.errors: if category.id > attendee.highest_complete_category: @@ -323,10 +321,8 @@ def handle_products(request, category, products, prefix): def set_quantities_from_products_form(products_form, current_cart): - # TODO: issue #8 is a problem here. - quantities = list(products_form.product_quantities()) - quantities.sort(key=lambda item: item[1]) + quantities = products_form.product_quantities() product_quantities = [ (rego.Product.objects.get(pk=i[0]), i[1]) for i in quantities ] From 312fffd137c20cb6e351201f9290f1b81fadf763 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 3 Apr 2016 09:45:39 +1000 Subject: [PATCH 090/418] Adds negative quantity tests to _test_limits, and removes _set_quantity_old. --- registrasion/controllers/cart.py | 104 ++++++++++++++----------------- 1 file changed, 48 insertions(+), 56 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index db9c912a..5fab4b30 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,6 +1,7 @@ import collections import datetime import discount +import itertools from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError @@ -73,25 +74,64 @@ class CartController(object): @transaction.atomic def set_quantities(self, product_quantities): + ''' Sets the quantities on each of the products on each of the + products specified. Raises an exception (ValidationError) if a limit + is violated. `product_quantities` is an iterable of (product, quantity) + pairs. ''' items_in_cart = rego.ProductItem.objects.filter(cart=self.cart) + product_quantities = list(product_quantities) - # Remove all items that we're updating - items_in_cart.filter( - product__in=(i[0] for i in product_quantities), - ).delete() + # 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() - all_product_quantities = list(product_quantities) + [ - (i.product, i.quantity) for i in items_in_cart.all() - ] + # Validate that the limits we're adding are OK self._test_limits(all_product_quantities) for product, quantity in product_quantities: - self._set_quantity_old(product, quantity) + try: + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product, + ) + product_item.quantity = quantity + product_item.save() + except ObjectDoesNotExist: + rego.ProductItem.objects.create( + cart=self.cart, + product=product, + quantity=quantity, + ) + + items_in_cart.filter(quantity=0).delete() self.end_batch() def _test_limits(self, product_quantities): + ''' Tests that the quantity changes we intend to make do not violate + the limits and enabling conditions imposed on the products. ''' + + # Test each product limit here + for product, quantity in product_quantities: + if quantity < 0: + # TODO: batch errors + raise ValidationError("Value must be zero or greater.") + + prod = ProductController(product) + limit = prod.user_quantity_remaining(self.cart.user) + + if quantity > limit: + # TODO: batch errors + raise ValidationError( + "You may only have %d of product: %s" % ( + limit, product.name, + ) + ) + # Collect by category by_cat = collections.defaultdict(list) for product, quantity in product_quantities: @@ -113,19 +153,6 @@ class CartController(object): ) ) - # Test each product limit here - for product, quantity in product_quantities: - prod = ProductController(product) - limit = prod.user_quantity_remaining(self.cart.user) - - if quantity > limit: - # TODO: batch errors - raise ValidationError( - "You may only have %d of product: %s" % ( - limit, cat.name, - ) - ) - # Test the enabling conditions errs = ConditionController.test_enabling_conditions( self.cart.user, @@ -142,41 +169,6 @@ class CartController(object): self.set_quantities(((product, quantity),)) - def _set_quantity_old(self, product, quantity): - ''' Sets the _quantity_ of the given _product_ in the cart to the given - _quantity_. ''' - - if quantity < 0: - raise ValidationError("Cannot have fewer than 0 items in cart.") - - try: - product_item = rego.ProductItem.objects.get( - cart=self.cart, - product=product) - old_quantity = product_item.quantity - - if quantity == 0: - product_item.delete() - return - except ObjectDoesNotExist: - if quantity == 0: - return - - product_item = rego.ProductItem.objects.create( - cart=self.cart, - product=product, - quantity=0, - ) - - old_quantity = 0 - - # Validate the addition to the cart - adjustment = quantity - old_quantity - prod = ProductController(product) - - product_item.quantity = quantity - product_item.save() - def add_to_cart(self, product, quantity): ''' Adds _quantity_ of the given _product_ to the cart. Raises ValidationError if constraints are violated.''' From eab1deff7776a7780ef237c2b21215bcd0dc2908 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 3 Apr 2016 10:06:35 +1000 Subject: [PATCH 091/418] Removes set_quantity and add_to_cart from CartController, and factors it into a test controller for testing --- registrasion/controllers/cart.py | 25 ++------- registrasion/tests/cart_controller_helper.py | 25 +++++++++ registrasion/tests/test_cart.py | 38 ++++++------- registrasion/tests/test_ceilings.py | 18 +++---- registrasion/tests/test_discount.py | 54 +++++++++---------- registrasion/tests/test_enabling_condition.py | 38 ++++++------- registrasion/tests/test_invoice.py | 22 ++++---- registrasion/tests/test_refund.py | 4 +- registrasion/tests/test_voucher.py | 20 +++---- 9 files changed, 125 insertions(+), 119 deletions(-) create mode 100644 registrasion/tests/cart_controller_helper.py diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 5fab4b30..f23de385 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -21,8 +21,8 @@ class CartController(object): def __init__(self, cart): self.cart = cart - @staticmethod - def for_user(user): + @classmethod + def for_user(cls, user): ''' Returns the user's current cart, or creates a new cart if there isn't one ready yet. ''' @@ -35,7 +35,7 @@ class CartController(object): reservation_duration=datetime.timedelta(), ) existing.save() - return CartController(existing) + return cls(existing) def extend_reservation(self): ''' Updates the cart's time last updated value, which is used to @@ -163,25 +163,6 @@ class CartController(object): # TODO: batch errors raise ValidationError("An enabling condition failed") - 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 = rego.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 apply_voucher(self, voucher_code): ''' Applies the voucher with the given code to this cart. ''' diff --git a/registrasion/tests/cart_controller_helper.py b/registrasion/tests/cart_controller_helper.py new file mode 100644 index 00000000..9176f2c8 --- /dev/null +++ b/registrasion/tests/cart_controller_helper.py @@ -0,0 +1,25 @@ +from registrasion.controllers.cart import CartController +from registrasion import models as rego + +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 = rego.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) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index c203c9b7..db90a723 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -8,9 +8,9 @@ from django.core.exceptions import ValidationError from django.test import TestCase from registrasion import models as rego -from registrasion.controllers.cart import CartController from registrasion.controllers.product import ProductController +from cart_controller_helper import TestingCartController from patch_datetime import SetTimeMixin UTC = pytz.timezone('UTC') @@ -73,11 +73,11 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_4.save() # Burn through some carts -- this made some past EC tests fail - current_cart = CartController.for_user(cls.USER_1) + current_cart = TestingCartController.for_user(cls.USER_1) current_cart.cart.active = False current_cart.cart.save() - current_cart = CartController.for_user(cls.USER_2) + current_cart = TestingCartController.for_user(cls.USER_2) current_cart.cart.active = False current_cart.cart.save() @@ -129,21 +129,21 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): class BasicCartTests(RegistrationCartTestCase): def test_get_cart(self): - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.cart.active = False current_cart.cart.save() old_cart = current_cart - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) self.assertNotEqual(old_cart.cart, current_cart.cart) - current_cart2 = CartController.for_user(self.USER_1) + 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 = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) # Add a product twice current_cart.add_to_cart(self.PROD_1, 1) @@ -158,7 +158,7 @@ class BasicCartTests(RegistrationCartTestCase): self.assertEquals(2, item.quantity) def test_set_quantity(self): - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) def get_item(): return rego.ProductItem.objects.get( @@ -190,7 +190,7 @@ class BasicCartTests(RegistrationCartTestCase): self.assertEqual(2, get_item().quantity) def test_add_to_cart_product_per_user_limit(self): - current_cart = CartController.for_user(self.USER_1) + 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) @@ -206,14 +206,14 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.cart.active = False current_cart.cart.save() - current_cart = CartController.for_user(self.USER_1) + 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 = CartController.for_user(self.USER_2) + second_user_cart = TestingCartController.for_user(self.USER_2) second_user_cart.add_to_cart(self.PROD_1, 10) def set_limits(self): @@ -230,7 +230,7 @@ class BasicCartTests(RegistrationCartTestCase): def test_per_user_product_limit_ignored_if_blank(self): self.set_limits() - current_cart = CartController.for_user(self.USER_1) + 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 @@ -238,7 +238,7 @@ class BasicCartTests(RegistrationCartTestCase): def test_per_user_category_limit_ignored_if_blank(self): self.set_limits() - current_cart = CartController.for_user(self.USER_1) + 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 @@ -247,7 +247,7 @@ class BasicCartTests(RegistrationCartTestCase): def test_per_user_category_limit_only(self): self.set_limits() - current_cart = CartController.for_user(self.USER_1) + 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) @@ -264,7 +264,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.cart.active = False current_cart.cart.save() - current_cart = CartController.for_user(self.USER_1) + 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) @@ -272,7 +272,7 @@ class BasicCartTests(RegistrationCartTestCase): def test_per_user_category_and_product_limits(self): self.set_limits() - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) # Hit both the product and category edges: current_cart.set_quantity(self.PROD_3, 4) @@ -290,7 +290,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.cart.active = False current_cart.cart.save() - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.set_quantity(self.PROD_3, 4) with self.assertRaises(ValidationError): @@ -308,7 +308,7 @@ class BasicCartTests(RegistrationCartTestCase): products=[self.PROD_2, self.PROD_3, self.PROD_4], ) - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) prods = get_prods() self.assertTrue(item in prods) current_cart.add_to_cart(item, quantity) @@ -317,7 +317,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.cart.active = False current_cart.cart.save() - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) prods = get_prods() self.assertTrue(item not in prods) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index c39df39a..e65a9ecf 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -3,7 +3,7 @@ import pytz from django.core.exceptions import ValidationError -from registrasion.controllers.cart import CartController +from cart_controller_helper import TestingCartController from test_cart import RegistrationCartTestCase @@ -22,7 +22,7 @@ class CeilingsTestCases(RegistrationCartTestCase): def __add_to_cart_test(self): - current_cart = CartController.for_user(self.USER_1) + 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 @@ -46,7 +46,7 @@ class CeilingsTestCases(RegistrationCartTestCase): start_time=datetime.datetime(2015, 01, 01, tzinfo=UTC), end_time=datetime.datetime(2015, 02, 01, tzinfo=UTC)) - current_cart = CartController.for_user(self.USER_1) + 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, 01, 01, tzinfo=UTC)) @@ -74,8 +74,8 @@ class CeilingsTestCases(RegistrationCartTestCase): self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) - first_cart = CartController.for_user(self.USER_1) - second_cart = CartController.for_user(self.USER_2) + first_cart = TestingCartController.for_user(self.USER_1) + second_cart = TestingCartController.for_user(self.USER_2) first_cart.add_to_cart(self.PROD_1, 1) @@ -111,8 +111,8 @@ class CeilingsTestCases(RegistrationCartTestCase): def __validation_test(self): self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) - first_cart = CartController.for_user(self.USER_1) - second_cart = CartController.for_user(self.USER_2) + 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) @@ -136,13 +136,13 @@ class CeilingsTestCases(RegistrationCartTestCase): def test_items_released_from_ceiling_by_refund(self): self.make_ceiling("Limit ceiling", limit=1) - first_cart = CartController.for_user(self.USER_1) + first_cart = TestingCartController.for_user(self.USER_1) first_cart.add_to_cart(self.PROD_1, 1) first_cart.cart.active = False first_cart.cart.save() - second_cart = CartController.for_user(self.USER_2) + second_cart = TestingCartController.for_user(self.USER_2) with self.assertRaises(ValidationError): second_cart.add_to_cart(self.PROD_1, 1) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index f2eb36bb..1fb4225d 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -4,7 +4,7 @@ from decimal import Decimal from registrasion import models as rego from registrasion.controllers import discount -from registrasion.controllers.cart import CartController +from cart_controller_helper import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -84,7 +84,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_is_applied(self): self.add_discount_prod_1_includes_prod_2() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.add_to_cart(self.PROD_2, 1) @@ -94,7 +94,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_is_applied_for_category(self): self.add_discount_prod_1_includes_cat_2() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.add_to_cart(self.PROD_3, 1) @@ -104,7 +104,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_does_not_apply_if_not_met(self): self.add_discount_prod_1_includes_prod_2() - cart = CartController.for_user(self.USER_1) + 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 @@ -113,7 +113,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_applied_out_of_order(self): self.add_discount_prod_1_includes_prod_2() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) cart.add_to_cart(self.PROD_1, 1) @@ -123,7 +123,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discounts_collapse(self): self.add_discount_prod_1_includes_prod_2() - cart = CartController.for_user(self.USER_1) + 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) @@ -134,7 +134,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discounts_respect_quantity(self): self.add_discount_prod_1_includes_prod_2() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.add_to_cart(self.PROD_2, 3) @@ -147,7 +147,7 @@ class DiscountTestCase(RegistrationCartTestCase): discount_full = self.add_discount_prod_1_includes_prod_2() discount_half = self.add_discount_prod_1_includes_prod_2(Decimal(50)) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.add_to_cart(self.PROD_2, 3) @@ -166,13 +166,13 @@ class DiscountTestCase(RegistrationCartTestCase): self.add_discount_prod_1_includes_prod_2() # Enable the discount during the first cart. - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.cart.active = False cart.cart.save() # Use the discount in the second cart - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) # The discount should be applied. @@ -182,7 +182,7 @@ class DiscountTestCase(RegistrationCartTestCase): # The discount should respect the total quantity across all # of the user's carts. - cart = CartController.for_user(self.USER_1) + 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 @@ -193,7 +193,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_applies_only_once_enabled(self): # Enable the discount during the first cart. - cart = CartController.for_user(self.USER_1) + 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) @@ -201,7 +201,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart.cart.save() self.add_discount_prod_1_includes_prod_2() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 2) discount_items = list(cart.cart.discountitem_set.all()) @@ -209,7 +209,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_category_discount_applies_once_per_category(self): self.add_discount_prod_1_includes_cat_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Add two items from category 2 @@ -223,7 +223,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_category_discount_applies_to_highest_value(self): self.add_discount_prod_1_includes_cat_2(quantity=1) - cart = CartController.for_user(self.USER_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 @@ -241,7 +241,7 @@ class DiscountTestCase(RegistrationCartTestCase): # Both users should be able to apply the same discount # in the same way for user in (self.USER_1, self.USER_2): - cart = CartController.for_user(user) + cart = TestingCartController.for_user(user) cart.add_to_cart(self.PROD_1, 1) # Enable the discount cart.add_to_cart(self.PROD_3, 1) @@ -270,7 +270,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_category_discount_appears_once_if_met_twice(self): self.add_discount_prod_1_includes_cat_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts( @@ -283,7 +283,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_category_discount_appears_with_category(self): self.add_discount_prod_1_includes_cat_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) @@ -292,7 +292,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_category_discount_appears_with_product(self): self.add_discount_prod_1_includes_cat_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts( @@ -305,7 +305,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_category_discount_appears_once_with_two_valid_product(self): self.add_discount_prod_1_includes_cat_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts( @@ -318,7 +318,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_product_discount_appears_with_product(self): self.add_discount_prod_1_includes_prod_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts( @@ -331,7 +331,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_product_discount_does_not_appear_with_category(self): self.add_discount_prod_1_includes_prod_2(quantity=1) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts(self.USER_1, [self.CAT_1], []) @@ -340,7 +340,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_quantity_is_correct_before_first_purchase(self): self.add_discount_prod_1_includes_cat_2(quantity=2) - cart = CartController.for_user(self.USER_1) + 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 @@ -353,7 +353,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discount_quantity_is_correct_after_first_purchase(self): self.test_discount_quantity_is_correct_before_first_purchase() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) @@ -369,7 +369,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_product_discount_enabled_twice_appears_twice(self): self.add_discount_prod_1_includes_prod_3_and_prod_4(quantity=2) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts( self.USER_1, @@ -380,7 +380,7 @@ class DiscountTestCase(RegistrationCartTestCase): def test_discounts_are_released_by_refunds(self): self.add_discount_prod_1_includes_prod_2(quantity=2) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount discounts = discount.available_discounts( self.USER_1, @@ -392,7 +392,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart.cart.active = False # Keep discount enabled cart.cart.save() - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted cart.cart.active = False cart.cart.save() diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index aeec4708..e6c090dd 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from registrasion.controllers.category import CategoryController -from registrasion.controllers.cart import CartController +from cart_controller_helper import TestingCartController from registrasion.controllers.product import ProductController from test_cart import RegistrationCartTestCase @@ -56,7 +56,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.add_product_enabling_condition() # Cannot buy PROD_1 without buying PROD_2 - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): current_cart.add_to_cart(self.PROD_1, 1) @@ -66,20 +66,20 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_product_enabled_by_product_in_previous_cart(self): self.add_product_enabling_condition() - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_2, 1) current_cart.cart.active = False current_cart.cart.save() # Create new cart and try to add PROD_1 - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) def test_product_enabling_condition_enables_category(self): self.add_product_enabling_condition_on_category() # Cannot buy PROD_1 without buying item from CAT_2 - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): current_cart.add_to_cart(self.PROD_1, 1) @@ -90,7 +90,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.add_category_enabling_condition() # Cannot buy PROD_1 without buying PROD_2 - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): current_cart.add_to_cart(self.PROD_1, 1) @@ -101,13 +101,13 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_product_enabled_by_category_in_previous_cart(self): self.add_category_enabling_condition() - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_3, 1) current_cart.cart.active = False current_cart.cart.save() # Create new cart and try to add PROD_1 - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) def test_multiple_non_mandatory_conditions(self): @@ -115,7 +115,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.add_category_enabling_condition() # User 1 is testing the product enabling condition - cart_1 = CartController.for_user(self.USER_1) + 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) @@ -123,7 +123,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): cart_1.add_to_cart(self.PROD_1, 1) # User 2 is testing the category enabling condition - cart_2 = CartController.for_user(self.USER_2) + 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) @@ -134,7 +134,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.add_product_enabling_condition(mandatory=True) self.add_category_enabling_condition(mandatory=True) - cart_1 = CartController.for_user(self.USER_1) + 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) @@ -148,7 +148,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.add_product_enabling_condition(mandatory=False) self.add_category_enabling_condition(mandatory=True) - cart_1 = CartController.for_user(self.USER_1) + 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) @@ -199,7 +199,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_available_products_on_category_works_when_condition_is_met(self): self.add_product_enabling_condition(mandatory=False) - cart_1 = CartController.for_user(self.USER_1) + cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) prods = ProductController.available_products( @@ -224,7 +224,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_available_products_on_products_works_when_condition_is_met(self): self.add_product_enabling_condition(mandatory=False) - cart_1 = CartController.for_user(self.USER_1) + cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) prods = ProductController.available_products( @@ -238,13 +238,13 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_category_enabling_condition_fails_if_cart_refunded(self): self.add_category_enabling_condition(mandatory=False) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) cart.cart.active = False cart.cart.save() - cart_2 = CartController.for_user(self.USER_1) + 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) @@ -257,13 +257,13 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_product_enabling_condition_fails_if_cart_refunded(self): self.add_product_enabling_condition(mandatory=False) - cart = CartController.for_user(self.USER_1) + cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) cart.cart.active = False cart.cart.save() - cart_2 = CartController.for_user(self.USER_1) + 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) @@ -276,7 +276,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def test_available_categories(self): self.add_product_enabling_condition_on_category(mandatory=False) - cart_1 = CartController.for_user(self.USER_1) + cart_1 = TestingCartController.for_user(self.USER_1) cats = CategoryController.available_categories( self.USER_1, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 37265a2a..fd6c9cfb 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -5,7 +5,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError from registrasion import models as rego -from registrasion.controllers.cart import CartController +from cart_controller_helper import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -16,7 +16,7 @@ UTC = pytz.timezone('UTC') class InvoiceTestCase(RegistrationCartTestCase): def test_create_invoice(self): - current_cart = CartController.for_user(self.USER_1) + 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) @@ -49,11 +49,11 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_create_invoice_fails_if_cart_invalid(self): self.make_ceiling("Limit ceiling", limit=1) self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) self.add_timedelta(self.RESERVATION * 2) - cart_2 = CartController.for_user(self.USER_2) + cart_2 = TestingCartController.for_user(self.USER_2) cart_2.add_to_cart(self.PROD_1, 1) # Now try to invoice the first user @@ -61,7 +61,7 @@ class InvoiceTestCase(RegistrationCartTestCase): InvoiceController.for_cart(current_cart.cart) def test_paying_invoice_makes_new_cart(self): - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) invoice = InvoiceController.for_cart(current_cart.cart) @@ -74,7 +74,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertFalse(invoice.invoice.cart.active) # Asking for a cart should generate a new one - new_cart = CartController.for_user(self.USER_1) + new_cart = TestingCartController.for_user(self.USER_1) self.assertNotEqual(current_cart.cart, new_cart.cart) def test_invoice_includes_discounts(self): @@ -96,7 +96,7 @@ class InvoiceTestCase(RegistrationCartTestCase): quantity=1 ).save() - current_cart = CartController.for_user(self.USER_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 @@ -112,7 +112,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.invoice.value) def test_invoice_voids_self_if_cart_is_invalid(self): - current_cart = CartController.for_user(self.USER_1) + 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) @@ -134,7 +134,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertFalse(invoice_2_new.invoice.void) def test_voiding_invoice_creates_new_invoice(self): - current_cart = CartController.for_user(self.USER_1) + 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) @@ -147,7 +147,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) def test_cannot_pay_void_invoice(self): - current_cart = CartController.for_user(self.USER_1) + 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) @@ -159,7 +159,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.pay("Reference", invoice_1.invoice.value) def test_cannot_void_paid_invoice(self): - current_cart = CartController.for_user(self.USER_1) + 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) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 0fe0648f..bde25929 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,6 +1,6 @@ import pytz -from registrasion.controllers.cart import CartController +from cart_controller_helper import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -11,7 +11,7 @@ UTC = pytz.timezone('UTC') class RefundTestCase(RegistrationCartTestCase): def test_refund_marks_void_and_unpaid_and_cart_released(self): - current_cart = CartController.for_user(self.USER_1) + 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) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index abc7c8c3..da12abb3 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from registrasion import models as rego -from registrasion.controllers.cart import CartController +from cart_controller_helper import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -31,12 +31,12 @@ class VoucherTestCases(RegistrationCartTestCase): self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) - cart_1 = CartController.for_user(self.USER_1) + 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 = CartController.for_user(self.USER_2) + cart_2 = TestingCartController.for_user(self.USER_2) with self.assertRaises(ValidationError): cart_2.apply_voucher(voucher.code) @@ -66,7 +66,7 @@ class VoucherTestCases(RegistrationCartTestCase): enabling_condition.save() # Adding the product without a voucher will not work - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): current_cart.add_to_cart(self.PROD_1, 1) @@ -90,7 +90,7 @@ class VoucherTestCases(RegistrationCartTestCase): ).save() # Having PROD_1 in place should add a discount - current_cart = CartController.for_user(self.USER_1) + 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())) @@ -106,19 +106,19 @@ class VoucherTestCases(RegistrationCartTestCase): def test_vouchers_case_insensitive(self): voucher = self.new_voucher(code="VOUCHeR") - current_cart = CartController.for_user(self.USER_1) + 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 = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) with self.assertRaises(ValidationError): current_cart.apply_voucher(voucher.code) def test_voucher_can_only_be_applied_once_across_multiple_carts(self): voucher = self.new_voucher(limit=2) - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) inv = InvoiceController.for_cart(current_cart.cart) @@ -131,13 +131,13 @@ class VoucherTestCases(RegistrationCartTestCase): def test_refund_releases_used_vouchers(self): voucher = self.new_voucher(limit=2) - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) inv = InvoiceController.for_cart(current_cart.cart) inv.pay("Hello!", inv.invoice.value) - current_cart = CartController.for_user(self.USER_1) + current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): current_cart.apply_voucher(voucher.code) From 4d134e95d70b7f2f45e729488d1e542cef14d9bd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 3 Apr 2016 12:53:36 +1000 Subject: [PATCH 092/418] Refactors discount ceiling testing to make sure that the discount ceiling only considers items where the discount was applied in determining if the discount was reached. --- registrasion/controllers/conditions.py | 55 ++++++++++++++------------ registrasion/tests/test_cart.py | 16 +++++++- registrasion/tests/test_ceilings.py | 36 ++++++++++++++++- registrasion/tests/test_voucher.py | 10 ----- 4 files changed, 78 insertions(+), 39 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 7243737a..731b7edc 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -33,9 +33,9 @@ class ConditionController(object): rego.IncludedProductDiscount: ProductConditionController, rego.ProductEnablingCondition: ProductConditionController, rego.TimeOrStockLimitDiscount: - TimeOrStockLimitConditionController, + TimeOrStockLimitDiscountController, rego.TimeOrStockLimitEnablingCondition: - TimeOrStockLimitConditionController, + TimeOrStockLimitEnablingConditionController, rego.VoucherDiscount: VoucherConditionController, rego.VoucherEnablingCondition: VoucherConditionController, } @@ -184,7 +184,7 @@ class ProductConditionController(ConditionController): class TimeOrStockLimitConditionController(ConditionController): - ''' Condition tests for TimeOrStockLimit EnablingCondition and + ''' Common condition tests for TimeOrStockLimit EnablingCondition and Discount.''' def __init__(self, ceiling): @@ -213,23 +213,6 @@ class TimeOrStockLimitConditionController(ConditionController): return True - def _products(self): - ''' Abstracts away the product list, becuase enabling conditions - list products differently to discounts. ''' - if isinstance(self.ceiling, rego.TimeOrStockLimitEnablingCondition): - category_products = rego.Product.objects.filter( - category__in=self.ceiling.categories.all(), - ) - return self.ceiling.products.all() | category_products - else: - categories = rego.Category.objects.filter( - discountforcategory__discount=self.ceiling, - ) - return rego.Product.objects.filter( - Q(discountforproduct__discount=self.ceiling) | - Q(category__in=categories.all()) - ) - def _get_remaining_stock(self, user): ''' Returns the stock that remains under this ceiling, excluding the user's current cart. ''' @@ -244,15 +227,35 @@ class TimeOrStockLimitConditionController(ConditionController): active=True, ) - product_items = rego.ProductItem.objects.filter( - product__in=self._products().all(), - ) - product_items = product_items.filter(cart__in=reserved_carts) - - count = product_items.aggregate(Sum("quantity"))["quantity__sum"] or 0 + items = self._items() + items = items.filter(cart__in=reserved_carts) + count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 return self.ceiling.limit - count +class TimeOrStockLimitEnablingConditionController( + TimeOrStockLimitConditionController): + + def _items(self): + category_products = rego.Product.objects.filter( + category__in=self.ceiling.categories.all(), + ) + products = self.ceiling.products.all() | category_products + + product_items = rego.ProductItem.objects.filter( + product__in=products.all(), + ) + return product_items + + +class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController): + + def _items(self): + discount_items = rego.DiscountItem.objects.filter( + discount=self.ceiling, + ) + return discount_items + class VoucherConditionController(ConditionController): ''' Condition test for VoucherEnablingCondition and VoucherDiscount.''' diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index db90a723..45c18ef0 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -110,7 +110,8 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_discount_ceiling( - cls, name, limit=None, start_time=None, end_time=None): + cls, name, limit=None, start_time=None, end_time=None, + percentage=100): limit_ceiling = rego.TimeOrStockLimitDiscount.objects.create( description=name, start_time=start_time, @@ -121,11 +122,22 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): rego.DiscountForProduct.objects.create( discount=limit_ceiling, product=cls.PROD_1, - percentage=100, + percentage=percentage, quantity=10, ).save() + @classmethod + def new_voucher(self, code="VOUCHER", limit=1): + voucher = rego.Voucher.objects.create( + recipient="Voucher recipient", + code=code, + limit=limit, + ) + voucher.save() + return voucher + + class BasicCartTests(RegistrationCartTestCase): def test_get_cart(self): diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index e65a9ecf..94b1c311 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -4,9 +4,10 @@ import pytz from django.core.exceptions import ValidationError from cart_controller_helper import TestingCartController - from test_cart import RegistrationCartTestCase +from registrasion import models as rego + UTC = pytz.timezone('UTC') @@ -150,3 +151,36 @@ class CeilingsTestCases(RegistrationCartTestCase): 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 = rego.VoucherDiscount.objects.create( + description="VOUCHER RECIPIENT", + voucher=voucher, + ) + discount.save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=self.PROD_1, + percentage=100, + quantity=1 + ).save() + + # 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, len(cart.cart.discountitem_set.all())) + + cart.cart.active = False + cart.cart.save() + + # 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, len(cart.cart.discountitem_set.all())) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index da12abb3..de47c864 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -16,16 +16,6 @@ UTC = pytz.timezone('UTC') class VoucherTestCases(RegistrationCartTestCase): - @classmethod - def new_voucher(self, code="VOUCHER", limit=1): - voucher = rego.Voucher.objects.create( - recipient="Voucher recipient", - code=code, - limit=limit, - ) - voucher.save() - return voucher - def test_apply_voucher(self): voucher = self.new_voucher() From 7609965883725bab6bd30d06a7a9bc7fa0d0a98b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 3 Apr 2016 13:21:57 +1000 Subject: [PATCH 093/418] flake8 compliance --- registrasion/controllers/cart.py | 5 +---- registrasion/controllers/category.py | 8 +------- registrasion/controllers/conditions.py | 7 +++---- registrasion/controllers/product.py | 5 +++-- registrasion/tests/cart_controller_helper.py | 1 + registrasion/tests/test_cart.py | 1 - registrasion/views.py | 1 - 7 files changed, 9 insertions(+), 19 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index f23de385..95a2b921 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -6,7 +6,7 @@ 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, Sum +from django.db.models import Max from django.utils import timezone from registrasion import models as rego @@ -71,7 +71,6 @@ class CartController(object): self.cart.revision += 1 self.cart.save() - @transaction.atomic def set_quantities(self, product_quantities): ''' Sets the quantities on each of the products on each of the @@ -194,8 +193,6 @@ class CartController(object): ''' Determines whether the status of the current cart is valid; this is normally called before generating or paying an invoice ''' - is_reserved = self.cart in rego.Cart.reserved_carts() - # TODO: validate vouchers items = rego.ProductItem.objects.filter(cart=self.cart) diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 7f244263..9ab9dd3b 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -53,10 +53,4 @@ class CategoryController(object): ) cat_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - - cat_limit = self.category.limit_per_user - - if cat_limit is None: - return 999999 # We should probably work on this. - else: - return cat_limit - cat_count + cat_limit - cat_count diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 731b7edc..9a0b26df 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -3,7 +3,6 @@ import itertools from collections import defaultdict from collections import namedtuple -from django.db.models import Q from django.db.models import Sum from django.utils import timezone @@ -64,8 +63,8 @@ class ConditionController(object): "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 ) + quantities = dict((product, quantity) + for product, quantity in product_quantities) elif product_quantities is None: products = set(products) quantities = {} @@ -84,7 +83,6 @@ class ConditionController(object): # if there are no mandatory conditions non_mandatory = defaultdict(lambda: False) - remainders = [] for condition in all_conditions: cond = cls.for_condition(condition) remainder = cond.user_quantity_remaining(user) @@ -233,6 +231,7 @@ class TimeOrStockLimitConditionController(ConditionController): return self.ceiling.limit - count + class TimeOrStockLimitEnablingConditionController( TimeOrStockLimitConditionController): diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 349d01d7..d25a79bb 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,6 +1,5 @@ import itertools -from django.db.models import Q from django.db.models import Sum from registrasion import models as rego @@ -33,7 +32,9 @@ class ProductController(object): passed_limits = set( product for product in all_products - if CategoryController(product.category).user_quantity_remaining(user) > 0 + if CategoryController(product.category).user_quantity_remaining( + user + ) > 0 if cls(product).user_quantity_remaining(user) > 0 ) diff --git a/registrasion/tests/cart_controller_helper.py b/registrasion/tests/cart_controller_helper.py index 9176f2c8..05782297 100644 --- a/registrasion/tests/cart_controller_helper.py +++ b/registrasion/tests/cart_controller_helper.py @@ -3,6 +3,7 @@ from registrasion import models as rego from django.core.exceptions import ObjectDoesNotExist + class TestingCartController(CartController): def set_quantity(self, product, quantity, batched=False): diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 45c18ef0..1de72bc9 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -126,7 +126,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): quantity=10, ).save() - @classmethod def new_voucher(self, code="VOUCHER", limit=1): voucher = rego.Voucher.objects.create( diff --git a/registrasion/views.py b/registrasion/views.py index f7e87694..192f401d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -14,7 +14,6 @@ from django.contrib.auth.decorators import login_required from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError -from django.db import transaction from django.http import Http404 from django.shortcuts import redirect from django.shortcuts import render From a4d684f444e7ef3a8a3e19de1c23a69a13f4c892 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 3 Apr 2016 15:25:39 +1000 Subject: [PATCH 094/418] Raises limits errors in the right parts of the form --- registrasion/controllers/cart.py | 32 ++++++++++++++++++---------- registrasion/controllers/category.py | 2 +- registrasion/exceptions.py | 4 ++++ registrasion/views.py | 16 +++++++++++--- 4 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 registrasion/exceptions.py diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 95a2b921..a643863f 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -10,6 +10,7 @@ from django.db.models import Max from django.utils import timezone from registrasion import models as rego +from registrasion.exceptions import CartValidationError from category import CategoryController from conditions import ConditionController @@ -114,22 +115,25 @@ class CartController(object): ''' Tests that the quantity changes we intend to make do not violate the limits and enabling conditions imposed on the products. ''' + errors = [] + # Test each product limit here for product, quantity in product_quantities: if quantity < 0: # TODO: batch errors - raise ValidationError("Value must be zero or greater.") + errors.append((product, "Value must be zero or greater.")) prod = ProductController(product) limit = prod.user_quantity_remaining(self.cart.user) if quantity > limit: # TODO: batch errors - raise ValidationError( + errors.append(( + product, "You may only have %d of product: %s" % ( - limit, product.name, + limit, product, ) - ) + )) # Collect by category by_cat = collections.defaultdict(list) @@ -137,20 +141,21 @@ class CartController(object): by_cat[product.category].append((product, quantity)) # Test each category limit here - for cat in by_cat: - ctrl = CategoryController(cat) + for category in by_cat: + ctrl = CategoryController(category) limit = ctrl.user_quantity_remaining(self.cart.user) # Get the amount so far in the cart - to_add = sum(i[1] for i in by_cat[cat]) + to_add = sum(i[1] for i in by_cat[category]) if to_add > limit: # TODO: batch errors - raise ValidationError( + errors.append(( + category, "You may only have %d items in category: %s" % ( - limit, cat.name, + limit, category.name, ) - ) + )) # Test the enabling conditions errs = ConditionController.test_enabling_conditions( @@ -160,7 +165,12 @@ class CartController(object): if errs: # TODO: batch errors - raise ValidationError("An enabling condition failed") + errors.append( + ("enabling_conditions", "An enabling condition failed") + ) + + if errors: + raise CartValidationError(errors) def apply_voucher(self, voucher_code): ''' Applies the voucher with the given code to this cart. ''' diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 9ab9dd3b..04f502db 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -53,4 +53,4 @@ class CategoryController(object): ) cat_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - cat_limit - cat_count + return cat_limit - cat_count diff --git a/registrasion/exceptions.py b/registrasion/exceptions.py new file mode 100644 index 00000000..ac6624d9 --- /dev/null +++ b/registrasion/exceptions.py @@ -0,0 +1,4 @@ +from django.core.exceptions import ValidationError + +class CartValidationError(ValidationError): + pass diff --git a/registrasion/views.py b/registrasion/views.py index 192f401d..77c00bb3 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -6,6 +6,7 @@ from registrasion.controllers import discount from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.product import ProductController +from registrasion.exceptions import CartValidationError from collections import namedtuple @@ -321,15 +322,24 @@ def handle_products(request, category, products, prefix): def set_quantities_from_products_form(products_form, current_cart): - quantities = products_form.product_quantities() + quantities = list(products_form.product_quantities()) product_quantities = [ (rego.Product.objects.get(pk=i[0]), i[1]) for i in quantities ] + field_names = dict( + (i[0][0], i[1][2]) for i in zip(product_quantities, quantities) + ) try: current_cart.set_quantities(product_quantities) - except ValidationError as ve: - products_form.add_error(None, ve) + except CartValidationError as ve: + for ve_field in ve.error_list: + product, message = ve_field.message + if product in field_names: + field = field_names[product] + else: + field = None + products_form.add_error(field, message) def handle_voucher(request, prefix): From f5d9458d1a9c9fe6eb349f1f7175c82a395ed7d6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 10:22:44 +1000 Subject: [PATCH 095/418] Adds a validation based on available_products to validate_cart, and a test based on simple enabling conditions --- registrasion/controllers/cart.py | 37 ++++++++++++++++--- registrasion/tests/test_enabling_condition.py | 16 ++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index a643863f..76c3adef 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -203,15 +203,35 @@ class CartController(object): ''' 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 = [] + # TODO: validate vouchers - items = rego.ProductItem.objects.filter(cart=self.cart) + items = rego.ProductItem.objects.filter(cart=cart) + + products = set(i.product for i in items) + available = set(ProductController.available_products( + user, + products=products, + )) + + if products != available: + # Then we have products that aren't available any more. + for product in products: + if product not in available: + message = "%s is no longer available to you." % product + errors.append(ValidationError(message)) product_quantities = list((i.product, i.quantity) for i in items) - self._test_limits(product_quantities) + try: + self._test_limits(product_quantities) + except ValidationError as ve: + errors.append(ve) # Validate the discounts - discount_items = rego.DiscountItem.objects.filter(cart=self.cart) + discount_items = rego.DiscountItem.objects.filter(cart=cart) seen_discounts = set() for discount_item in discount_items: @@ -223,12 +243,17 @@ class CartController(object): pk=discount.pk) cond = ConditionController.for_condition(real_discount) - if not cond.is_met(self.cart.user): - raise ValidationError("Discounts are no longer available") + if not cond.is_met(user): + errors.append( + ValidationError("Discounts are no longer available") + ) + if errors: + raise ValidationError(errors) + + @transaction.atomic def recalculate_discounts(self): ''' Calculates all of the discounts available for this product. - NB should be transactional, and it's terribly inefficient. ''' # Delete the existing entries. diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index e6c090dd..2e992d7a 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -293,3 +293,19 @@ class EnablingConditionTestCases(RegistrationCartTestCase): self.assertTrue(self.CAT_1 in cats) self.assertTrue(self.CAT_2 in cats) + + def test_validate_cart_when_enabling_conditions_become_unmet(self): + self.add_product_enabling_condition(mandatory=False) + + 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() From 7d97d2d2dea12ac974ba8fdc82e55d2d0e957b50 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 11:36:07 +1000 Subject: [PATCH 096/418] Adds fix_simple_errors to cart - it zeroes out unavailable products. Adds test that it does that. --- registrasion/controllers/cart.py | 23 ++++++++++++++- registrasion/tests/test_enabling_condition.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 76c3adef..6fef142f 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -210,7 +210,6 @@ class CartController(object): # TODO: validate vouchers items = rego.ProductItem.objects.filter(cart=cart) - products = set(i.product for i in items) available = set(ProductController.available_products( user, @@ -251,6 +250,28 @@ class CartController(object): if errors: raise ValidationError(errors) + 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. ''' + + # TODO: fix vouchers first (this affects available discounts) + + # Fix products and discounts + items = rego.ProductItem.objects.filter(cart=self.cart) + 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. diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 2e992d7a..6dd3d102 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -309,3 +309,32 @@ class EnablingConditionTestCases(RegistrationCartTestCase): # Should fail with self.assertRaises(ValidationError): cart.validate_cart() + + def test_fix_simple_errors_resolves_unavailable_products(self): + self.test_validate_cart_when_enabling_conditions_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 = rego.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 = rego.ProductItem.objects.filter(cart=cart.cart) + self.assertTrue([i for i in items if i.product == self.PROD_1]) From 6f28c20b706f27feb665bdafc0dd99bce2b639a2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 12:09:16 +1000 Subject: [PATCH 097/418] Factors _test_voucher() method into CartController --- registrasion/controllers/cart.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 6fef142f..339d7ba1 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -175,16 +175,26 @@ class CartController(object): def apply_voucher(self, voucher_code): ''' Applies the voucher with the given code to this cart. ''' - # Is voucher exhausted? - active_carts = rego.Cart.reserved_carts() - # Try and find the voucher voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + self._test_voucher(voucher) + + # If successful... + self.cart.vouchers.add(voucher) + self.end_batch() + + def _test_voucher(self, voucher): + ''' Tests whether this voucher is allowed to be applied to this cart. + Raises ValidationError if not. ''' + + # Is voucher exhausted? + active_carts = rego.Cart.reserved_carts() + # It's invalid for a user to enter a voucher that's exhausted carts_with_voucher = active_carts.filter(vouchers=voucher) if len(carts_with_voucher) >= voucher.limit: - raise ValidationError("This voucher is no longer available") + 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 = rego.Cart.objects.filter( @@ -195,9 +205,6 @@ class CartController(object): if len(user_carts_with_voucher) > 0: raise ValidationError("You have already entered this voucher.") - # If successful... - self.cart.vouchers.add(voucher) - self.end_batch() def validate_cart(self): ''' Determines whether the status of the current cart is valid; From 8d07518a9bc6412fece1ea0906cbf85446cef921 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 12:22:32 +1000 Subject: [PATCH 098/418] Fixes an incorrect voucher test --- registrasion/tests/test_voucher.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index de47c864..b9373583 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -114,6 +114,8 @@ class VoucherTestCases(RegistrationCartTestCase): inv = InvoiceController.for_cart(current_cart.cart) inv.pay("Hello!", inv.invoice.value) + current_cart = TestingCartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): current_cart.apply_voucher(voucher.code) From 0d57da8d6f668550d053d156f4841456fd06c71d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 12:48:05 +1000 Subject: [PATCH 099/418] Makes apply_voucher() idempotent, adds _test_voucher to validate_cart, and updates tests. --- registrasion/controllers/cart.py | 29 +++++++++++++++++++++++++---- registrasion/tests/test_voucher.py | 12 +++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 339d7ba1..0fdc8f76 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -178,6 +178,10 @@ class CartController(object): # Try and find the voucher voucher = rego.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... @@ -193,18 +197,32 @@ class CartController(object): # 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 len(carts_with_voucher) >= 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 = rego.Cart.objects.filter( + user_carts_with_voucher = carts_with_voucher.filter( user=self.cart.user, - released=False, - vouchers=voucher, ) + if len(user_carts_with_voucher) > 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(ve)) def validate_cart(self): ''' Determines whether the status of the current cart is valid; @@ -214,7 +232,10 @@ class CartController(object): user = self.cart.user errors = [] - # TODO: validate vouchers + try: + self._test_vouchers(self.cart.vouchers.all()) + except ValidationError as ve: + errors.append(ve) items = rego.ProductItem.objects.filter(cart=cart) products = set(i.product for i in items) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index b9373583..732b19f1 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -37,11 +37,11 @@ class VoucherTestCases(RegistrationCartTestCase): cart_2.cart.active = False cart_2.cart.save() - # After the reservation duration, user 1 should not be able to apply - # voucher, as user 2 has paid for their cart. + # After the reservation duration, even though the voucher has applied, + # it exceeds the number of vouchers available. self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) with self.assertRaises(ValidationError): - cart_1.apply_voucher(voucher.code) + cart_1.validate_cart() def test_voucher_enables_item(self): voucher = self.new_voucher() @@ -103,8 +103,10 @@ class VoucherTestCases(RegistrationCartTestCase): voucher = self.new_voucher(limit=2) current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) - with self.assertRaises(ValidationError): - 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) From c8c16072ba8790b5e4bbe13477af736d4494da74 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 13:01:25 +1000 Subject: [PATCH 100/418] fix_simple_errors() now removes exhausted vouchers from the voucher set. --- registrasion/controllers/cart.py | 18 ++++++++++++++---- registrasion/tests/test_voucher.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 0fdc8f76..d4aa082a 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -198,7 +198,7 @@ class CartController(object): # 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 len(carts_with_voucher) >= voucher.limit: + 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 @@ -206,7 +206,7 @@ class CartController(object): user=self.cart.user, ) - if len(user_carts_with_voucher) > 0: + if user_carts_with_voucher.count() > 0: raise ValidationError("You have already entered this voucher.") def _test_vouchers(self, vouchers): @@ -278,13 +278,24 @@ class CartController(object): if errors: raise ValidationError(errors) + @transaction.atomic def fix_simple_errors(self): ''' This attempts to fix the easy errors raised by ValidationError. This includes removing items from the cart that are no longer available, recalculating all of the discounts, and removing voucher codes that are no longer available. ''' - # TODO: fix vouchers first (this affects available discounts) + # Fix vouchers first (this affects available discounts) + active_carts = rego.Cart.reserved_carts() + to_remove = [] + for voucher in self.cart.vouchers.all(): + try: + self._test_voucher(voucher) + except ValidationError as ve: + to_remove.append(voucher) + + for voucher in to_remove: + self.cart.vouchers.remove(voucher) # Fix products and discounts items = rego.ProductItem.objects.filter(cart=self.cart) @@ -299,7 +310,6 @@ class CartController(object): self.set_quantities(zeros) - @transaction.atomic def recalculate_discounts(self): ''' Calculates all of the discounts available for this product. diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 732b19f1..e3ac0d0d 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -43,6 +43,18 @@ class VoucherTestCases(RegistrationCartTestCase): 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() @@ -137,3 +149,11 @@ class VoucherTestCases(RegistrationCartTestCase): inv.refund("Hello!", inv.invoice.value) 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()) From 39b130811c16c9889ce27ce22a291297b0aa0bb0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 14:28:37 +1000 Subject: [PATCH 101/418] Removes superfluous test --- registrasion/controllers/cart.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index d4aa082a..e8310f4b 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -238,24 +238,13 @@ class CartController(object): errors.append(ve) items = rego.ProductItem.objects.filter(cart=cart) - products = set(i.product for i in items) - available = set(ProductController.available_products( - user, - products=products, - )) - - if products != available: - # Then we have products that aren't available any more. - for product in products: - if product not in available: - message = "%s is no longer available to you." % product - errors.append(ValidationError(message)) product_quantities = list((i.product, i.quantity) for i in items) try: self._test_limits(product_quantities) except ValidationError as ve: - errors.append(ve) + for error in ve.error_list: + errors.append(error.message[1]) # Validate the discounts discount_items = rego.DiscountItem.objects.filter(cart=cart) From 0340b6da20640b51386a45708b5e528ad00a4b74 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 14:34:16 +1000 Subject: [PATCH 102/418] =?UTF-8?q?Adds=20=E2=80=9Cfix=5Ferrors=E2=80=9D?= =?UTF-8?q?=20query=20to=20=E2=80=9Ccheckout=E2=80=9D,=20which=20allows=20?= =?UTF-8?q?users=20to=20have=20issues=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/views.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index 77c00bb3..3f9dbf4f 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -377,10 +377,30 @@ def checkout(request): invoice. ''' current_cart = CartController.for_user(request.user) - current_invoice = InvoiceController.for_cart(current_cart.cart) + + if "fix_errors" in request.GET and request.GET["fix_errors"] == "true": + current_cart.fix_simple_errors() + + try: + current_invoice = InvoiceController.for_cart(current_cart.cart) + except ValidationError as ve: + return checkout_errors(request, ve) return redirect("invoice", current_invoice.invoice.id) +def checkout_errors(request, errors): + + error_list = [] + for error in errors.error_list: + if isinstance(error, tuple): + error = error[1] + error_list.append(error) + + data = { + "error_list": error_list, + } + + return render(request, "registrasion/checkout_errors.html", data) @login_required def invoice(request, invoice_id): From 40bc5985f4b8149eea9ad8ad094fdde81498e017 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 15:40:16 +1000 Subject: [PATCH 103/418] Propagates the error messages up from enabling condition testing --- registrasion/controllers/cart.py | 9 ++----- registrasion/controllers/conditions.py | 37 +++++++++++++++++++++++++- registrasion/views.py | 2 ++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index e8310f4b..def22c17 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -120,14 +120,12 @@ class CartController(object): # Test each product limit here for product, quantity in product_quantities: if quantity < 0: - # TODO: batch errors errors.append((product, "Value must be zero or greater.")) prod = ProductController(product) limit = prod.user_quantity_remaining(self.cart.user) if quantity > limit: - # TODO: batch errors errors.append(( product, "You may only have %d of product: %s" % ( @@ -149,7 +147,6 @@ class CartController(object): to_add = sum(i[1] for i in by_cat[category]) if to_add > limit: - # TODO: batch errors errors.append(( category, "You may only have %d items in category: %s" % ( @@ -164,10 +161,8 @@ class CartController(object): ) if errs: - # TODO: batch errors - errors.append( - ("enabling_conditions", "An enabling condition failed") - ) + for error in errs: + errors.append(error) if errors: raise CartValidationError(errors) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 9a0b26df..55b1a7d5 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -44,6 +44,26 @@ class ConditionController(object): except KeyError: return ConditionController() + + 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 test_enabling_conditions( cls, user, products=None, product_quantities=None): @@ -83,6 +103,8 @@ class ConditionController(object): # if there are no mandatory conditions non_mandatory = defaultdict(lambda: False) + messages = {} + for condition in all_conditions: cond = cls.for_condition(condition) remainder = cond.user_quantity_remaining(user) @@ -104,12 +126,21 @@ class ConditionController(object): consumed = 1 met = consumed <= remainder + if not met: + items = ", ".join(str(product) for product in all_products) + base = cls.MESSAGE[remainder == 0][len(all_products) == 1] + message = base % {"items": items, "remainder": remainder} + for product in all_products: if condition.mandatory: mandatory[product] &= met else: non_mandatory[product] |= met + if not met and product not in messages: + messages[product] = message + + valid = defaultdict(lambda: True) for product in itertools.chain(mandatory, non_mandatory): if product in mandatory: @@ -119,7 +150,11 @@ class ConditionController(object): # Otherwise, we need just one non-mandatory condition met valid[product] = non_mandatory[product] - error_fields = [product for product in valid if not valid[product]] + error_fields = [ + (product, messages[product]) + for product in valid if not valid[product] + ] + return error_fields def user_quantity_remaining(self, user): diff --git a/registrasion/views.py b/registrasion/views.py index 3f9dbf4f..1cb4ec57 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -337,6 +337,8 @@ def set_quantities_from_products_form(products_form, current_cart): product, message = ve_field.message if product in field_names: field = field_names[product] + elif isinstance(product, rego.Product): + continue else: field = None products_form.add_error(field, message) From cc318dfa9ba8a38d82a2edd1490cad586560c9d3 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 16:09:57 +1000 Subject: [PATCH 104/418] Fixes tests --- registrasion/controllers/product.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index d25a79bb..b49d5569 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -38,9 +38,10 @@ class ProductController(object): if cls(product).user_quantity_remaining(user) > 0 ) - failed_conditions = set(ConditionController.test_enabling_conditions( + failed_and_messages = ConditionController.test_enabling_conditions( 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) From 4021aa3c8ebc7d414fe7a751b6e28bf1b4a73263 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 16:33:04 +1000 Subject: [PATCH 105/418] =?UTF-8?q?Resolves=20#12=20=E2=80=94=20each=20ite?= =?UTF-8?q?m=20category=20shows=20what=20items=20you=20have=20already=20pu?= =?UTF-8?q?rchased=20in=20each=20category?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/templatetags/registrasion_tags.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 6de13e74..63a2bc24 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -35,14 +35,18 @@ def items_pending(context): @register.assignment_tag(takes_context=True) -def items_purchased(context): - ''' Returns all of the items that this user has purchased ''' +def items_purchased(context, category=None): + ''' Returns all of the items that this user has purchased, optionally + from the given category. ''' all_items = rego.ProductItem.objects.filter( cart__user=context.request.user, cart__active=False, ) + if category: + all_items = all_items.filter(product__category=category) + products = set(item.product for item in all_items) out = [] for product in products: From 812cc0b9c8348b4b3e072e0ad5b729008ff91eb5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 16:45:54 +1000 Subject: [PATCH 106/418] =?UTF-8?q?Resolves=20#6=20=E2=80=94=20Help=20text?= =?UTF-8?q?=20for=20items=20without=20a=20description=20is=20much=20much?= =?UTF-8?q?=20nicer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/forms.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index f1527aa5..7a1e1f12 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -47,7 +47,10 @@ class _QuantityBoxProductsForm(_ProductsForm): @classmethod def set_fields(cls, category, products): for product in products: - help_text = "$%d -- %s" % (product.price, product.description) + 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, From c9a62db774c57ec5b2b057787aedb84a401a1813 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 17:02:11 +1000 Subject: [PATCH 107/418] Resolves #17 - cannot generate invoice if there are no product items --- registrasion/controllers/invoice.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index fdea324b..dafb134b 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -67,8 +67,11 @@ class InvoiceController(object): ) invoice.save() - # TODO: calculate line items. product_items = rego.ProductItem.objects.filter(cart=cart) + + if len(product_items) == 0: + raise ValidationError("Your cart is empty.") + product_items = product_items.order_by( "product__category__order", "product__order" ) From 8ad265a65a089eae32449a0d0e7f88de6b3211b5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 17:19:09 +1000 Subject: [PATCH 108/418] Fixes tests now that $0 invoices pay themselves --- registrasion/controllers/invoice.py | 9 ++++---- registrasion/tests/test_discount.py | 10 ++++----- registrasion/tests/test_invoice.py | 35 ++++++++++++++++++++++++++--- registrasion/tests/test_voucher.py | 8 ++++--- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index dafb134b..264049c8 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -57,6 +57,7 @@ class InvoiceController(object): return value @classmethod + @transaction.atomic def _generate(cls, cart): ''' Generates an invoice for the given cart. ''' invoice = rego.Invoice.objects.create( @@ -65,7 +66,6 @@ class InvoiceController(object): cart_revision=cart.revision, value=Decimal() ) - invoice.save() product_items = rego.ProductItem.objects.filter(cart=cart) @@ -85,7 +85,6 @@ class InvoiceController(object): quantity=item.quantity, price=product.price, ) - line_item.save() invoice_value += line_item.quantity * line_item.price for item in discount_items: @@ -95,11 +94,13 @@ class InvoiceController(object): quantity=item.quantity, price=cls.resolve_discount_value(item) * -1, ) - line_item.save() invoice_value += line_item.quantity * line_item.price - # TODO: calculate line items from discounts invoice.value = invoice_value + + if invoice.value == 0: + invoice.paid = True + invoice.save() return invoice diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 1fb4225d..860a90a3 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -346,9 +346,8 @@ class DiscountTestCase(RegistrationCartTestCase): discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) self.assertEqual(2, discounts[0].quantity) - inv = InvoiceController.for_cart(cart.cart) - inv.pay("Dummy reference", inv.invoice.value) - self.assertTrue(inv.invoice.paid) + cart.cart.active = False + cart.cart.save() def test_discount_quantity_is_correct_after_first_purchase(self): self.test_discount_quantity_is_correct_before_first_purchase() @@ -358,9 +357,8 @@ class DiscountTestCase(RegistrationCartTestCase): discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) self.assertEqual(1, discounts[0].quantity) - inv = InvoiceController.for_cart(cart.cart) - inv.pay("Dummy reference", inv.invoice.value) - self.assertTrue(inv.invoice.paid) + cart.cart.active = False + cart.cart.save() def test_discount_is_gone_after_quantity_exhausted(self): self.test_discount_quantity_is_correct_after_first_purchase() diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index fd6c9cfb..5dc1559b 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -83,18 +83,16 @@ class InvoiceTestCase(RegistrationCartTestCase): code="VOUCHER", limit=1 ) - voucher.save() discount = rego.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) - discount.save() rego.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(50), quantity=1 - ).save() + ) current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) @@ -111,6 +109,32 @@ class InvoiceTestCase(RegistrationCartTestCase): self.PROD_1.price * Decimal("0.5"), invoice_1.invoice.value) + def test_zero_value_invoice_is_automatically_paid(self): + voucher = rego.Voucher.objects.create( + recipient="Voucher recipient", + code="VOUCHER", + limit=1 + ) + discount = rego.VoucherDiscount.objects.create( + description="VOUCHER RECIPIENT", + voucher=voucher, + ) + rego.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) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + self.assertTrue(invoice_1.invoice.paid) + def test_invoice_voids_self_if_cart_is_invalid(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -169,3 +193,8 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice_1.void() + + def test_cannot_generate_blank_invoice(self): + current_cart = TestingCartController.for_user(self.USER_1) + with self.assertRaises(ValidationError): + invoice_1 = InvoiceController.for_cart(current_cart.cart) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index e3ac0d0d..1502c2fc 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -125,8 +125,8 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) - inv = InvoiceController.for_cart(current_cart.cart) - inv.pay("Hello!", inv.invoice.value) + current_cart.cart.active = False + current_cart.cart.save() current_cart = TestingCartController.for_user(self.USER_1) @@ -139,9 +139,11 @@ class VoucherTestCases(RegistrationCartTestCase): 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 = InvoiceController.for_cart(current_cart.cart) - inv.pay("Hello!", inv.invoice.value) + if not inv.invoice.paid: + inv.pay("Hello!", inv.invoice.value) current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): From 2f77f5bb23ee7a98cb03cd9395aa728598ebc220 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 17:24:25 +1000 Subject: [PATCH 109/418] Replaces .active = False; .save() pattern in tests with a test controller method --- registrasion/tests/cart_controller_helper.py | 4 +++ registrasion/tests/test_cart.py | 28 +++++++++---------- registrasion/tests/test_ceilings.py | 18 ++++++------ registrasion/tests/test_discount.py | 28 +++++++++---------- registrasion/tests/test_enabling_condition.py | 20 ++++++------- registrasion/tests/test_voucher.py | 8 +++--- 6 files changed, 55 insertions(+), 51 deletions(-) diff --git a/registrasion/tests/cart_controller_helper.py b/registrasion/tests/cart_controller_helper.py index 05782297..9e4191f0 100644 --- a/registrasion/tests/cart_controller_helper.py +++ b/registrasion/tests/cart_controller_helper.py @@ -24,3 +24,7 @@ class TestingCartController(CartController): except ObjectDoesNotExist: old_quantity = 0 self.set_quantity(product, old_quantity + quantity) + + def next_cart(self): + self.cart.active = False + self.cart.save() diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 1de72bc9..df5e290f 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -74,12 +74,12 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): # Burn through some carts -- this made some past EC tests fail current_cart = TestingCartController.for_user(cls.USER_1) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() current_cart = TestingCartController.for_user(cls.USER_2) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): @@ -142,8 +142,8 @@ class BasicCartTests(RegistrationCartTestCase): def test_get_cart(self): current_cart = TestingCartController.for_user(self.USER_1) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() old_cart = current_cart @@ -214,8 +214,8 @@ class BasicCartTests(RegistrationCartTestCase): with self.assertRaises(ValidationError): current_cart.add_to_cart(self.PROD_1, 10) - current_cart.cart.active = False - current_cart.cart.save() + + 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, @@ -272,8 +272,8 @@ class BasicCartTests(RegistrationCartTestCase): with self.assertRaises(ValidationError): current_cart.add_to_cart(self.PROD_3, 1) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() current_cart = TestingCartController.for_user(self.USER_1) # The category limit should extend across carts @@ -298,8 +298,8 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_4, 1) # The limits should extend across carts... - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() current_cart = TestingCartController.for_user(self.USER_1) current_cart.set_quantity(self.PROD_3, 4) @@ -325,8 +325,8 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.add_to_cart(item, quantity) self.assertTrue(item in prods) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() current_cart = TestingCartController.for_user(self.USER_1) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 94b1c311..cc333c29 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -91,8 +91,8 @@ class CeilingsTestCases(RegistrationCartTestCase): second_cart.add_to_cart(self.PROD_1, 1) # User 2 pays for their cart - second_cart.cart.active = False - second_cart.cart.save() + + 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 @@ -128,8 +128,8 @@ class CeilingsTestCases(RegistrationCartTestCase): first_cart.validate_cart() # Paid cart outside the reservation window - second_cart.cart.active = False - second_cart.cart.save() + + second_cart.next_cart() self.add_timedelta(self.RESERVATION + datetime.timedelta(seconds=1)) with self.assertRaises(ValidationError): first_cart.validate_cart() @@ -140,15 +140,15 @@ class CeilingsTestCases(RegistrationCartTestCase): first_cart = TestingCartController.for_user(self.USER_1) first_cart.add_to_cart(self.PROD_1, 1) - first_cart.cart.active = False - first_cart.cart.save() + + 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.released = True - first_cart.cart.save() + first_cart.next_cart() second_cart.add_to_cart(self.PROD_1, 1) @@ -176,8 +176,8 @@ class CeilingsTestCases(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) self.assertEqual(1, len(cart.cart.discountitem_set.all())) - cart.cart.active = False - cart.cart.save() + + cart.next_cart() # The second cart has no voucher attached, so should apply the # ceiling discount diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 860a90a3..c4211c0f 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -168,8 +168,8 @@ class DiscountTestCase(RegistrationCartTestCase): # Enable the discount during the first cart. cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) - cart.cart.active = False - cart.cart.save() + + cart.next_cart() # Use the discount in the second cart cart = TestingCartController.for_user(self.USER_1) @@ -177,8 +177,8 @@ class DiscountTestCase(RegistrationCartTestCase): # The discount should be applied. self.assertEqual(1, len(cart.cart.discountitem_set.all())) - cart.cart.active = False - cart.cart.save() + + cart.next_cart() # The discount should respect the total quantity across all # of the user's carts. @@ -197,8 +197,8 @@ class DiscountTestCase(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) # This would exhaust discount if present cart.add_to_cart(self.PROD_2, 2) - cart.cart.active = False - cart.cart.save() + + cart.next_cart() self.add_discount_prod_1_includes_prod_2() cart = TestingCartController.for_user(self.USER_1) @@ -346,8 +346,8 @@ class DiscountTestCase(RegistrationCartTestCase): discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) self.assertEqual(2, discounts[0].quantity) - cart.cart.active = False - cart.cart.save() + + cart.next_cart() def test_discount_quantity_is_correct_after_first_purchase(self): self.test_discount_quantity_is_correct_before_first_purchase() @@ -357,8 +357,8 @@ class DiscountTestCase(RegistrationCartTestCase): discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) self.assertEqual(1, discounts[0].quantity) - cart.cart.active = False - cart.cart.save() + + cart.next_cart() def test_discount_is_gone_after_quantity_exhausted(self): self.test_discount_quantity_is_correct_after_first_purchase() @@ -388,12 +388,12 @@ class DiscountTestCase(RegistrationCartTestCase): self.assertEqual(1, len(discounts)) cart.cart.active = False # Keep discount enabled - cart.cart.save() + cart.next_cart() cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 2) # The discount will be exhausted - cart.cart.active = False - cart.cart.save() + + cart.next_cart() discounts = discount.available_discounts( self.USER_1, @@ -403,7 +403,7 @@ class DiscountTestCase(RegistrationCartTestCase): self.assertEqual(0, len(discounts)) cart.cart.released = True - cart.cart.save() + cart.next_cart() discounts = discount.available_discounts( self.USER_1, diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 6dd3d102..bedb6c86 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -68,8 +68,8 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_2, 1) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() # Create new cart and try to add PROD_1 current_cart = TestingCartController.for_user(self.USER_1) @@ -103,8 +103,8 @@ class EnablingConditionTestCases(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_3, 1) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() # Create new cart and try to add PROD_1 current_cart = TestingCartController.for_user(self.USER_1) @@ -241,15 +241,15 @@ class EnablingConditionTestCases(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) - cart.cart.active = False - cart.cart.save() + + 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.released = True - cart.cart.save() + cart.next_cart() with self.assertRaises(ValidationError): cart_2.set_quantity(self.PROD_1, 1) @@ -260,15 +260,15 @@ class EnablingConditionTestCases(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) - cart.cart.active = False - cart.cart.save() + + 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.released = True - cart.cart.save() + cart.next_cart() with self.assertRaises(ValidationError): cart_2.set_quantity(self.PROD_1, 1) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 1502c2fc..a8686fb1 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -34,8 +34,8 @@ class VoucherTestCases(RegistrationCartTestCase): # user 2 should be able to apply voucher self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) cart_2.apply_voucher(voucher.code) - cart_2.cart.active = False - cart_2.cart.save() + + cart_2.next_cart() # After the reservation duration, even though the voucher has applied, # it exceeds the number of vouchers available. @@ -125,8 +125,8 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code) - current_cart.cart.active = False - current_cart.cart.save() + + current_cart.next_cart() current_cart = TestingCartController.for_user(self.USER_1) From 53413388e016c9353fbd4844690f3deacd309f65 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 18:28:33 +1000 Subject: [PATCH 110/418] Optimises queries through simplifying repeated queries and select_related use --- registrasion/controllers/cart.py | 11 ++++- registrasion/controllers/category.py | 2 +- registrasion/controllers/conditions.py | 31 ++++++++++---- registrasion/controllers/discount.py | 18 ++++++-- registrasion/controllers/product.py | 15 +++++-- .../templatetags/registrasion_tags.py | 15 +++---- registrasion/views.py | 41 +++++++++++++------ 7 files changed, 95 insertions(+), 38 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index def22c17..abf56357 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -80,6 +80,11 @@ class CartController(object): pairs. ''' items_in_cart = rego.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 @@ -283,6 +288,7 @@ class CartController(object): # Fix products and discounts items = rego.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, @@ -302,7 +308,9 @@ class CartController(object): # Delete the existing entries. rego.DiscountItem.objects.filter(cart=self.cart).delete() - product_items = self.cart.productitem_set.all() + product_items = self.cart.productitem_set.all().select_related( + "product", "product__category", + ) products = [i.product for i in product_items] discounts = discount.available_discounts(self.cart.user, [], products) @@ -310,6 +318,7 @@ class CartController(object): # The highest-value discounts will apply to the highest-value # products first. product_items = self.cart.productitem_set.all() + product_items = product_items.select_related("product") product_items = product_items.order_by('product__price') product_items = reversed(product_items) for item in product_items: diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 04f502db..a3753600 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -22,7 +22,7 @@ class CategoryController(object): from product import ProductController if products is AllProducts: - products = rego.Product.objects.all() + products = rego.Product.objects.all().select_related("category") available = ProductController.available_products( user, diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 55b1a7d5..6e5fb82f 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,4 +1,5 @@ import itertools +import operator from collections import defaultdict from collections import namedtuple @@ -90,12 +91,22 @@ class ConditionController(object): quantities = {} # Get the conditions covered by the products themselves - all_conditions = [ - product.enablingconditionbase_set.select_subclasses() | - product.category.enablingconditionbase_set.select_subclasses() + + prods = ( + product.enablingconditionbase_set.select_subclasses() for product in products - ] - all_conditions = set(itertools.chain(*all_conditions)) + ) + # Get the conditions covered by their categories + cats = ( + category.enablingconditionbase_set.select_subclasses() + for category in set(product.category for product in products) + ) + + if products: + # Simplify the query. + all_conditions = reduce(operator.or_, itertools.chain(prods, cats)) + else: + all_conditions = [] # All mandatory conditions on a product need to be met mandatory = defaultdict(lambda: True) @@ -115,10 +126,14 @@ class ConditionController(object): from_category = rego.Product.objects.filter( category__in=condition.categories.all(), ).all() - all_products = set(itertools.chain(cond_products, from_category)) - + all_products = cond_products | from_category + all_products = all_products.select_related("category") # Remove the products that we aren't asking about - all_products = all_products & products + all_products = [ + product + for product in all_products + if product in products + ] if quantities: consumed = sum(quantities[i] for i in all_products) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 084584ee..f03063a8 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -13,7 +13,7 @@ class DiscountAndQuantity(object): self.quantity = quantity def __repr__(self): - print "(discount=%s, clause=%s, quantity=%d)" % ( + return "(discount=%s, clause=%s, quantity=%d)" % ( self.discount, self.clause, self.quantity, ) @@ -37,11 +37,20 @@ def available_discounts(user, categories, products): ) # (Not relevant: discounts that match products in provided categories) + product_discounts = product_discounts.select_related( + "product", + "product__category", + ) + + all_category_discounts = category_discounts | product_category_discounts + all_category_discounts = all_category_discounts.select_related( + "category", + ) + # The set of all potential discounts potential_discounts = set(itertools.chain( product_discounts, - category_discounts, - product_category_discounts, + all_category_discounts, )) discounts = [] @@ -50,6 +59,7 @@ def available_discounts(user, categories, products): accepted_discounts = set() failed_discounts = set() + for discount in potential_discounts: real_discount = rego.DiscountBase.objects.get_subclass( pk=discount.discount.pk, @@ -63,7 +73,7 @@ def available_discounts(user, categories, products): cart__user=user, cart__active=False, # Only past carts count cart__released=False, # You can reuse refunded discounts - discount=discount.discount, + discount=real_discount, ) agg = past_uses.aggregate(Sum("quantity")) past_use_count = agg["quantity__sum"] diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index b49d5569..4e9e6cab 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -23,18 +23,25 @@ class ProductController(object): if category is not None: all_products = rego.Product.objects.filter(category=category) + all_products = all_products.select_related("category") else: all_products = [] if products is not None: - all_products = itertools.chain(all_products, products) + all_products = set(itertools.chain(all_products, products)) + + cat_quants = dict( + ( + category, + CategoryController(category).user_quantity_remaining(user), + ) + for category in set(product.category for product in all_products) + ) passed_limits = set( product for product in all_products - if CategoryController(product.category).user_quantity_remaining( - user - ) > 0 + if cat_quants[product.category] > 0 if cls(product).user_quantity_remaining(user) > 0 ) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 63a2bc24..17edc8e4 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -30,7 +30,7 @@ def items_pending(context): all_items = rego.ProductItem.objects.filter( cart__user=context.request.user, cart__active=True, - ) + ).select_related("product", "product__category") return all_items @@ -42,17 +42,18 @@ def items_purchased(context, category=None): all_items = rego.ProductItem.objects.filter( cart__user=context.request.user, cart__active=False, - ) + cart__released=False, + ).select_related("product", "product__category") if category: all_items = all_items.filter(product__category=category) - products = set(item.product for item in all_items) + pq = all_items.values("product").annotate(quantity=Sum("quantity")).all() + products = rego.Product.objects.all() out = [] - for product in products: - pp = all_items.filter(product=product) - quantity = pp.aggregate(Sum("quantity"))["quantity__sum"] - out.append(ProductAndQuantity(product, quantity)) + for item in pq: + prod = products.get(pk=item["product"]) + out.append(ProductAndQuantity(prod, item["quantity"])) return out diff --git a/registrasion/views.py b/registrasion/views.py index 1cb4ec57..231783a3 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -115,11 +115,20 @@ def guided_registration(request, page_id=0): current_step = 3 title = "Additional items" + all_products = rego.Product.objects.filter( + category__in=cats, + ).select_related("category") + + available_products = set(ProductController.available_products( + request.user, + products=all_products, + )) + for category in cats: - products = ProductController.available_products( - request.user, - category=category, - ) + products = [ + i for i in available_products + if i.category == category + ] prefix = "category_" + str(category.id) p = handle_products(request, category, products, prefix) @@ -280,15 +289,17 @@ def handle_products(request, category, products, prefix): items = rego.ProductItem.objects.filter( product__in=products, cart=current_cart.cart, - ) + ).select_related("product") quantities = [] - for product in products: - # Only add items that are enabled. - try: - quantity = items.get(product=product).quantity - except ObjectDoesNotExist: - quantity = 0 - quantities.append((product, quantity)) + seen = set() + + for item in items: + quantities.append((item.product, item.quantity)) + seen.add(item.product) + + zeros = set(products) - seen + for product in zeros: + quantities.append((product, 0)) products_form = ProductsForm( request.POST or None, @@ -323,8 +334,12 @@ def handle_products(request, category, products, prefix): def set_quantities_from_products_form(products_form, current_cart): quantities = list(products_form.product_quantities()) + + pks = [i[0] for i in quantities] + products = rego.Product.objects.filter(id__in=pks).select_related("category") + product_quantities = [ - (rego.Product.objects.get(pk=i[0]), i[1]) for i in quantities + (products.get(pk=i[0]), i[1]) for i in quantities ] field_names = dict( (i[0][0], i[1][2]) for i in zip(product_quantities, quantities) From dba37736362d188a804beea817c697c95241134a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 22:12:29 +1000 Subject: [PATCH 111/418] Adds db indices --- .../migrations/0012_auto_20160406_1212.py | 49 +++++++++++++++++++ registrasion/models.py | 27 ++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 registrasion/migrations/0012_auto_20160406_1212.py diff --git a/registrasion/migrations/0012_auto_20160406_1212.py b/registrasion/migrations/0012_auto_20160406_1212.py new file mode 100644 index 00000000..61a27ed1 --- /dev/null +++ b/registrasion/migrations/0012_auto_20160406_1212.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-06 12:12 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0011_auto_20160401_0943'), + ] + + operations = [ + migrations.AlterField( + model_name='cart', + name='active', + field=models.BooleanField(db_index=True, default=True), + ), + migrations.AlterField( + model_name='cart', + name='released', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='cart', + name='time_last_updated', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='category', + name='order', + field=models.PositiveIntegerField(db_index=True, verbose_name='Display order'), + ), + migrations.AlterField( + model_name='product', + name='order', + field=models.PositiveIntegerField(db_index=True, verbose_name='Display order'), + ), + migrations.AlterField( + model_name='productitem', + name='quantity', + field=models.PositiveIntegerField(db_index=True), + ), + migrations.AlterIndexTogether( + name='cart', + index_together=set([('active', 'released'), ('released', 'user'), ('active', 'user'), ('active', 'time_last_updated')]), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 1e0e23de..941ebef5 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -99,6 +99,7 @@ class Category(models.Model): ) order = models.PositiveIntegerField( verbose_name=("Display order"), + db_index=True, ) render_type = models.IntegerField( choices=CATEGORY_RENDER_TYPES, @@ -147,6 +148,7 @@ class Product(models.Model): ) order = models.PositiveIntegerField( verbose_name=("Display order"), + db_index=True, ) @@ -313,6 +315,7 @@ class VoucherDiscount(DiscountBase): Voucher, on_delete=models.CASCADE, verbose_name=_("Voucher"), + db_index=True, ) @@ -458,17 +461,33 @@ class Cart(models.Model): ''' Represents a set of product items that have been purchased, or are pending purchase. ''' + class Meta: + index_together = [ + ("active", "time_last_updated"), + ("active", "released"), + ("active", "user"), + ("released", "user"), + ] + def __str__(self): return "%d rev #%d" % (self.id, self.revision) user = models.ForeignKey(User) # ProductItems (foreign key) vouchers = models.ManyToManyField(Voucher, blank=True) - time_last_updated = models.DateTimeField() + time_last_updated = models.DateTimeField( + db_index=True, + ) reservation_duration = models.DurationField() revision = models.PositiveIntegerField(default=1) - active = models.BooleanField(default=True) - released = models.BooleanField(default=False) # Refunds etc + active = models.BooleanField( + default=True, + db_index=True, + ) + released = models.BooleanField( + default=False, + db_index=True + ) # Refunds etc @classmethod def reserved_carts(cls): @@ -492,7 +511,7 @@ class ProductItem(models.Model): cart = models.ForeignKey(Cart) product = models.ForeignKey(Product) - quantity = models.PositiveIntegerField() + quantity = models.PositiveIntegerField(db_index=True) @python_2_unicode_compatible From 0b7ccfc82719a4efb9c3a575f90b7af349b0a5e4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 6 Apr 2016 22:57:40 +1000 Subject: [PATCH 112/418] Enforces minimum quantity of 0 for quantity boxes --- registrasion/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 7a1e1f12..b7259782 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -55,6 +55,7 @@ class _QuantityBoxProductsForm(_ProductsForm): field = forms.IntegerField( label=product.name, help_text=help_text, + min_value=0, ) cls.base_fields[cls.field_name(product)] = field From 8e95bb746990633fb33910a079dfca52a06e99ee Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 13:26:25 +1000 Subject: [PATCH 113/418] flake8 fixes --- registrasion/controllers/cart.py | 6 +++--- registrasion/controllers/conditions.py | 2 -- registrasion/controllers/discount.py | 1 - registrasion/exceptions.py | 1 + registrasion/forms.py | 5 ++++- registrasion/tests/test_cart.py | 4 ---- registrasion/tests/test_ceilings.py | 2 -- registrasion/tests/test_discount.py | 1 - registrasion/tests/test_enabling_condition.py | 2 -- registrasion/tests/test_invoice.py | 2 +- registrasion/tests/test_voucher.py | 1 - registrasion/views.py | 6 +++++- 12 files changed, 14 insertions(+), 19 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index abf56357..2b711c70 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -199,7 +199,8 @@ class CartController(object): 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) + 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( @@ -275,12 +276,11 @@ class CartController(object): codes that are no longer available. ''' # Fix vouchers first (this affects available discounts) - active_carts = rego.Cart.reserved_carts() to_remove = [] for voucher in self.cart.vouchers.all(): try: self._test_voucher(voucher) - except ValidationError as ve: + except ValidationError: to_remove.append(voucher) for voucher in to_remove: diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 6e5fb82f..03b50118 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -45,7 +45,6 @@ class ConditionController(object): except KeyError: return ConditionController() - SINGLE = True PLURAL = False NONE = True @@ -155,7 +154,6 @@ class ConditionController(object): if not met and product not in messages: messages[product] = message - valid = defaultdict(lambda: True) for product in itertools.chain(mandatory, non_mandatory): if product in mandatory: diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index f03063a8..624e78a1 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -59,7 +59,6 @@ def available_discounts(user, categories, products): accepted_discounts = set() failed_discounts = set() - for discount in potential_discounts: real_discount = rego.DiscountBase.objects.get_subclass( pk=discount.discount.pk, diff --git a/registrasion/exceptions.py b/registrasion/exceptions.py index ac6624d9..6444a462 100644 --- a/registrasion/exceptions.py +++ b/registrasion/exceptions.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError + class CartValidationError(ValidationError): pass diff --git a/registrasion/forms.py b/registrasion/forms.py index b7259782..47dacd3d 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -48,7 +48,10 @@ class _QuantityBoxProductsForm(_ProductsForm): def set_fields(cls, category, products): for product in products: if product.description: - help_text = "$%d each -- %s" % (product.price, product.description) + help_text = "$%d each -- %s" % ( + product.price, + product.description, + ) else: help_text = "$%d each" % product.price diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index df5e290f..d47d659e 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -142,7 +142,6 @@ class BasicCartTests(RegistrationCartTestCase): def test_get_cart(self): current_cart = TestingCartController.for_user(self.USER_1) - current_cart.next_cart() old_cart = current_cart @@ -214,7 +213,6 @@ class BasicCartTests(RegistrationCartTestCase): 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) @@ -272,7 +270,6 @@ class BasicCartTests(RegistrationCartTestCase): 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) @@ -325,7 +322,6 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.add_to_cart(item, quantity) self.assertTrue(item in prods) - current_cart.next_cart() current_cart = TestingCartController.for_user(self.USER_1) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index cc333c29..f8481fea 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -140,7 +140,6 @@ class CeilingsTestCases(RegistrationCartTestCase): 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) @@ -176,7 +175,6 @@ class CeilingsTestCases(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) self.assertEqual(1, len(cart.cart.discountitem_set.all())) - cart.next_cart() # The second cart has no voucher attached, so should apply the diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index c4211c0f..fb35330e 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -5,7 +5,6 @@ from decimal import Decimal from registrasion import models as rego from registrasion.controllers import discount from cart_controller_helper import TestingCartController -from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index bedb6c86..5d0e410c 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -241,7 +241,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): 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) @@ -260,7 +259,6 @@ class EnablingConditionTestCases(RegistrationCartTestCase): 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) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 5dc1559b..19332392 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -197,4 +197,4 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): - invoice_1 = InvoiceController.for_cart(current_cart.cart) + InvoiceController.for_cart(current_cart.cart) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index a8686fb1..a8bc6b00 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -125,7 +125,6 @@ class VoucherTestCases(RegistrationCartTestCase): 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) diff --git a/registrasion/views.py b/registrasion/views.py index 231783a3..b3f9212d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -336,7 +336,9 @@ def set_quantities_from_products_form(products_form, current_cart): quantities = list(products_form.product_quantities()) pks = [i[0] for i in quantities] - products = rego.Product.objects.filter(id__in=pks).select_related("category") + products = rego.Product.objects.filter( + id__in=pks, + ).select_related("category") product_quantities = [ (products.get(pk=i[0]), i[1]) for i in quantities @@ -405,6 +407,7 @@ def checkout(request): return redirect("invoice", current_invoice.invoice.id) + def checkout_errors(request, errors): error_list = [] @@ -419,6 +422,7 @@ def checkout_errors(request, errors): return render(request, "registrasion/checkout_errors.html", data) + @login_required def invoice(request, invoice_id): ''' Displays an invoice for a given invoice id. ''' From ac10ea4ee895cdc087ab72148455c203fb04e142 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 10:19:18 +1000 Subject: [PATCH 114/418] s/cart_controller_helper/controller_helpers/ --- ...art_controller_helper.py => controller_helpers.py} | 0 registrasion/tests/test_cart.py | 2 +- registrasion/tests/test_ceilings.py | 2 +- registrasion/tests/test_enabling_condition.py | 2 +- registrasion/tests/test_invoice.py | 11 +++++++++-- registrasion/tests/test_refund.py | 2 +- registrasion/tests/test_voucher.py | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) rename registrasion/tests/{cart_controller_helper.py => controller_helpers.py} (100%) diff --git a/registrasion/tests/cart_controller_helper.py b/registrasion/tests/controller_helpers.py similarity index 100% rename from registrasion/tests/cart_controller_helper.py rename to registrasion/tests/controller_helpers.py diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d47d659e..10ba10d2 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -10,7 +10,7 @@ from django.test import TestCase from registrasion import models as rego from registrasion.controllers.product import ProductController -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from patch_datetime import SetTimeMixin UTC = pytz.timezone('UTC') diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index f8481fea..c5664481 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -3,7 +3,7 @@ import pytz from django.core.exceptions import ValidationError -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase from registrasion import models as rego diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 5d0e410c..d977cc5c 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from registrasion.controllers.category import CategoryController -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.product import ProductController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 19332392..080ca008 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -5,7 +5,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError from registrasion import models as rego -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -197,4 +197,11 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): - InvoiceController.for_cart(current_cart.cart) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + # TODO: test partially paid invoice cannot be void until payments + # are refunded + + # TODO: test overpaid invoice results in credit note + + # TODO: test credit note generation more generally diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index bde25929..e0b6c681 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,6 +1,6 @@ import pytz -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index a8bc6b00..443ce1d7 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from registrasion import models as rego -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase From 563355485435071e8d254425eeca495af0b0e7a2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 10:23:38 +1000 Subject: [PATCH 115/418] Tests now use TestingInvoiceController --- registrasion/tests/controller_helpers.py | 5 ++++ registrasion/tests/test_discount.py | 3 ++- registrasion/tests/test_invoice.py | 32 ++++++++++++------------ registrasion/tests/test_refund.py | 4 +-- registrasion/tests/test_voucher.py | 4 +-- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 9e4191f0..32af58c8 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -1,4 +1,5 @@ from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego from django.core.exceptions import ObjectDoesNotExist @@ -28,3 +29,7 @@ class TestingCartController(CartController): def next_cart(self): self.cart.active = False self.cart.save() + + +class TestingInvoiceController(InvoiceController): + pass diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index fb35330e..e9105378 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -4,7 +4,8 @@ from decimal import Decimal from registrasion import models as rego from registrasion.controllers import discount -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 080ca008..0a7404d6 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from controller_helpers import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -20,7 +20,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have a single line item line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) self.assertEqual(1, len(line_items)) @@ -29,7 +29,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Adding item to cart should produce a new invoice current_cart.add_to_cart(self.PROD_2, 1) - invoice_2 = InvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) # The old invoice should automatically be voided @@ -58,13 +58,13 @@ class InvoiceTestCase(RegistrationCartTestCase): # Now try to invoice the first user with self.assertRaises(ValidationError): - InvoiceController.for_cart(current_cart.cart) + TestingInvoiceController.for_cart(current_cart.cart) def test_paying_invoice_makes_new_cart(self): current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) - invoice = InvoiceController.for_cart(current_cart.cart) + invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A payment!", invoice.invoice.value) # This payment is for the correct amount invoice should be paid. @@ -99,7 +99,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have two line items line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) @@ -131,7 +131,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) self.assertTrue(invoice_1.invoice.paid) @@ -140,21 +140,21 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) self.assertFalse(invoice_1.invoice.void) # Adding item to cart should produce a new invoice current_cart.add_to_cart(self.PROD_2, 1) - invoice_2 = InvoiceController.for_cart(current_cart.cart) + 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 = InvoiceController(invoice_1.invoice) + invoice_1_new = TestingInvoiceController(invoice_1.invoice) self.assertTrue(invoice_1_new.invoice.void) # Viewing invoice_2's invoice should *not* show it as void - invoice_2_new = InvoiceController(invoice_2.invoice) + invoice_2_new = TestingInvoiceController(invoice_2.invoice) self.assertFalse(invoice_2_new.invoice.void) def test_voiding_invoice_creates_new_invoice(self): @@ -162,12 +162,12 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) self.assertFalse(invoice_1.invoice.void) invoice_1.void() - invoice_2 = InvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) def test_cannot_pay_void_invoice(self): @@ -175,7 +175,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) invoice_1.void() @@ -187,7 +187,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) invoice_1.pay("Reference", invoice_1.invoice.value) @@ -197,7 +197,7 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # TODO: test partially paid invoice cannot be void until payments # are refunded diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index e0b6c681..4510cbda 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,7 +1,7 @@ import pytz from controller_helpers import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -15,7 +15,7 @@ class RefundTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice = InvoiceController.for_cart(current_cart.cart) + invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A Payment!", invoice.invoice.value) self.assertFalse(invoice.invoice.void) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 443ce1d7..7fa709dc 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -7,7 +7,7 @@ from django.db import IntegrityError from registrasion import models as rego from controller_helpers import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -140,7 +140,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) - inv = InvoiceController.for_cart(current_cart.cart) + inv = TestingInvoiceController.for_cart(current_cart.cart) if not inv.invoice.paid: inv.pay("Hello!", inv.invoice.value) From 38cdb8aa63300ff6a3dede12c115e8ab34f746ac Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 08:28:43 +1000 Subject: [PATCH 116/418] Makes invoice model, controller, and test changes to match issue #15 design doc --- registrasion/controllers/invoice.py | 187 +++++++++++------- ...6_2228_squashed_0015_auto_20160406_1942.py | 88 +++++++++ registrasion/models.py | 83 ++++++-- registrasion/tests/controller_helpers.py | 26 ++- registrasion/tests/test_invoice.py | 16 +- registrasion/tests/test_refund.py | 10 +- registrasion/tests/test_voucher.py | 2 +- 7 files changed, 313 insertions(+), 99 deletions(-) create mode 100644 registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 264049c8..a25221f1 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Sum +from django.utils import timezone from registrasion import models as rego @@ -13,6 +14,7 @@ class InvoiceController(object): def __init__(self, invoice): self.invoice = invoice + self.update_status() self.update_validity() # Make sure this invoice is up-to-date @classmethod @@ -22,21 +24,26 @@ class InvoiceController(object): an invoice is generated.''' try: - invoice = rego.Invoice.objects.get( + invoice = rego.Invoice.objects.exclude( + status=rego.Invoice.STATUS_VOID, + ).get( cart=cart, cart_revision=cart.revision, - void=False, ) except ObjectDoesNotExist: cart_controller = CartController(cart) cart_controller.validate_cart() # Raises ValidationError on fail. - # Void past invoices for this cart - rego.Invoice.objects.filter(cart=cart).update(void=True) - + cls.void_all_invoices(cart) invoice = cls._generate(cart) - return InvoiceController(invoice) + return cls(invoice) + + @classmethod + def void_all_invoices(cls, cart): + invoices = rego.Invoice.objects.filter(cart=cart).all() + for invoice in invoices: + cls(invoice).void() @classmethod def resolve_discount_value(cls, item): @@ -60,11 +67,21 @@ class InvoiceController(object): @transaction.atomic def _generate(cls, cart): ''' Generates an invoice for the given cart. ''' + + issued = timezone.now() + reservation_limit = cart.reservation_duration + cart.time_last_updated + # Never generate a due time that is before the issue time + due = max(issued, reservation_limit) + invoice = rego.Invoice.objects.create( user=cart.user, cart=cart, cart_revision=cart.revision, - value=Decimal() + status=rego.Invoice.STATUS_UNPAID, + value=Decimal(), + issue_time=issued, + due_time=due, + recipient="BOB_THOMAS", # TODO: add recipient generating code ) product_items = rego.ProductItem.objects.filter(cart=cart) @@ -84,6 +101,7 @@ class InvoiceController(object): description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, price=product.price, + product=product, ) invoice_value += line_item.quantity * line_item.price @@ -93,94 +111,121 @@ class InvoiceController(object): description=item.discount.description, quantity=item.quantity, price=cls.resolve_discount_value(item) * -1, + product=item.product, ) invoice_value += line_item.quantity * line_item.price invoice.value = invoice_value - if invoice.value == 0: - invoice.paid = True - invoice.save() return invoice + def total_payments(self): + ''' Returns the total amount paid towards this invoice. ''' + + payments = rego.PaymentBase.objects.filter(invoice=self.invoice) + total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0 + return total_paid + + def update_status(self): + ''' Updates the status of this invoice based upon the total + payments.''' + + old_status = self.invoice.status + total_paid = self.total_payments() + num_payments = rego.PaymentBase.objects.filter( + invoice=self.invoice, + ).count() + remainder = self.invoice.value - total_paid + + if old_status == rego.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 == rego.Invoice.STATUS_PAID: + if remainder > 0: + # Invoice went from having a remainder of zero or less + # to having a positive remainder -- must be a refund + self._mark_refunded() + elif old_status == rego.Invoice.STATUS_REFUNDED: + # Should not ever change from here + pass + elif old_status == rego.Invoice.STATUS_VOID: + # Should not ever change from here + pass + + def _mark_paid(self): + ''' Marks the invoice as paid, and updates the attached cart if + necessary. ''' + cart = self.invoice.cart + if cart: + cart.active = False + cart.save() + self.invoice.status = rego.Invoice.STATUS_PAID + self.invoice.save() + + def _mark_refunded(self): + ''' Marks the invoice as refunded, and updates the attached cart if + necessary. ''' + cart = self.invoice.cart + if cart: + cart.active = False + cart.released = True + cart.save() + self.invoice.status = rego.Invoice.STATUS_REFUNDED + self.invoice.save() + + def _mark_void(self): + ''' Marks the invoice as refunded, and updates the attached cart if + necessary. ''' + self.invoice.status = rego.Invoice.STATUS_VOID + self.invoice.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. ''' + cart = self.invoice.cart + if not cart: + return True + + return cart.revision == self.invoice.cart_revision + def update_validity(self): - ''' Updates the validity of this invoice if the cart it is attached to - has updated. ''' - if self.invoice.cart is not None: - if self.invoice.cart.revision != self.invoice.cart_revision: - self.void() + ''' Voids this invoice if the cart it is attached to has updated. ''' + if not self._invoice_matches_cart(): + self.void() def void(self): ''' Voids the invoice if it is valid to do so. ''' - if self.invoice.paid: + if self.invoice.status == rego.Invoice.STATUS_PAID: raise ValidationError("Paid invoices cannot be voided, " "only refunded.") - self.invoice.void = True - self.invoice.save() - - @transaction.atomic - def pay(self, reference, amount): - ''' Pays the invoice by the given amount. If the payment - equals the total on the invoice, finalise the invoice. - (NB should be transactional.) - ''' - if self.invoice.cart: - cart = CartController(self.invoice.cart) - cart.validate_cart() # Raises ValidationError if invalid - - if self.invoice.void: - raise ValidationError("Void invoices cannot be paid") - - if self.invoice.paid: - raise ValidationError("Paid invoices cannot be paid again") - - ''' Adds a payment ''' - payment = rego.Payment.objects.create( - invoice=self.invoice, - reference=reference, - amount=amount, - ) - payment.save() - - payments = rego.Payment.objects.filter(invoice=self.invoice) - agg = payments.aggregate(Sum("amount")) - total = agg["amount__sum"] - - if total == self.invoice.value: - self.invoice.paid = True - - if self.invoice.cart: - cart = self.invoice.cart - cart.active = False - cart.save() - - self.invoice.save() + self._mark_void() @transaction.atomic def refund(self, reference, amount): - ''' Refunds the invoice by the given amount. The invoice is - marked as unpaid, and the underlying cart is marked as released. + ''' Refunds the invoice by the given amount. + + The invoice is marked as refunded, and the underlying cart is marked + as released. + + TODO: replace with credit notes work instead. ''' - if self.invoice.void: + if self.invoice.is_void: raise ValidationError("Void invoices cannot be refunded") - ''' Adds a payment ''' - payment = rego.Payment.objects.create( + # Adds a payment + # TODO: replace by creating a credit note instead + rego.ManualPayment.objects.create( invoice=self.invoice, reference=reference, amount=0 - amount, ) - payment.save() - self.invoice.paid = False - self.invoice.void = True - - if self.invoice.cart: - cart = self.invoice.cart - cart.released = True - cart.save() - - self.invoice.save() + self.update_status() diff --git a/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py new file mode 100644 index 00000000..e4d88313 --- /dev/null +++ b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-07 03:13 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + replaces = [('registrasion', '0013_auto_20160406_2228'), ('registrasion', '0014_auto_20160406_1847'), ('registrasion', '0015_auto_20160406_1942')] + + dependencies = [ + ('registrasion', '0012_auto_20160406_1212'), + ] + + operations = [ + 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)), + ], + ), + migrations.RemoveField( + model_name='payment', + name='invoice', + ), + migrations.RemoveField( + model_name='invoice', + name='paid', + ), + migrations.RemoveField( + model_name='invoice', + name='void', + ), + migrations.AddField( + model_name='invoice', + name='due_time', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='issue_time', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='recipient', + field=models.CharField(default='Lol', max_length=1024), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='status', + field=models.IntegerField(choices=[(1, 'Unpaid'), (2, 'Paid'), (3, 'Refunded'), (4, 'VOID')], db_index=True), + ), + 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.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')), + ], + bases=('registrasion.paymentbase',), + ), + migrations.DeleteModel( + name='Payment', + ), + migrations.AddField( + model_name='paymentbase', + name='invoice', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Invoice'), + ), + migrations.AlterField( + model_name='invoice', + name='cart_revision', + field=models.IntegerField(db_index=True, null=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 941ebef5..fedaa68b 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import datetime import itertools +from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.db import models @@ -26,13 +27,10 @@ class Attendee(models.Model): def get_instance(user): ''' Returns the instance of attendee for the given user, or creates a new one. ''' - attendees = Attendee.objects.filter(user=user) - if len(attendees) > 0: - return attendees[0] - else: - attendee = Attendee(user=user) - attendee.save() - return attendee + try: + return Attendee.objects.get(user=user) + except ObjectDoesNotExist: + return Attendee.objects.create(user=user) user = models.OneToOneField(User, on_delete=models.CASCADE) # Badge/profile is linked @@ -54,6 +52,19 @@ class AttendeeProfileBase(models.Model): speaker profile. If it's None, that functionality is disabled. ''' return None + def invoice_recipient(self): + ''' Returns a representation of this attendee profile for the purpose + of rendering to an invoice. Override in subclasses. ''' + + # Manual dispatch to subclass. Fleh. + slf = AttendeeProfileBase.objects.get_subclass(id=self.id) + # Actually compare the functions. + if type(slf).invoice_recipient != type(self).invoice_recipient: + return type(slf).invoice_recipient(slf) + + # Return a default + return slf.attendee.user.username + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) @@ -533,6 +544,18 @@ class Invoice(models.Model): ''' An invoice. Invoices can be automatically generated when checking out a Cart, in which case, it is attached to a given revision of a Cart. ''' + STATUS_UNPAID = 1 + STATUS_PAID = 2 + STATUS_REFUNDED = 3 + STATUS_VOID = 4 + + STATUS_TYPES = [ + (STATUS_UNPAID, _("Unpaid")), + (STATUS_PAID, _("Paid")), + (STATUS_REFUNDED, _("Refunded")), + (STATUS_VOID, _("VOID")), + ] + def __str__(self): return "Invoice #%d" % self.id @@ -541,13 +564,37 @@ class Invoice(models.Model): raise ValidationError( "If this is a cart invoice, it must have a revision") + @property + def is_unpaid(self): + return self.status == self.STATUS_UNPAID + + @property + def is_void(self): + return self.status == self.STATUS_VOID + + @property + def is_paid(self): + return self.status == self.STATUS_PAID + + @property + def is_refunded(self): + return self.status == self.STATUS_REFUNDED + # Invoice Number user = models.ForeignKey(User) cart = models.ForeignKey(Cart, null=True) - cart_revision = models.IntegerField(null=True) + cart_revision = models.IntegerField( + null=True, + db_index=True, + ) # Line Items (foreign key) - void = models.BooleanField(default=False) - paid = models.BooleanField(default=False) + 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) @@ -565,17 +612,25 @@ class LineItem(models.Model): description = models.CharField(max_length=255) quantity = models.PositiveIntegerField() price = models.DecimalField(max_digits=8, decimal_places=2) + product = models.ForeignKey(Product, null=True, blank=True) @python_2_unicode_compatible -class Payment(models.Model): - ''' A payment for an invoice. Each invoice can have multiple payments - attached to it.''' +class PaymentBase(models.Model): + ''' The base payment type for invoices. Payment apps should subclass this + class to handle implementation-specific issues. ''' + + 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=64) + reference = models.CharField(max_length=255) amount = models.DecimalField(max_digits=8, decimal_places=2) + + +class ManualPayment(PaymentBase): + ''' Payments that are manually entered by staff. ''' + pass diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 32af58c8..60cf2346 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -3,6 +3,7 @@ from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError class TestingCartController(CartController): @@ -32,4 +33,27 @@ class TestingCartController(CartController): class TestingInvoiceController(InvoiceController): - pass + + def pay(self, reference, amount): + ''' Testing method for simulating an invoice paymenht by the given + amount. ''' + if self.invoice.cart: + cart = CartController(self.invoice.cart) + cart.validate_cart() # Raises ValidationError if invalid + + status = self.invoice.status + if status == rego.Invoice.STATUS_VOID: + raise ValidationError("Void invoices cannot be paid") + elif status == rego.Invoice.STATUS_PAID: + raise ValidationError("Paid invoices cannot be paid again") + elif status == rego.Invoice.STATUS_REFUNDED: + raise ValidationError("Refunded invoices cannot be paid") + + ''' Adds a payment ''' + payment = rego.ManualPayment.objects.create( + invoice=self.invoice, + reference=reference, + amount=amount, + ) + + self.update_status() diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 0a7404d6..645dfb80 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -35,8 +35,8 @@ class InvoiceTestCase(RegistrationCartTestCase): # The old invoice should automatically be voided invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id) invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id) - self.assertTrue(invoice_1_new.void) - self.assertFalse(invoice_2_new.void) + self.assertTrue(invoice_1_new.is_void) + self.assertFalse(invoice_2_new.is_void) # Invoice should have two line items line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) @@ -68,7 +68,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.pay("A payment!", invoice.invoice.value) # This payment is for the correct amount invoice should be paid. - self.assertTrue(invoice.invoice.paid) + self.assertTrue(invoice.invoice.is_paid) # Cart should not be active self.assertFalse(invoice.invoice.cart.active) @@ -133,7 +133,7 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) - self.assertTrue(invoice_1.invoice.paid) + self.assertTrue(invoice_1.invoice.is_paid) def test_invoice_voids_self_if_cart_is_invalid(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -142,7 +142,7 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) - self.assertFalse(invoice_1.invoice.void) + 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) @@ -151,11 +151,11 @@ class InvoiceTestCase(RegistrationCartTestCase): # Viewing invoice_1's invoice should show it as void invoice_1_new = TestingInvoiceController(invoice_1.invoice) - self.assertTrue(invoice_1_new.invoice.void) + 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.void) + self.assertFalse(invoice_2_new.invoice.is_void) def test_voiding_invoice_creates_new_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -164,7 +164,7 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) - self.assertFalse(invoice_1.invoice.void) + self.assertFalse(invoice_1.invoice.is_void) invoice_1.void() invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 4510cbda..35457749 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -18,11 +18,13 @@ class RefundTestCase(RegistrationCartTestCase): invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A Payment!", invoice.invoice.value) - self.assertFalse(invoice.invoice.void) - self.assertTrue(invoice.invoice.paid) + self.assertFalse(invoice.invoice.is_void) + self.assertTrue(invoice.invoice.is_paid) + self.assertFalse(invoice.invoice.is_refunded) self.assertFalse(invoice.invoice.cart.released) invoice.refund("A Refund!", invoice.invoice.value) - self.assertTrue(invoice.invoice.void) - self.assertFalse(invoice.invoice.paid) + self.assertFalse(invoice.invoice.is_void) + self.assertFalse(invoice.invoice.is_paid) + self.assertTrue(invoice.invoice.is_refunded) self.assertTrue(invoice.invoice.cart.released) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 7fa709dc..be598206 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -141,7 +141,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) inv = TestingInvoiceController.for_cart(current_cart.cart) - if not inv.invoice.paid: + if not inv.invoice.is_paid: inv.pay("Hello!", inv.invoice.value) current_cart = TestingCartController.for_user(self.USER_1) From 0e80e0336c2021c7cb585f29f396dddc31676292 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 16:59:03 +1000 Subject: [PATCH 117/418] adds invoice_recipient to AttendeeProfileBase --- registrasion/controllers/invoice.py | 7 ++++++- registrasion/tests/test_cart.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index a25221f1..f7ae13e5 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -73,6 +73,11 @@ class InvoiceController(object): # Never generate a due time that is before the issue time due = max(issued, reservation_limit) + # Get the invoice recipient + profile = rego.AttendeeProfileBase.objects.get_subclass( + id=cart.user.attendee.attendeeprofilebase.id, + ) + recipient = profile.invoice_recipient() invoice = rego.Invoice.objects.create( user=cart.user, cart=cart, @@ -81,7 +86,7 @@ class InvoiceController(object): value=Decimal(), issue_time=issued, due_time=due, - recipient="BOB_THOMAS", # TODO: add recipient generating code + recipient=recipient, ) product_items = rego.ProductItem.objects.filter(cart=cart) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 10ba10d2..e87ef37e 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -23,6 +23,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def setUpTestData(cls): + + super(RegistrationCartTestCase, cls).setUpTestData() + cls.USER_1 = User.objects.create_user( username='testuser', email='test@example.com', @@ -33,6 +36,15 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', password='top_secret') + attendee1 = rego.Attendee.get_instance(cls.USER_1) + attendee1.save() + profile1 = rego.AttendeeProfileBase.objects.create(attendee=attendee1) + profile1.save() + attendee2 = rego.Attendee.get_instance(cls.USER_2) + attendee2.save() + profile2 = rego.AttendeeProfileBase.objects.create(attendee=attendee2) + profile2.save() + cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] From 2fbe789090861dabf5c7c0481995c378fbaf4e8b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 18:26:31 +1000 Subject: [PATCH 118/418] =?UTF-8?q?Adds=20validate=5Fallowed=5Fto=5Fpay(),?= =?UTF-8?q?=20which=20validates=20whether=20you=E2=80=99re=20allowed=20to?= =?UTF-8?q?=20pay=20for=20an=20invoice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/controllers/invoice.py | 24 ++++++++++++++++++++++++ registrasion/tests/controller_helpers.py | 11 +---------- registrasion/tests/test_cart.py | 4 ++++ registrasion/tests/test_invoice.py | 17 +++++++++++++++-- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index f7ae13e5..c5a527ca 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -126,6 +126,30 @@ class InvoiceController(object): return invoice + 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 total_payments(self): ''' Returns the total amount paid towards this invoice. ''' diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 60cf2346..476351dd 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -37,17 +37,8 @@ class TestingInvoiceController(InvoiceController): def pay(self, reference, amount): ''' Testing method for simulating an invoice paymenht by the given amount. ''' - if self.invoice.cart: - cart = CartController(self.invoice.cart) - cart.validate_cart() # Raises ValidationError if invalid - status = self.invoice.status - if status == rego.Invoice.STATUS_VOID: - raise ValidationError("Void invoices cannot be paid") - elif status == rego.Invoice.STATUS_PAID: - raise ValidationError("Paid invoices cannot be paid again") - elif status == rego.Invoice.STATUS_REFUNDED: - raise ValidationError("Refunded invoices cannot be paid") + self.validate_allowed_to_pay() ''' Adds a payment ''' payment = rego.ManualPayment.objects.create( diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index e87ef37e..f8a82c21 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -148,6 +148,10 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): voucher.save() return voucher + @classmethod + def reget(cls, object): + return type(object).objects.get(id=object.id) + class BasicCartTests(RegistrationCartTestCase): diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 645dfb80..b121bfa1 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -180,7 +180,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.void() with self.assertRaises(ValidationError): - invoice_1.pay("Reference", invoice_1.invoice.value) + invoice_1.validate_allowed_to_pay() def test_cannot_void_paid_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -199,9 +199,22 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice_1 = 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() + + + # TODO: test partially paid invoice cannot be void until payments # are refunded # TODO: test overpaid invoice results in credit note - # TODO: test credit note generation more generally + # TODO: test credit note generation more generally From 94a42c100bbdc18180421d080fac1ef2b2961db4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 19:19:19 +1000 Subject: [PATCH 119/418] Adds manual payment functionality --- registrasion/forms.py | 7 +++++++ registrasion/urls.py | 5 ++++- registrasion/views.py | 34 ++++++++++++++++++++++++++-------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 47dacd3d..68c69d36 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,6 +3,13 @@ import models as rego from django import forms +class ManualPaymentForm(forms.ModelForm): + + class Meta: + model = rego.ManualPayment + fields = ["reference", "amount"] + + # Products forms -- none of these have any fields: they are to be subclassed # and the fields added as needs be. diff --git a/registrasion/urls.py b/registrasion/urls.py index 0620dafb..eb0606df 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,3 +1,5 @@ +import views + from django.conf.urls import url, patterns urlpatterns = patterns( @@ -5,7 +7,8 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), - url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), + url(r"^invoice/([0-9]+)/manual_payment$", + views.manual_payment, name="manual_payment"), url(r"^profile$", "edit_profile", name="attendee_edit"), url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", diff --git a/registrasion/views.py b/registrasion/views.py index b3f9212d..da8687cc 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,7 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render @@ -443,15 +444,32 @@ def invoice(request, invoice_id): @login_required -def pay_invoice(request, invoice_id): - ''' Marks the invoice with the given invoice id as paid. - WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. +def manual_payment(request, invoice_id): + ''' Allows staff to make manual payments or refunds on an invoice.''' + + FORM_PREFIX = "manual_payment" + + if not request.user.is_staff: + raise Http404() - ''' invoice_id = int(invoice_id) - inv = rego.Invoice.objects.get(pk=invoice_id) + inv = get_object_or_404(rego.Invoice, pk=invoice_id) current_invoice = InvoiceController(inv) - if not current_invoice.invoice.paid and not current_invoice.invoice.void: - current_invoice.pay("Demo invoice payment", inv.value) - return redirect("invoice", current_invoice.invoice.id) + form = forms.ManualPaymentForm( + request.POST or None, + prefix=FORM_PREFIX, + ) + + if request.POST and form.is_valid(): + form.instance.invoice = inv + form.save() + current_invoice.update_status() + form = forms.ManualPaymentForm(prefix=FORM_PREFIX) + + data = { + "invoice": inv, + "form": form, + } + + return render(request, "registrasion/manual_payment.html", data) From 3dab78ab25b2d55c4482bb92cacf3c37d3ecc7a9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 12:21:39 +1000 Subject: [PATCH 120/418] Adds the access_code field to Attendee model --- .../migrations/0014_attendee_access_code.py | 21 +++++++++++++++++++ .../migrations/0015_auto_20160408_0220.py | 20 ++++++++++++++++++ .../migrations/0016_auto_20160408_0234.py | 20 ++++++++++++++++++ registrasion/models.py | 14 +++++++++++++ registrasion/util.py | 15 +++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 registrasion/migrations/0014_attendee_access_code.py create mode 100644 registrasion/migrations/0015_auto_20160408_0220.py create mode 100644 registrasion/migrations/0016_auto_20160408_0234.py create mode 100644 registrasion/util.py diff --git a/registrasion/migrations/0014_attendee_access_code.py b/registrasion/migrations/0014_attendee_access_code.py new file mode 100644 index 00000000..a579f47a --- /dev/null +++ b/registrasion/migrations/0014_attendee_access_code.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 02:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import registrasion.util + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0013_auto_20160406_2228_squashed_0015_auto_20160406_1942'), + ] + + operations = [ + migrations.AddField( + model_name='attendee', + name='access_code', + field=models.CharField(default=registrasion.util.generate_access_code, max_length=6, unique=True), + ), + ] diff --git a/registrasion/migrations/0015_auto_20160408_0220.py b/registrasion/migrations/0015_auto_20160408_0220.py new file mode 100644 index 00000000..acdde45f --- /dev/null +++ b/registrasion/migrations/0015_auto_20160408_0220.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 02:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0014_attendee_access_code'), + ] + + operations = [ + migrations.AlterField( + model_name='attendee', + name='access_code', + field=models.CharField(max_length=6, unique=True), + ), + ] diff --git a/registrasion/migrations/0016_auto_20160408_0234.py b/registrasion/migrations/0016_auto_20160408_0234.py new file mode 100644 index 00000000..94beb184 --- /dev/null +++ b/registrasion/migrations/0016_auto_20160408_0234.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 02:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0015_auto_20160408_0220'), + ] + + operations = [ + migrations.AlterField( + model_name='attendee', + name='access_code', + field=models.CharField(db_index=True, max_length=6, unique=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index fedaa68b..2719dd0e 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import util + import datetime import itertools @@ -32,8 +34,20 @@ class Attendee(models.Model): 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) highest_complete_category = models.IntegerField(default=0) diff --git a/registrasion/util.py b/registrasion/util.py new file mode 100644 index 00000000..fb97d1d5 --- /dev/null +++ b/registrasion/util.py @@ -0,0 +1,15 @@ +import string + +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 = 4 + # all upper-case letters + digits 1-9 (no 0 vs O confusion) + chars = string.uppercase + string.digits[1:] + # 4 chars => 35 ** 4 = 1500625 (should be enough for anyone) + return get_random_string(length=length, allowed_chars=chars) From ea1d6f52e693827cf861d6994717c7610d8ce812 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 13:15:24 +1000 Subject: [PATCH 121/418] Adds payment access codes. --- registrasion/controllers/invoice.py | 17 ++++++++++++++ registrasion/urls.py | 3 +++ registrasion/views.py | 35 ++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index c5a527ca..de401d31 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -126,6 +126,23 @@ class InvoiceController(object): return invoice + 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() diff --git a/registrasion/urls.py b/registrasion/urls.py index eb0606df..0949e4b4 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -7,8 +7,11 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), + url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), url(r"^invoice/([0-9]+)/manual_payment$", views.manual_payment, name="manual_payment"), + url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, + name="invoice_access"), url(r"^profile$", "edit_profile", name="attendee_edit"), url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", diff --git a/registrasion/views.py b/registrasion/views.py index da8687cc..251f0fba 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -424,18 +424,41 @@ def checkout_errors(request, errors): return render(request, "registrasion/checkout_errors.html", data) -@login_required -def invoice(request, invoice_id): - ''' Displays an invoice for a given invoice id. ''' +def invoice_access(request, access_code): + ''' Redirects to the first unpaid invoice for the attendee that matches + the given access code, if any. ''' + + invoices = rego.Invoice.objects.filter( + user__attendee__access_code=access_code, + status=rego.Invoice.STATUS_UNPAID, + ).order_by("issue_time") + + if not invoices: + raise Http404() + + invoice = invoices[0] + + return redirect("invoice", invoice.id, access_code) + + +def invoice(request, invoice_id, access_code=None): + ''' Displays an invoice for a given invoice id. + This view is not authenticated, but it will only allow access to either: + the user the invoice belongs to; staff; or a request made with the correct + access code. + ''' invoice_id = int(invoice_id) inv = rego.Invoice.objects.get(pk=invoice_id) - if request.user != inv.cart.user and not request.user.is_staff: - raise Http404() - current_invoice = InvoiceController(inv) + if not current_invoice.can_view( + user=request.user, + access_code=access_code, + ): + raise Http404() + data = { "invoice": current_invoice.invoice, } From 01b9adbaf46e27eb24dea8d0c6629bde0dcaa02f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 17:32:06 +1000 Subject: [PATCH 122/418] Re-writes the guided registration to individually track completed categories, and keep the form page the same until every category is finished. Resolves #14 --- .../migrations/0017_auto_20160408_0731.py | 24 ++++++++++ registrasion/models.py | 2 +- .../templatetags/registrasion_tags.py | 8 +++- registrasion/views.py | 46 +++++++++++++------ 4 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 registrasion/migrations/0017_auto_20160408_0731.py diff --git a/registrasion/migrations/0017_auto_20160408_0731.py b/registrasion/migrations/0017_auto_20160408_0731.py new file mode 100644 index 00000000..5bb8957c --- /dev/null +++ b/registrasion/migrations/0017_auto_20160408_0731.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 07:31 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0016_auto_20160408_0234'), + ] + + operations = [ + migrations.RemoveField( + model_name='attendee', + name='highest_complete_category', + ), + migrations.AddField( + model_name='attendee', + name='guided_categories_complete', + field=models.ManyToManyField(to='registrasion.Category'), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 2719dd0e..a1c06ca8 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -49,7 +49,7 @@ class Attendee(models.Model): db_index=True, ) completed_registration = models.BooleanField(default=False) - highest_complete_category = models.IntegerField(default=0) + guided_categories_complete = models.ManyToManyField("category") class AttendeeProfileBase(models.Model): diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 17edc8e4..7d618171 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -30,7 +30,13 @@ def items_pending(context): all_items = rego.ProductItem.objects.filter( cart__user=context.request.user, cart__active=True, - ).select_related("product", "product__category") + ).select_related( + "product", + "product__category", + ).order_by( + "product__category__order", + "product__order", + ) return all_items diff --git a/registrasion/views.py b/registrasion/views.py index 251f0fba..155d1aed 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -52,6 +52,7 @@ def guided_registration(request, page_id=0): through each category one by one ''' + SESSION_KEY = "guided_registration_categories" next_step = redirect("guided_registration") sections = [] @@ -94,19 +95,23 @@ def guided_registration(request, page_id=0): else: # We're selling products - last_category = attendee.highest_complete_category + starting = attendee.guided_categories_complete.count() == 0 # Get the next category cats = rego.Category.objects - cats = cats.filter(id__gt=last_category).order_by("order") + if SESSION_KEY in request.session: + _cats = request.session[SESSION_KEY] + cats = cats.filter(id__in=_cats) + else: + cats = cats.exclude( + id__in=attendee.guided_categories_complete.all(), + ) - if cats.count() == 0: - # We've filled in every category - attendee.completed_registration = True - attendee.save() - return next_step + cats = cats.order_by("order") - if last_category == 0: + request.session[SESSION_KEY] = [] + + if starting: # Only display the first Category title = "Select ticket type" current_step = 2 @@ -125,6 +130,12 @@ def guided_registration(request, page_id=0): products=all_products, )) + if len(available_products) == 0: + # We've filled in every category + attendee.completed_registration = True + attendee.save() + return next_step + for category in cats: products = [ i for i in available_products @@ -141,14 +152,17 @@ def guided_registration(request, page_id=0): discounts=discounts, form=products_form, ) - if products: - # This product category does not exist for this user - sections.append(section) - if request.method == "POST" and not products_form.errors: - if category.id > attendee.highest_complete_category: - # This is only saved if we pass each form with no errors. - attendee.highest_complete_category = category.id + if products: + # This product category has items to show. + sections.append(section) + # Add this to the list of things to show if the form errors. + request.session[SESSION_KEY].append(category.id) + + if request.method == "POST" and not products_form.errors: + # This is only saved if we pass each form with no errors, + # and if the form actually has products. + attendee.guided_categories_complete.add(category) if sections and request.method == "POST": for section in sections: @@ -156,6 +170,8 @@ def guided_registration(request, page_id=0): break else: attendee.save() + if SESSION_KEY in request.session: + del request.session[SESSION_KEY] # We've successfully processed everything return next_step From 97438624e1ca2bf76c5883d06fb4b7121a03bac4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 19:41:55 +1000 Subject: [PATCH 123/418] Makes the guided registration stay on the front page if an incorrect voucher is added but a valid profile is filled out. Resolves #9 --- registrasion/views.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 155d1aed..1b0fe646 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -53,6 +53,8 @@ def guided_registration(request, page_id=0): ''' SESSION_KEY = "guided_registration_categories" + ASK_FOR_PROFILE = 777 # Magic number. Meh. + next_step = redirect("guided_registration") sections = [] @@ -72,9 +74,19 @@ def guided_registration(request, page_id=0): except ObjectDoesNotExist: profile = None - if not profile: - # TODO: if voucherform is invalid, make sure - # that profileform does not save + # Figure out if we need to show the profile form and the voucher form + show_profile_and_voucher = False + if SESSION_KEY not in request.session: + if not profile: + show_profile_and_voucher = True + else: + if request.session[SESSION_KEY] == ASK_FOR_PROFILE: + show_profile_and_voucher = True + + if show_profile_and_voucher: + # Keep asking for the profile until everything passes. + request.session[SESSION_KEY] = ASK_FOR_PROFILE + voucher_form, voucher_handled = handle_voucher(request, "voucher") profile_form, profile_handled = handle_profile(request, "profile") From ae8f39381f4a06a5b4864ce938ce327622bbbff4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 19:49:18 +1000 Subject: [PATCH 124/418] Flake8 fixes --- registrasion/controllers/invoice.py | 1 - registrasion/tests/controller_helpers.py | 3 +-- registrasion/tests/test_discount.py | 1 - registrasion/tests/test_invoice.py | 4 +--- registrasion/util.py | 1 + registrasion/views.py | 2 +- 6 files changed, 4 insertions(+), 8 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index de401d31..fce09f8f 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -142,7 +142,6 @@ class InvoiceController(object): return False - def _refresh(self): ''' Refreshes the underlying invoice and cart objects. ''' self.invoice.refresh_from_db() diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 476351dd..ad8661b6 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -3,7 +3,6 @@ from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego from django.core.exceptions import ObjectDoesNotExist -from django.core.exceptions import ValidationError class TestingCartController(CartController): @@ -41,7 +40,7 @@ class TestingInvoiceController(InvoiceController): self.validate_allowed_to_pay() ''' Adds a payment ''' - payment = rego.ManualPayment.objects.create( + rego.ManualPayment.objects.create( invoice=self.invoice, reference=reference, amount=amount, diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index e9105378..e84ca283 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -5,7 +5,6 @@ from decimal import Decimal from registrasion import models as rego from registrasion.controllers import discount from controller_helpers import TestingCartController -from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index b121bfa1..7854680d 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -197,7 +197,7 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): - invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) + TestingInvoiceController.for_cart(current_cart.cart) def test_cannot_pay_implicitly_void_invoice(self): cart = TestingCartController.for_user(self.USER_1) @@ -210,8 +210,6 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice.validate_allowed_to_pay() - - # TODO: test partially paid invoice cannot be void until payments # are refunded diff --git a/registrasion/util.py b/registrasion/util.py index fb97d1d5..3df54800 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -2,6 +2,7 @@ import string 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. diff --git a/registrasion/views.py b/registrasion/views.py index 1b0fe646..f0795647 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -484,7 +484,7 @@ def invoice(request, invoice_id, access_code=None): if not current_invoice.can_view( user=request.user, access_code=access_code, - ): + ): raise Http404() data = { From 6b10a0a7e42764250b07a205a278a9b72227cadc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 10 Apr 2016 14:41:43 +1000 Subject: [PATCH 125/418] Adds CreditNote, CreditNoteController, related models, and tests. --- registrasion/controllers/credit_note.py | 51 ++++ registrasion/controllers/invoice.py | 34 ++- ...refund_squashed_0019_auto_20160410_0753.py | 43 +++ ...refund_squashed_0020_auto_20160411_0256.py | 27 ++ .../migrations/0020_auto_20160411_0258.py | 21 ++ registrasion/models.py | 92 ++++++ registrasion/tests/controller_helpers.py | 10 + registrasion/tests/test_invoice.py | 274 +++++++++++++++++- registrasion/tests/test_refund.py | 2 +- registrasion/tests/test_voucher.py | 2 +- 10 files changed, 534 insertions(+), 22 deletions(-) create mode 100644 registrasion/controllers/credit_note.py create mode 100644 registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py create mode 100644 registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py create mode 100644 registrasion/migrations/0020_auto_20160411_0258.py diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py new file mode 100644 index 00000000..bd15947d --- /dev/null +++ b/registrasion/controllers/credit_note.py @@ -0,0 +1,51 @@ +from django.db import transaction + +from registrasion import models as rego + + +class CreditNoteController(object): + + 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 = rego.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. + ''' + + from invoice import InvoiceController # Circular imports bleh. + inv = InvoiceController(invoice) + inv.validate_allowed_to_pay() + + # Apply payment to invoice + rego.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. diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index fce09f8f..58b0beb6 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -8,6 +8,7 @@ from django.utils import timezone from registrasion import models as rego from cart import CartController +from credit_note import CreditNoteController class InvoiceController(object): @@ -189,6 +190,12 @@ class InvoiceController(object): if remainder <= 0: # Invoice no longer has amount owing self._mark_paid() + + if remainder < 0: + CreditNoteController.generate_from_invoice( + self.invoice, + 0 - remainder, + ) elif total_paid == 0 and num_payments > 0: # Invoice has multiple payments totalling zero self._mark_void() @@ -247,30 +254,31 @@ class InvoiceController(object): def void(self): ''' Voids the invoice if it is valid to do so. ''' - if self.invoice.status == rego.Invoice.STATUS_PAID: - raise ValidationError("Paid invoices cannot be voided, " - "only refunded.") + if self.total_payments() > 0: + raise ValidationError("Invoices with payments must be refunded.") + elif self.invoice.is_refunded: + raise ValidationError("Refunded invoices may not be voided.") self._mark_void() @transaction.atomic - def refund(self, reference, amount): - ''' Refunds the invoice by the given amount. + 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. - TODO: replace with credit notes work instead. ''' if self.invoice.is_void: raise ValidationError("Void invoices cannot be refunded") - # Adds a payment - # TODO: replace by creating a credit note instead - rego.ManualPayment.objects.create( - invoice=self.invoice, - reference=reference, - amount=0 - amount, - ) + # Raises a credit note fot the value of the invoice. + amount = self.total_payments() + if amount == 0: + self.void() + return + + CreditNoteController.generate_from_invoice(self.invoice, amount) self.update_status() diff --git a/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py b/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py new file mode 100644 index 00000000..abb3b6e9 --- /dev/null +++ b/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-10 07:54 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + replaces = [('registrasion', '0018_creditnote_creditnoteapplication_creditnoterefund'), ('registrasion', '0019_auto_20160410_0753')] + + dependencies = [ + ('registrasion', '0017_auto_20160408_0731'), + ] + + operations = [ + 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.paymentbase',), + ), + 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)), + ('parent', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.CreditNote')), + ], + ), + ] diff --git a/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py b/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py new file mode 100644 index 00000000..b070e3e6 --- /dev/null +++ b/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 02:57 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('registrasion', '0019_manualcreditnoterefund'), ('registrasion', '0020_auto_20160411_0256')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('registrasion', '0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753'), + ] + + operations = [ + migrations.CreateModel( + name='ManualCreditNoteRefund', + fields=[ + ('entered_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('creditnoterefund_ptr', models.OneToOneField(auto_created=True, default=0, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund')), + ], + ), + ] diff --git a/registrasion/migrations/0020_auto_20160411_0258.py b/registrasion/migrations/0020_auto_20160411_0258.py new file mode 100644 index 00000000..0148f90d --- /dev/null +++ b/registrasion/migrations/0020_auto_20160411_0258.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 02:58 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256'), + ] + + operations = [ + migrations.AlterField( + model_name='manualcreditnoterefund', + name='creditnoterefund_ptr', + field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund'), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index a1c06ca8..6e625887 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -648,3 +648,95 @@ class PaymentBase(models.Model): class ManualPayment(PaymentBase): ''' Payments that are manually entered by staff. ''' pass + + +class CreditNote(PaymentBase): + ''' Credit notes represent money accounted for in the system that do not + belong to specific invoices. They may be paid into other invoices, or + cashed out as refunds. + + Each CreditNote may either be used to pay towards another Invoice in the + system (by attaching a CreditNoteApplication), or may be marked as + refunded (by attaching a CreditNoteRefund).''' + + @classmethod + def unclaimed(cls): + return cls.objects.filter( + creditnoteapplication=None, + creditnoterefund=None, + ) + + @property + def status(self): + if self.is_unclaimed: + return "Unclaimed" + + if hasattr(self, 'creditnoteapplication'): + destination = self.creditnoteapplication.invoice.id + return "Applied to invoice %d" % destination + + elif hasattr(self, 'creditnoterefund'): + reference = self.creditnoterefund.reference + print reference + return "Refunded with reference: %s" % reference + + raise ValueError("This should never happen.") + + @property + def is_unclaimed(self): + return not ( + hasattr(self, 'creditnoterefund') or + hasattr(self, 'creditnoteapplication') + ) + + @property + def value(self): + ''' Returns the value of the credit note. Because CreditNotes are + implemented as PaymentBase objects internally, the amount is a + negative payment against an invoice. ''' + return -self.amount + + +class CleanOnSave(object): + + def save(self, *a, **k): + self.full_clean() + super(CleanOnSave, self).save(*a, **k) + + +class CreditNoteApplication(CleanOnSave, PaymentBase): + ''' Represents an application of a credit note to an Invoice. ''' + + def clean(self): + if not hasattr(self, "parent"): + return + if hasattr(self.parent, 'creditnoterefund'): + raise ValidationError( + "Cannot apply a refunded credit note to an invoice" + ) + + parent = models.OneToOneField(CreditNote) + + +class CreditNoteRefund(CleanOnSave, models.Model): + ''' Represents a refund of a credit note to an external payment. + Credit notes may only be refunded in full. How those refunds are handled + is left as an exercise to the payment app. ''' + + def clean(self): + if not hasattr(self, "parent"): + return + if hasattr(self.parent, 'creditnoteapplication'): + raise ValidationError( + "Cannot refund a credit note that has been paid to an invoice" + ) + + parent = models.OneToOneField(CreditNote) + time = models.DateTimeField(default=timezone.now) + reference = models.CharField(max_length=255) + + +class ManualCreditNoteRefund(CreditNoteRefund): + ''' Credit notes that are entered by a staff member. ''' + + entered_by = models.ForeignKey(User) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index ad8661b6..fe41f316 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -1,4 +1,5 @@ from registrasion.controllers.cart import CartController +from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego @@ -47,3 +48,12 @@ class TestingInvoiceController(InvoiceController): ) self.update_status() + + +class TestingCreditNoteController(CreditNoteController): + + def refund(self): + rego.CreditNoteRefund.objects.create( + parent=self.credit_note, + reference="Whoops." + ) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 7854680d..f9ea59b6 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from controller_helpers import TestingCartController +from controller_helpers import TestingCreditNoteController from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -187,12 +188,25 @@ class InvoiceTestCase(RegistrationCartTestCase): # 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) + invoice = TestingInvoiceController.for_cart(current_cart.cart) - invoice_1.pay("Reference", invoice_1.invoice.value) + invoice.pay("Reference", invoice.invoice.value) with self.assertRaises(ValidationError): - invoice_1.void() + invoice.void() + + def test_cannot_void_partially_paid_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 = TestingInvoiceController.for_cart(current_cart.cart) + + 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) @@ -210,9 +224,255 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice.validate_allowed_to_pay() - # TODO: test partially paid invoice cannot be void until payments - # are refunded + def test_overpaid_invoice_results_in_credit_note(self): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) - # TODO: test overpaid invoice results in credit note + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) - # TODO: test credit note generation more generally + # 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.total_payments()) + self.assertTrue(invoice.invoice.is_paid) + + # There should be a credit note generated out of the invoice. + credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + 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): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + # 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.total_payments()) + self.assertTrue(invoice.invoice.is_paid) + + # There should be no credit notes + credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + self.assertEqual(0, credit_notes.count()) + + def test_refund_partially_paid_invoice_generates_correct_credit_note(self): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + # 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.total_payments()) + self.assertTrue(invoice.invoice.is_void) + + # There should be a credit note generated out of the invoice. + credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + 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): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + 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.total_payments()) + self.assertTrue(invoice.invoice.is_refunded) + + # There should be a credit note generated out of the invoice. + credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + self.assertEqual(1, credit_notes.count()) + self.assertEqual(to_pay, credit_notes[0].value) + + def test_apply_credit_note_pays_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)) + + 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. + credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + cn = TestingCreditNoteController(credit_note) + + # That credit note should be in the unclaimed pile + self.assertEquals(1, rego.CreditNote.unclaimed().count()) + + # 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, rego.CreditNote.unclaimed().count()) + + def test_apply_credit_note_generates_new_credit_note_if_overpaying(self): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 2) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + 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. + credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + cn = TestingCreditNoteController(credit_note) + + self.assertEquals(1, rego.CreditNote.unclaimed().count()) + + # Create a new cart (of half value of inv 1) and get 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) + + # We generated a new credit note, and spent the old one, + # unclaimed should still be 1. + self.assertEquals(1, rego.CreditNote.unclaimed().count()) + + credit_note2 = rego.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): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + 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. + credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + cn = TestingCreditNoteController(credit_note) + + # Create a new cart with invoice, pay it + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) + 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 + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) + 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): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + to_pay = invoice.invoice.value + invoice.pay("Reference", to_pay) + self.assertTrue(invoice.invoice.is_paid) + + invoice.refund() + + self.assertEquals(1, rego.CreditNote.unclaimed().count()) + + credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + + cn = TestingCreditNoteController(credit_note) + cn.refund() + + # Refunding a credit note should mark it as claimed + self.assertEquals(0, rego.CreditNote.unclaimed().count()) + + # 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): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + to_pay = invoice.invoice.value + invoice.pay("Reference", to_pay) + self.assertTrue(invoice.invoice.is_paid) + + invoice.refund() + + self.assertEquals(1, rego.CreditNote.unclaimed().count()) + + credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + + cn = TestingCreditNoteController(credit_note) + + # 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)) + cn.apply_to_invoice(invoice_2.invoice) + + self.assertEquals(0, rego.CreditNote.unclaimed().count()) + + # Cannot refund this credit note as it is already applied. + with self.assertRaises(ValidationError): + cn.refund() diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 35457749..fbed1f32 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -23,7 +23,7 @@ class RefundTestCase(RegistrationCartTestCase): self.assertFalse(invoice.invoice.is_refunded) self.assertFalse(invoice.invoice.cart.released) - invoice.refund("A Refund!", invoice.invoice.value) + invoice.refund() self.assertFalse(invoice.invoice.is_void) self.assertFalse(invoice.invoice.is_paid) self.assertTrue(invoice.invoice.is_refunded) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index be598206..d4614efb 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -148,7 +148,7 @@ class VoucherTestCases(RegistrationCartTestCase): with self.assertRaises(ValidationError): current_cart.apply_voucher(voucher.code) - inv.refund("Hello!", inv.invoice.value) + inv.refund() current_cart.apply_voucher(voucher.code) def test_fix_simple_errors_does_not_remove_limited_voucher(self): From 2c94e7538a6e426ef4c91cd94bd6282865cd9b56 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 11:07:24 +1000 Subject: [PATCH 126/418] Adds available_credit tag, and adds a view for refunding an invoice to generate a credit note. --- .../templatetags/registrasion_tags.py | 10 +++++++++ registrasion/urls.py | 2 ++ registrasion/views.py | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 7d618171..07ea7c14 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -16,6 +16,16 @@ def available_categories(context): return CategoryController.available_categories(context.request.user) +@register.assignment_tag(takes_context=True) +def available_credit(context): + ''' Returns the amount of unclaimed credit available for this user. ''' + notes = rego.CreditNote.unclaimed().filter( + invoice__user=context.request.user, + ) + ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0 + return 0 - ret + + @register.assignment_tag(takes_context=True) def invoices(context): ''' Returns all of the invoices that this user has. ''' diff --git a/registrasion/urls.py b/registrasion/urls.py index 0949e4b4..b927edbf 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -10,6 +10,8 @@ urlpatterns = patterns( url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), url(r"^invoice/([0-9]+)/manual_payment$", views.manual_payment, name="manual_payment"), + url(r"^invoice/([0-9]+)/refund$", + views.refund, name="refund"), url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, name="invoice_access"), url(r"^profile$", "edit_profile", name="attendee_edit"), diff --git a/registrasion/views.py b/registrasion/views.py index f0795647..509473aa 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -524,3 +524,24 @@ def manual_payment(request, invoice_id): } return render(request, "registrasion/manual_payment.html", data) + + +@login_required +def refund(request, invoice_id): + ''' Allows staff to refund payments against an invoice and request a + credit note.''' + + if not request.user.is_staff: + raise Http404() + + invoice_id = int(invoice_id) + inv = get_object_or_404(rego.Invoice, pk=invoice_id) + current_invoice = InvoiceController(inv) + + try: + current_invoice.refund() + messages.success(request, "This invoice has been refunded.") + except ValidationError as ve: + messages.error(request, ve) + + return redirect("invoice", invoice_id) From 680ce689f6358b447f5328469ecbbc97cf6b3907 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 12:11:14 +1000 Subject: [PATCH 127/418] Adds initial credit note display view --- registrasion/urls.py | 1 + registrasion/views.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/registrasion/urls.py b/registrasion/urls.py index b927edbf..7b28693e 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -6,6 +6,7 @@ urlpatterns = patterns( "registrasion.views", url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), + url(r"^credit_note/([0-9]+)$", views.credit_note, name="credit_note"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), url(r"^invoice/([0-9]+)/manual_payment$", diff --git a/registrasion/views.py b/registrasion/views.py index 509473aa..13f1b857 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -4,6 +4,7 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers import discount from registrasion.controllers.cart import CartController +from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.product import ProductController from registrasion.exceptions import CartValidationError @@ -545,3 +546,23 @@ def refund(request, invoice_id): messages.error(request, ve) return redirect("invoice", invoice_id) + + +def credit_note(request, note_id, access_code=None): + ''' Displays an credit note for a given id. + This view can only be seen by staff. + ''' + + if not request.user.is_staff: + raise Http404() + + note_id = int(note_id) + note = rego.CreditNote.objects.get(pk=note_id) + + current_note = CreditNoteController(note) + + data = { + "credit_note": current_note.credit_note, + } + + return render(request, "registrasion/credit_note.html", data) From 7e8d044a9f515625513ccb43f56d38f109d654f9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 13:11:31 +1000 Subject: [PATCH 128/418] Adds the ability to apply or refund a credit note. --- registrasion/forms.py | 34 ++++++++++++++++++++++++++++++++++ registrasion/views.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 68c69d36..2de043f2 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,6 +3,40 @@ import models as rego from django import forms +class ApplyCreditNoteForm(forms.Form): + + 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_for_user + + def _unpaid_invoices_for_user(self): + invoices = rego.Invoice.objects.filter( + status=rego.Invoice.STATUS_UNPAID, + user=self.user, + ) + + return [ + (invoice.id, "Invoice %(id)d - $%(value)d" % invoice.__dict__) + for invoice in invoices + ] + + invoice = forms.ChoiceField( + #choices=_unpaid_invoices_for_user, + required=True, + ) + + +class ManualCreditNoteRefundForm(forms.ModelForm): + + class Meta: + model = rego.ManualCreditNoteRefund + fields = ["reference"] + + class ManualPaymentForm(forms.ModelForm): class Meta: diff --git a/registrasion/views.py b/registrasion/views.py index 13f1b857..b2ca8eca 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -561,8 +561,39 @@ def credit_note(request, note_id, access_code=None): current_note = CreditNoteController(note) + apply_form = forms.ApplyCreditNoteForm( + note.invoice.user, + request.POST or None, + prefix="apply_note" + ) + + refund_form = forms.ManualCreditNoteRefundForm( + request.POST or None, + prefix="refund_note" + ) + + if request.POST and apply_form.is_valid(): + inv_id = apply_form.cleaned_data["invoice"] + invoice = rego.Invoice.objects.get(pk=inv_id) + current_note.apply_to_invoice(invoice) + messages.success(request, + "Applied credit note %d to invoice." % note_id + ) + return redirect("invoice", invoice.id) + + elif request.POST and refund_form.is_valid(): + refund_form.instance.entered_by = request.user + refund_form.instance.parent = note + refund_form.save() + messages.success(request, + "Applied manual refund to credit note." + ) + return redirect("invoice", invoice.id) + data = { "credit_note": current_note.credit_note, + "apply_form": apply_form, + "refund_form": refund_form, } return render(request, "registrasion/credit_note.html", data) From 4fedc733047a52b85814feb7f3626c6cb5aa635e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 17:55:00 +1000 Subject: [PATCH 129/418] Renames EnablingCondition to Flag where possible --- registrasion/admin.py | 26 ++++++++-------- registrasion/controllers/conditions.py | 20 ++++++------ .../migrations/0021_auto_20160411_0748.py | 31 +++++++++++++++++++ registrasion/models.py | 15 +++++---- registrasion/tests/test_cart.py | 4 +-- registrasion/tests/test_enabling_condition.py | 8 ++--- registrasion/tests/test_voucher.py | 2 +- 7 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 registrasion/migrations/0021_auto_20160411_0748.py diff --git a/registrasion/admin.py b/registrasion/admin.py index 7155e177..67f50a8d 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -102,8 +102,8 @@ class VoucherDiscountInline(nested_admin.NestedStackedInline): ] -class VoucherEnablingConditionInline(nested_admin.NestedStackedInline): - model = rego.VoucherEnablingCondition +class VoucherFlagInline(nested_admin.NestedStackedInline): + model = rego.VoucherFlag verbose_name = _("Product and category enabled by voucher") verbose_name_plural = _("Products and categories enabled by voucher") @@ -125,7 +125,7 @@ class VoucherAdmin(nested_admin.NestedAdmin): discount_effects = None try: - enabling_effects = obj.voucherenablingcondition.effects() + enabling_effects = obj.voucherflag.effects() except ObjectDoesNotExist: enabling_effects = None @@ -140,20 +140,20 @@ class VoucherAdmin(nested_admin.NestedAdmin): list_display = ("recipient", "code", "effects") inlines = [ VoucherDiscountInline, - VoucherEnablingConditionInline, + VoucherFlagInline, ] # Enabling conditions -@admin.register(rego.ProductEnablingCondition) -class ProductEnablingConditionAdmin( +@admin.register(rego.ProductFlag) +class ProductFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): def enablers(self, obj): return list(obj.enabling_products.all()) - model = rego.ProductEnablingCondition + model = rego.ProductFlag fields = ("description", "enabling_products", "mandatory", "products", "categories"), @@ -161,12 +161,12 @@ class ProductEnablingConditionAdmin( # Enabling conditions -@admin.register(rego.CategoryEnablingCondition) -class CategoryEnablingConditionAdmin( +@admin.register(rego.CategoryFlag) +class CategoryFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): - model = rego.CategoryEnablingCondition + model = rego.CategoryFlag fields = ("description", "enabling_category", "mandatory", "products", "categories"), @@ -175,11 +175,11 @@ class CategoryEnablingConditionAdmin( # Enabling conditions -@admin.register(rego.TimeOrStockLimitEnablingCondition) -class TimeOrStockLimitEnablingConditionAdmin( +@admin.register(rego.TimeOrStockLimitFlag) +class TimeOrStockLimitFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): - model = rego.TimeOrStockLimitEnablingCondition + model = rego.TimeOrStockLimitFlag list_display = ( "description", diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 03b50118..787aed05 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -20,7 +20,7 @@ ConditionAndRemainder = namedtuple( class ConditionController(object): - ''' Base class for testing conditions that activate EnablingCondition + ''' Base class for testing conditions that activate Flag or Discount objects. ''' def __init__(self): @@ -29,15 +29,15 @@ class ConditionController(object): @staticmethod def for_condition(condition): CONTROLLERS = { - rego.CategoryEnablingCondition: CategoryConditionController, + rego.CategoryFlag: CategoryConditionController, rego.IncludedProductDiscount: ProductConditionController, - rego.ProductEnablingCondition: ProductConditionController, + rego.ProductFlag: ProductConditionController, rego.TimeOrStockLimitDiscount: TimeOrStockLimitDiscountController, - rego.TimeOrStockLimitEnablingCondition: - TimeOrStockLimitEnablingConditionController, + rego.TimeOrStockLimitFlag: + TimeOrStockLimitFlagController, rego.VoucherDiscount: VoucherConditionController, - rego.VoucherEnablingCondition: VoucherConditionController, + rego.VoucherFlag: VoucherConditionController, } try: @@ -211,7 +211,7 @@ class CategoryConditionController(ConditionController): class ProductConditionController(ConditionController): - ''' Condition tests for ProductEnablingCondition and + ''' Condition tests for ProductFlag and IncludedProductDiscount. ''' def __init__(self, condition): @@ -230,7 +230,7 @@ class ProductConditionController(ConditionController): class TimeOrStockLimitConditionController(ConditionController): - ''' Common condition tests for TimeOrStockLimit EnablingCondition and + ''' Common condition tests for TimeOrStockLimit Flag and Discount.''' def __init__(self, ceiling): @@ -280,7 +280,7 @@ class TimeOrStockLimitConditionController(ConditionController): return self.ceiling.limit - count -class TimeOrStockLimitEnablingConditionController( +class TimeOrStockLimitFlagController( TimeOrStockLimitConditionController): def _items(self): @@ -305,7 +305,7 @@ class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController): class VoucherConditionController(ConditionController): - ''' Condition test for VoucherEnablingCondition and VoucherDiscount.''' + ''' Condition test for VoucherFlag and VoucherDiscount.''' def __init__(self, condition): self.condition = condition diff --git a/registrasion/migrations/0021_auto_20160411_0748.py b/registrasion/migrations/0021_auto_20160411_0748.py new file mode 100644 index 00000000..345cd4ad --- /dev/null +++ b/registrasion/migrations/0021_auto_20160411_0748.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 07:48 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0020_auto_20160411_0258'), + ] + + operations = [ + migrations.RenameModel( + old_name='CategoryEnablingCondition', + new_name='CategoryFlag', + ), + migrations.RenameModel( + old_name='ProductEnablingCondition', + new_name='ProductFlag', + ), + migrations.RenameModel( + old_name='TimeOrStockLimitEnablingCondition', + new_name='TimeOrStockLimitFlag', + ), + migrations.RenameModel( + old_name='VoucherEnablingCondition', + new_name='VoucherFlag', + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 6e625887..a69f67dc 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -375,6 +375,9 @@ class EnablingConditionBase(models.Model): condition defined on a Product or Category, it will only be enabled if at least one condition is met. ''' + # TODO: rename to EnablingConditionBase once https://code.djangoproject.com/ticket/26488 + # is solved. + objects = InheritanceManager() def __str__(self): @@ -405,7 +408,7 @@ class EnablingConditionBase(models.Model): ) -class TimeOrStockLimitEnablingCondition(EnablingConditionBase): +class TimeOrStockLimitFlag(EnablingConditionBase): ''' Registration product ceilings ''' class Meta: @@ -432,7 +435,7 @@ class TimeOrStockLimitEnablingCondition(EnablingConditionBase): @python_2_unicode_compatible -class ProductEnablingCondition(EnablingConditionBase): +class ProductFlag(EnablingConditionBase): ''' The condition is met because a specific product is purchased. ''' def __str__(self): @@ -446,7 +449,7 @@ class ProductEnablingCondition(EnablingConditionBase): @python_2_unicode_compatible -class CategoryEnablingCondition(EnablingConditionBase): +class CategoryFlag(EnablingConditionBase): ''' The condition is met because a product in a particular product is purchased. ''' @@ -461,7 +464,7 @@ class CategoryEnablingCondition(EnablingConditionBase): @python_2_unicode_compatible -class VoucherEnablingCondition(EnablingConditionBase): +class VoucherFlag(EnablingConditionBase): ''' The condition is met because a Voucher is present. This is for e.g. enabling sponsor tickets. ''' @@ -472,10 +475,10 @@ class VoucherEnablingCondition(EnablingConditionBase): # @python_2_unicode_compatible -class RoleEnablingCondition(object): +class RoleFlag(object): ''' The condition is met because the active user has a particular Role. This is for e.g. enabling Team tickets. ''' - # TODO: implement RoleEnablingCondition + # TODO: implement RoleFlag pass diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index f8a82c21..eb7ff528 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -95,7 +95,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): - limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( + limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( description=name, mandatory=True, limit=limit, @@ -109,7 +109,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_category_ceiling( cls, name, limit=None, start_time=None, end_time=None): - limit_ceiling = rego.TimeOrStockLimitEnablingCondition.objects.create( + limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( description=name, mandatory=True, limit=limit, diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index d977cc5c..2782e464 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -12,13 +12,13 @@ from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') -class EnablingConditionTestCases(RegistrationCartTestCase): +class FlagTestCases(RegistrationCartTestCase): @classmethod def add_product_enabling_condition(cls, mandatory=False): ''' Adds a product enabling condition: adding PROD_1 to a cart is predicated on adding PROD_2 beforehand. ''' - enabling_condition = rego.ProductEnablingCondition.objects.create( + enabling_condition = rego.ProductFlag.objects.create( description="Product condition", mandatory=mandatory, ) @@ -31,7 +31,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def add_product_enabling_condition_on_category(cls, mandatory=False): ''' Adds a product enabling condition that operates on a category: adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' - enabling_condition = rego.ProductEnablingCondition.objects.create( + enabling_condition = rego.ProductFlag.objects.create( description="Product condition", mandatory=mandatory, ) @@ -43,7 +43,7 @@ class EnablingConditionTestCases(RegistrationCartTestCase): def add_category_enabling_condition(cls, mandatory=False): ''' Adds a category enabling condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' - enabling_condition = rego.CategoryEnablingCondition.objects.create( + enabling_condition = rego.CategoryFlag.objects.create( description="Category condition", mandatory=mandatory, enabling_category=cls.CAT_2, diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index d4614efb..de3f93ee 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -58,7 +58,7 @@ class VoucherTestCases(RegistrationCartTestCase): def test_voucher_enables_item(self): voucher = self.new_voucher() - enabling_condition = rego.VoucherEnablingCondition.objects.create( + enabling_condition = rego.VoucherFlag.objects.create( description="Voucher condition", voucher=voucher, mandatory=False, From 7b476fd5cb04ce106f291a06f30cb36cdb311267 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 17:56:11 +1000 Subject: [PATCH 130/418] s/enabling_condition/flag --- registrasion/tests/test_enabling_condition.py | 86 +++++++++---------- registrasion/tests/test_voucher.py | 8 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 2782e464..36cf34f1 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -15,45 +15,45 @@ UTC = pytz.timezone('UTC') class FlagTestCases(RegistrationCartTestCase): @classmethod - def add_product_enabling_condition(cls, mandatory=False): + def add_product_flag(cls, mandatory=False): ''' Adds a product enabling condition: adding PROD_1 to a cart is predicated on adding PROD_2 beforehand. ''' - enabling_condition = rego.ProductFlag.objects.create( + flag = rego.ProductFlag.objects.create( description="Product condition", mandatory=mandatory, ) - enabling_condition.save() - enabling_condition.products.add(cls.PROD_1) - enabling_condition.enabling_products.add(cls.PROD_2) - enabling_condition.save() + flag.save() + flag.products.add(cls.PROD_1) + flag.enabling_products.add(cls.PROD_2) + flag.save() @classmethod - def add_product_enabling_condition_on_category(cls, mandatory=False): + def add_product_flag_on_category(cls, mandatory=False): ''' Adds a product enabling condition that operates on a category: adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' - enabling_condition = rego.ProductFlag.objects.create( + flag = rego.ProductFlag.objects.create( description="Product condition", mandatory=mandatory, ) - enabling_condition.save() - enabling_condition.categories.add(cls.CAT_1) - enabling_condition.enabling_products.add(cls.PROD_3) - enabling_condition.save() + flag.save() + flag.categories.add(cls.CAT_1) + flag.enabling_products.add(cls.PROD_3) + flag.save() - def add_category_enabling_condition(cls, mandatory=False): + def add_category_flag(cls, mandatory=False): ''' Adds a category enabling condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' - enabling_condition = rego.CategoryFlag.objects.create( + flag = rego.CategoryFlag.objects.create( description="Category condition", mandatory=mandatory, enabling_category=cls.CAT_2, ) - enabling_condition.save() - enabling_condition.products.add(cls.PROD_1) - enabling_condition.save() + flag.save() + flag.products.add(cls.PROD_1) + flag.save() - def test_product_enabling_condition_enables_product(self): - self.add_product_enabling_condition() + 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) @@ -64,7 +64,7 @@ class FlagTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) def test_product_enabled_by_product_in_previous_cart(self): - self.add_product_enabling_condition() + self.add_product_flag() current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_2, 1) @@ -75,8 +75,8 @@ class FlagTestCases(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) - def test_product_enabling_condition_enables_category(self): - self.add_product_enabling_condition_on_category() + 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) @@ -86,8 +86,8 @@ class FlagTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_3, 1) current_cart.add_to_cart(self.PROD_1, 1) - def test_category_enabling_condition_enables_product(self): - self.add_category_enabling_condition() + 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) @@ -99,7 +99,7 @@ class FlagTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) def test_product_enabled_by_category_in_previous_cart(self): - self.add_category_enabling_condition() + self.add_category_flag() current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_3, 1) @@ -111,8 +111,8 @@ class FlagTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) def test_multiple_non_mandatory_conditions(self): - self.add_product_enabling_condition() - self.add_category_enabling_condition() + self.add_product_flag() + self.add_category_flag() # User 1 is testing the product enabling condition cart_1 = TestingCartController.for_user(self.USER_1) @@ -131,8 +131,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_1, 1) def test_multiple_mandatory_conditions(self): - self.add_product_enabling_condition(mandatory=True) - self.add_category_enabling_condition(mandatory=True) + self.add_product_flag(mandatory=True) + self.add_category_flag(mandatory=True) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -145,8 +145,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_1.add_to_cart(self.PROD_1, 1) def test_mandatory_conditions_are_mandatory(self): - self.add_product_enabling_condition(mandatory=False) - self.add_category_enabling_condition(mandatory=True) + self.add_product_flag(mandatory=False) + self.add_category_flag(mandatory=True) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -186,7 +186,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_4 in prods) def test_available_products_on_category_works_when_condition_not_met(self): - self.add_product_enabling_condition(mandatory=False) + self.add_product_flag(mandatory=False) prods = ProductController.available_products( self.USER_1, @@ -197,7 +197,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_category_works_when_condition_is_met(self): - self.add_product_enabling_condition(mandatory=False) + self.add_product_flag(mandatory=False) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -211,7 +211,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_not_met(self): - self.add_product_enabling_condition(mandatory=False) + self.add_product_flag(mandatory=False) prods = ProductController.available_products( self.USER_1, @@ -222,7 +222,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_is_met(self): - self.add_product_enabling_condition(mandatory=False) + self.add_product_flag(mandatory=False) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -235,8 +235,8 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_1 in prods) self.assertTrue(self.PROD_2 in prods) - def test_category_enabling_condition_fails_if_cart_refunded(self): - self.add_category_enabling_condition(mandatory=False) + def test_category_flag_fails_if_cart_refunded(self): + self.add_category_flag(mandatory=False) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) @@ -253,8 +253,8 @@ class FlagTestCases(RegistrationCartTestCase): with self.assertRaises(ValidationError): cart_2.set_quantity(self.PROD_1, 1) - def test_product_enabling_condition_fails_if_cart_refunded(self): - self.add_product_enabling_condition(mandatory=False) + def test_product_flag_fails_if_cart_refunded(self): + self.add_product_flag(mandatory=False) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -272,7 +272,7 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_available_categories(self): - self.add_product_enabling_condition_on_category(mandatory=False) + self.add_product_flag_on_category(mandatory=False) cart_1 = TestingCartController.for_user(self.USER_1) @@ -292,8 +292,8 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.CAT_1 in cats) self.assertTrue(self.CAT_2 in cats) - def test_validate_cart_when_enabling_conditions_become_unmet(self): - self.add_product_enabling_condition(mandatory=False) + def test_validate_cart_when_flags_become_unmet(self): + self.add_product_flag(mandatory=False) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -309,7 +309,7 @@ class FlagTestCases(RegistrationCartTestCase): cart.validate_cart() def test_fix_simple_errors_resolves_unavailable_products(self): - self.test_validate_cart_when_enabling_conditions_become_unmet() + self.test_validate_cart_when_flags_become_unmet() cart = TestingCartController.for_user(self.USER_1) # Should just remove all of the unavailable products diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index de3f93ee..894c6635 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -58,14 +58,14 @@ class VoucherTestCases(RegistrationCartTestCase): def test_voucher_enables_item(self): voucher = self.new_voucher() - enabling_condition = rego.VoucherFlag.objects.create( + flag = rego.VoucherFlag.objects.create( description="Voucher condition", voucher=voucher, mandatory=False, ) - enabling_condition.save() - enabling_condition.products.add(self.PROD_1) - enabling_condition.save() + flag.save() + flag.products.add(self.PROD_1) + flag.save() # Adding the product without a voucher will not work current_cart = TestingCartController.for_user(self.USER_1) From e88a287fefa68ac12a79ae1c2c51d77a5f84f32c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 17:59:20 +1000 Subject: [PATCH 131/418] renames test_enabling_condition to test_flag --- registrasion/tests/{test_enabling_condition.py => test_flag.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename registrasion/tests/{test_enabling_condition.py => test_flag.py} (100%) diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_flag.py similarity index 100% rename from registrasion/tests/test_enabling_condition.py rename to registrasion/tests/test_flag.py From c4c8a7ab82963ee3c3ecec098a7f133efabbde44 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 18:12:37 +1000 Subject: [PATCH 132/418] Tidies up the admin interface for flags --- .../migrations/0022_auto_20160411_0806.py | 31 +++++++++++++++++++ registrasion/models.py | 19 ++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 registrasion/migrations/0022_auto_20160411_0806.py diff --git a/registrasion/migrations/0022_auto_20160411_0806.py b/registrasion/migrations/0022_auto_20160411_0806.py new file mode 100644 index 00000000..436937ce --- /dev/null +++ b/registrasion/migrations/0022_auto_20160411_0806.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 08:06 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0021_auto_20160411_0748'), + ] + + operations = [ + migrations.AlterModelOptions( + name='categoryflag', + options={'verbose_name': 'flag (dependency on product from category)', 'verbose_name_plural': 'flags (dependency on product from category)'}, + ), + migrations.AlterModelOptions( + name='productflag', + options={'verbose_name': 'flag (dependency on product)', 'verbose_name_plural': 'flags (dependency on product)'}, + ), + migrations.AlterModelOptions( + name='timeorstocklimitflag', + options={'verbose_name': 'flag (time/stock limit)', 'verbose_name_plural': 'flags (time/stock limit)'}, + ), + migrations.AlterModelOptions( + name='voucherflag', + options={'verbose_name': 'flag (dependency on voucher)', 'verbose_name_plural': 'flags (dependency on voucher)'}, + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index a69f67dc..a7137f02 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -375,8 +375,8 @@ class EnablingConditionBase(models.Model): condition defined on a Product or Category, it will only be enabled if at least one condition is met. ''' - # TODO: rename to EnablingConditionBase once https://code.djangoproject.com/ticket/26488 - # is solved. + # TODO: rename to EnablingConditionBase once + # https://code.djangoproject.com/ticket/26488 is solved. objects = InheritanceManager() @@ -412,7 +412,8 @@ class TimeOrStockLimitFlag(EnablingConditionBase): ''' Registration product ceilings ''' class Meta: - verbose_name = _("ceiling") + verbose_name = _("flag (time/stock limit)") + verbose_name_plural = _("flags (time/stock limit)") start_time = models.DateTimeField( null=True, @@ -438,6 +439,10 @@ class TimeOrStockLimitFlag(EnablingConditionBase): class ProductFlag(EnablingConditionBase): ''' The condition is met because a specific product is purchased. ''' + class Meta: + verbose_name = _("flag (dependency on product)") + verbose_name_plural = _("flags (dependency on product)") + def __str__(self): return "Enabled by products: " + str(self.enabling_products.all()) @@ -453,6 +458,10 @@ class CategoryFlag(EnablingConditionBase): ''' The condition is met because a product in a particular product is purchased. ''' + class Meta: + verbose_name = _("flag (dependency on product from category)") + verbose_name_plural = _("flags (dependency on product from category)") + def __str__(self): return "Enabled by product in category: " + str(self.enabling_category) @@ -468,6 +477,10 @@ class VoucherFlag(EnablingConditionBase): ''' The condition is met because a Voucher is present. This is for e.g. enabling sponsor tickets. ''' + class Meta: + verbose_name = _("flag (dependency on voucher)") + verbose_name_plural = _("flags (dependency on voucher)") + def __str__(self): return "Enabled by voucher: %s" % self.voucher From 61dbe60cfad0f4a9f6f3770b7e83dd36ed2b88f1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 19:23:38 +1000 Subject: [PATCH 133/418] =?UTF-8?q?Renames=20the=20admin-visible=20names?= =?UTF-8?q?=20for=20many=20model=20classes,=20and=20adds=20a=20default=20o?= =?UTF-8?q?rdering=20where=20they=E2=80=99re=20useful=20too.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/admin.py | 3 -- .../migrations/0021_auto_20160411_0820.py | 51 +++++++++++++++++++ registrasion/models.py | 30 +++++++++-- 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 registrasion/migrations/0021_auto_20160411_0820.py diff --git a/registrasion/admin.py b/registrasion/admin.py index 7155e177..21c62e3b 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -16,7 +16,6 @@ class EffectsDisplayMixin(object): class ProductInline(admin.TabularInline): model = rego.Product - ordering = ("order", ) @admin.register(rego.Category) @@ -25,7 +24,6 @@ class CategoryAdmin(admin.ModelAdmin): fields = ("name", "description", "required", "render_type", "limit_per_user", "order",) list_display = ("name", "description") - ordering = ("order", ) inlines = [ ProductInline, ] @@ -36,7 +34,6 @@ class ProductAdmin(admin.ModelAdmin): model = rego.Product list_display = ("name", "category", "description") list_filter = ("category", ) - ordering = ("category__order", "order", ) # Discounts diff --git a/registrasion/migrations/0021_auto_20160411_0820.py b/registrasion/migrations/0021_auto_20160411_0820.py new file mode 100644 index 00000000..b6fc4367 --- /dev/null +++ b/registrasion/migrations/0021_auto_20160411_0820.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 08:20 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0020_auto_20160411_0258'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={'ordering': ('order',), 'verbose_name': 'inventory - category', 'verbose_name_plural': 'inventory - categories'}, + ), + migrations.AlterModelOptions( + name='discountitem', + options={'ordering': ('product',)}, + ), + migrations.AlterModelOptions( + name='includedproductdiscount', + options={'verbose_name': 'discount (product inclusions)', 'verbose_name_plural': 'discounts (product inclusions)'}, + ), + migrations.AlterModelOptions( + name='lineitem', + options={'ordering': ('id',)}, + ), + migrations.AlterModelOptions( + name='paymentbase', + options={'ordering': ('time',)}, + ), + migrations.AlterModelOptions( + name='product', + options={'ordering': ('category__order', 'order'), 'verbose_name': 'inventory - product'}, + ), + migrations.AlterModelOptions( + name='productitem', + options={'ordering': ('product',)}, + ), + migrations.AlterModelOptions( + name='timeorstocklimitdiscount', + options={'verbose_name': 'discount (time/stock limit)', 'verbose_name_plural': 'discounts (time/stock limit)'}, + ), + migrations.AlterModelOptions( + name='voucherdiscount', + options={'verbose_name': 'discount (enabled by voucher)', 'verbose_name_plural': 'discounts (enabled by voucher)'}, + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 6e625887..baf7cec3 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -89,7 +89,9 @@ class Category(models.Model): ''' Registration product categories ''' class Meta: - verbose_name_plural = _("categories") + verbose_name = _("inventory - category") + verbose_name_plural = _("inventory - categories") + ordering = ("order", ) def __str__(self): return self.name @@ -138,6 +140,10 @@ class Category(models.Model): class Product(models.Model): ''' Registration products ''' + class Meta: + verbose_name = _("inventory - product") + ordering = ("category__order", "order") + def __str__(self): return "%s - %s" % (self.category.name, self.name) @@ -310,7 +316,8 @@ class TimeOrStockLimitDiscount(DiscountBase): usage count. This is for e.g. Early Bird discounts. ''' class Meta: - verbose_name = _("Promotional discount") + verbose_name = _("discount (time/stock limit)") + verbose_name_plural = _("discounts (time/stock limit)") start_time = models.DateTimeField( null=True, @@ -336,6 +343,10 @@ class VoucherDiscount(DiscountBase): ''' Discounts that are enabled when a voucher code is in the current cart. ''' + class Meta: + verbose_name = _("discount (enabled by voucher)") + verbose_name_plural = _("discounts (enabled by voucher)") + voucher = models.OneToOneField( Voucher, on_delete=models.CASCADE, @@ -349,7 +360,8 @@ class IncludedProductDiscount(DiscountBase): e.g. A conference ticket includes a free t-shirt. ''' class Meta: - verbose_name = _("Product inclusion") + verbose_name = _("discount (product inclusions)") + verbose_name_plural = _("discounts (product inclusions)") enabling_products = models.ManyToManyField( Product, @@ -530,6 +542,9 @@ class Cart(models.Model): class ProductItem(models.Model): ''' Represents a product-quantity pair in a Cart. ''' + class Meta: + ordering = ("product", ) + def __str__(self): return "product: %s * %d in Cart: %s" % ( self.product, self.quantity, self.cart) @@ -543,6 +558,9 @@ class ProductItem(models.Model): class DiscountItem(models.Model): ''' Represents a discount-product-quantity relation in a Cart. ''' + class Meta: + ordering = ("product", ) + def __str__(self): return "%s: %s * %d in Cart: %s" % ( self.discount, self.product, self.quantity, self.cart) @@ -618,6 +636,9 @@ class LineItem(models.Model): and DiscountItems that belong to a cart (for consistency), but also allow for arbitrary line items when required. ''' + class Meta: + ordering = ("id", ) + def __str__(self): return "Line: %s * %d @ %s" % ( self.description, self.quantity, self.price) @@ -634,6 +655,9 @@ class PaymentBase(models.Model): ''' The base payment type for invoices. Payment apps should subclass this class to handle implementation-specific issues. ''' + class Meta: + ordering = ("time", ) + objects = InheritanceManager() def __str__(self): From 638ec261265cb3e408808c58dbb7f620c791b847 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 11 Apr 2016 20:02:16 +1000 Subject: [PATCH 134/418] Replaces the mandatory/non-mandatory concept with the enabled_if_true/disabled_if_false concept. Closes #4. --- registrasion/controllers/conditions.py | 33 +++++----- ...1_1001_squashed_0024_auto_20160411_1002.py | 37 ++++++++++++ registrasion/models.py | 60 ++++++++++++++----- registrasion/tests/test_cart.py | 4 +- registrasion/tests/test_flag.py | 60 ++++++++++++------- registrasion/tests/test_voucher.py | 2 +- 6 files changed, 138 insertions(+), 58 deletions(-) create mode 100644 registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 787aed05..ddae0de7 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -107,11 +107,11 @@ class ConditionController(object): else: all_conditions = [] - # All mandatory conditions on a product need to be met - mandatory = defaultdict(lambda: True) - # At least one non-mandatory condition on a product must be met - # if there are no mandatory conditions - non_mandatory = defaultdict(lambda: False) + # 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) messages = {} @@ -146,22 +146,23 @@ class ConditionController(object): message = base % {"items": items, "remainder": remainder} for product in all_products: - if condition.mandatory: - mandatory[product] &= met + if condition.is_disable_if_false: + do_not_disable[product] &= met else: - non_mandatory[product] |= met + do_enable[product] |= met if not met and product not in messages: messages[product] = message - valid = defaultdict(lambda: True) - for product in itertools.chain(mandatory, non_mandatory): - if product in mandatory: - # If there's a mandatory condition, all must be met - valid[product] = mandatory[product] - else: - # Otherwise, we need just one non-mandatory condition met - valid[product] = non_mandatory[product] + valid = {} + for product in itertools.chain(do_not_disable, do_enable): + 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]) diff --git a/registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py b/registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py new file mode 100644 index 00000000..e9a9f6bb --- /dev/null +++ b/registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 10:46 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('registrasion', '0023_auto_20160411_1001'), ('registrasion', '0024_auto_20160411_1002')] + + dependencies = [ + ('registrasion', '0022_auto_20160411_0806'), + ] + + operations = [ + migrations.AlterField( + model_name='enablingconditionbase', + name='categories', + field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", to=b'registrasion.Category'), + ), + migrations.RenameField( + model_name='enablingconditionbase', + old_name='mandatory', + new_name='condition', + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='condition', + field=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.AlterField( + model_name='enablingconditionbase', + name='products', + field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", to=b'registrasion.Product'), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index a7137f02..58ec1f96 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -366,16 +366,28 @@ class RoleDiscount(object): pass +class FlagBase(object): + ''' This will replace EnablingConditionBase once it's ready. ''' + + DISABLE_IF_FALSE = 1 + ENABLE_IF_TRUE = 2 + + @python_2_unicode_compatible class EnablingConditionBase(models.Model): ''' This defines a condition which allows products or categories to - be made visible. If there is at least one mandatory enabling condition - defined on a Product or Category, it will only be enabled if *all* - mandatory conditions are met, otherwise, if there is at least one enabling - condition defined on a Product or Category, it will only be enabled if at - least one condition is met. ''' + be made visible, or be prevented from being visible. - # TODO: rename to EnablingConditionBase once + The various subclasses of this can define the conditions that enable + or disable products, by the following rules: + + If there is at least one 'disable if false' flag defined on a product or + category, all such flag conditions must be met. If there is at least one + 'enable if true' flag, at least one such condition must be met. + + If both types of conditions exist on a product, both of these rules apply. + ''' + # TODO: rename to FlagBase once # https://code.djangoproject.com/ticket/26488 is solved. objects = InheritanceManager() @@ -384,27 +396,43 @@ class EnablingConditionBase(models.Model): return self.description def effects(self): - ''' Returns all of the items enabled by this condition. ''' + ''' 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) - mandatory = models.BooleanField( - default=False, - help_text=_("If there is at least one mandatory condition defined on " - "a product or category, all such conditions must be met. " - "Otherwise, at least one non-mandatory condition must be " - "met."), + condition = models.IntegerField( + default=FlagBase.ENABLE_IF_TRUE, + choices=( + (FlagBase.DISABLE_IF_FALSE, _("Disable if false")), + (FlagBase.ENABLE_IF_TRUE, _("Enable if true")), + ), + help_text=_("If there is at least one 'disable if false' flag " + "defined on a product or category, all such flag " + " conditions must be met. If there is at least one " + "'enable if true' flag, at least one such condition must " + "be met. If both types of conditions exist on a product, " + "both of these rules apply." + ), ) products = models.ManyToManyField( Product, blank=True, - help_text=_("Products that are enabled if this condition is met."), + help_text=_("Products affected by this flag's condition."), ) categories = models.ManyToManyField( Category, blank=True, - help_text=_("Categories whose products are enabled if this condition " - "is met."), + help_text=_("Categories whose products are affected by this flag's " + "condition." + ), ) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index eb7ff528..066bf377 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -97,7 +97,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( description=name, - mandatory=True, + condition=rego.FlagBase.DISABLE_IF_FALSE, limit=limit, start_time=start_time, end_time=end_time @@ -111,7 +111,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls, name, limit=None, start_time=None, end_time=None): limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( description=name, - mandatory=True, + condition=rego.FlagBase.DISABLE_IF_FALSE, limit=limit, start_time=start_time, end_time=end_time diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index 36cf34f1..b1ccdc3c 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -15,12 +15,12 @@ UTC = pytz.timezone('UTC') class FlagTestCases(RegistrationCartTestCase): @classmethod - def add_product_flag(cls, mandatory=False): + def add_product_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): ''' Adds a product enabling condition: adding PROD_1 to a cart is predicated on adding PROD_2 beforehand. ''' flag = rego.ProductFlag.objects.create( description="Product condition", - mandatory=mandatory, + condition=condition, ) flag.save() flag.products.add(cls.PROD_1) @@ -28,24 +28,24 @@ class FlagTestCases(RegistrationCartTestCase): flag.save() @classmethod - def add_product_flag_on_category(cls, mandatory=False): + def add_product_flag_on_category(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): ''' Adds a product enabling condition that operates on a category: adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' flag = rego.ProductFlag.objects.create( description="Product condition", - mandatory=mandatory, + condition=condition, ) flag.save() flag.categories.add(cls.CAT_1) flag.enabling_products.add(cls.PROD_3) flag.save() - def add_category_flag(cls, mandatory=False): + def add_category_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): ''' Adds a category enabling condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' flag = rego.CategoryFlag.objects.create( description="Category condition", - mandatory=mandatory, + condition=condition, enabling_category=cls.CAT_2, ) flag.save() @@ -110,7 +110,7 @@ class FlagTestCases(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) - def test_multiple_non_mandatory_conditions(self): + def test_multiple_eit_conditions(self): self.add_product_flag() self.add_category_flag() @@ -130,9 +130,9 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_3, 1) cart_2.add_to_cart(self.PROD_1, 1) - def test_multiple_mandatory_conditions(self): - self.add_product_flag(mandatory=True) - self.add_category_flag(mandatory=True) + def test_multiple_dif_conditions(self): + self.add_product_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) + self.add_category_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -144,18 +144,32 @@ class FlagTestCases(RegistrationCartTestCase): cart_1.add_to_cart(self.PROD_3, 1) # Meets the category condition cart_1.add_to_cart(self.PROD_1, 1) - def test_mandatory_conditions_are_mandatory(self): - self.add_product_flag(mandatory=False) - self.add_category_flag(mandatory=True) + def test_eit_and_dif_conditions_work_together(self): + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_category_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) 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 + + 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.add_to_cart(self.PROD_3, 1) # Meets the category condition + + 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): @@ -186,7 +200,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_4 in prods) def test_available_products_on_category_works_when_condition_not_met(self): - self.add_product_flag(mandatory=False) + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) prods = ProductController.available_products( self.USER_1, @@ -197,7 +211,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_category_works_when_condition_is_met(self): - self.add_product_flag(mandatory=False) + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -211,7 +225,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_not_met(self): - self.add_product_flag(mandatory=False) + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) prods = ProductController.available_products( self.USER_1, @@ -222,7 +236,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_is_met(self): - self.add_product_flag(mandatory=False) + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -236,7 +250,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_category_flag_fails_if_cart_refunded(self): - self.add_category_flag(mandatory=False) + self.add_category_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) @@ -254,7 +268,7 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_product_flag_fails_if_cart_refunded(self): - self.add_product_flag(mandatory=False) + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -272,7 +286,7 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_available_categories(self): - self.add_product_flag_on_category(mandatory=False) + self.add_product_flag_on_category(condition=rego.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) @@ -293,7 +307,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.CAT_2 in cats) def test_validate_cart_when_flags_become_unmet(self): - self.add_product_flag(mandatory=False) + self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 894c6635..5f1e07f0 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -61,7 +61,7 @@ class VoucherTestCases(RegistrationCartTestCase): flag = rego.VoucherFlag.objects.create( description="Voucher condition", voucher=voucher, - mandatory=False, + condition=rego.FlagBase.ENABLE_IF_TRUE, ) flag.save() flag.products.add(self.PROD_1) From c24b9ee213b9307d4435cc6c19d359d9e39dedac Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 12 Apr 2016 08:30:33 +1000 Subject: [PATCH 135/418] Makes EnablingConditionBase a minimal reification of an abstract base model FlagBase, replaces enablingconditionbase with flagbase where possible, and fixes method names and documentation --- registrasion/controllers/cart.py | 6 ++-- registrasion/controllers/conditions.py | 15 ++++---- registrasion/controllers/product.py | 6 ++-- .../migrations/0024_auto_20160411_2230.py | 25 +++++++++++++ registrasion/models.py | 36 +++++++++++-------- registrasion/tests/test_flag.py | 10 +++--- 6 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 registrasion/migrations/0024_auto_20160411_2230.py diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 2b711c70..70972be5 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -118,7 +118,7 @@ class CartController(object): def _test_limits(self, product_quantities): ''' Tests that the quantity changes we intend to make do not violate - the limits and enabling conditions imposed on the products. ''' + the limits and flag conditions imposed on the products. ''' errors = [] @@ -159,8 +159,8 @@ class CartController(object): ) )) - # Test the enabling conditions - errs = ConditionController.test_enabling_conditions( + # Test the flag conditions + errs = ConditionController.test_flags( self.cart.user, product_quantities=product_quantities, ) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index ddae0de7..fde2f805 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -65,16 +65,16 @@ class ConditionController(object): } @classmethod - def test_enabling_conditions( + def test_flags( cls, user, products=None, product_quantities=None): - ''' Evaluates all of the enabling conditions on the given products. + ''' 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 enabling conditions pass, an empty list is returned, otherwise + If all flag conditions pass, an empty list is returned, otherwise a list is returned containing all of the products that are *not enabled*. ''' @@ -90,14 +90,13 @@ class ConditionController(object): quantities = {} # Get the conditions covered by the products themselves - prods = ( - product.enablingconditionbase_set.select_subclasses() + product.flagbase_set.select_subclasses() for product in products ) # Get the conditions covered by their categories cats = ( - category.enablingconditionbase_set.select_subclasses() + category.flagbase_set.select_subclasses() for category in set(product.category for product in products) ) @@ -172,7 +171,7 @@ class ConditionController(object): return error_fields def user_quantity_remaining(self, user): - ''' Returns the number of items covered by this enabling condition the + ''' 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. @@ -182,7 +181,7 @@ class ConditionController(object): return 99999999 if self.is_met(user) else 0 def is_met(self, user): - ''' Returns True if this enabling condition is met, otherwise returns + ''' Returns True if this flag condition is met, otherwise returns False. Either this method, or user_quantity_remaining() must be overridden diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 4e9e6cab..a28f99cb 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -15,9 +15,9 @@ class ProductController(object): @classmethod def available_products(cls, user, category=None, products=None): ''' Returns a list of all of the products that are available per - enabling conditions from the given categories. + flag conditions from the given categories. TODO: refactor so that all conditions are tested here and - can_add_with_enabling_conditions calls this method. ''' + can_add_with_flags calls this method. ''' if category is None and products is None: raise ValueError("You must provide products or a category") @@ -45,7 +45,7 @@ class ProductController(object): if cls(product).user_quantity_remaining(user) > 0 ) - failed_and_messages = ConditionController.test_enabling_conditions( + failed_and_messages = ConditionController.test_flags( user, products=passed_limits ) failed_conditions = set(i[0] for i in failed_and_messages) diff --git a/registrasion/migrations/0024_auto_20160411_2230.py b/registrasion/migrations/0024_auto_20160411_2230.py new file mode 100644 index 00000000..e1baf8c4 --- /dev/null +++ b/registrasion/migrations/0024_auto_20160411_2230.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 22:30 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0023_auto_20160411_1001_squashed_0024_auto_20160411_1002'), + ] + + operations = [ + migrations.AlterField( + model_name='enablingconditionbase', + 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.AlterField( + model_name='enablingconditionbase', + name='products', + field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", related_name='flagbase_set', to='registrasion.Product'), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 58ec1f96..734fd0f0 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -366,15 +366,8 @@ class RoleDiscount(object): pass -class FlagBase(object): - ''' This will replace EnablingConditionBase once it's ready. ''' - - DISABLE_IF_FALSE = 1 - ENABLE_IF_TRUE = 2 - - @python_2_unicode_compatible -class EnablingConditionBase(models.Model): +class FlagBase(models.Model): ''' This defines a condition which allows products or categories to be made visible, or be prevented from being visible. @@ -387,10 +380,14 @@ class EnablingConditionBase(models.Model): If both types of conditions exist on a product, both of these rules apply. ''' - # TODO: rename to FlagBase once - # https://code.djangoproject.com/ticket/26488 is solved. - objects = InheritanceManager() + class Meta: + # TODO: make concrete once https://code.djangoproject.com/ticket/26488 + # is solved. + abstract = True + + DISABLE_IF_FALSE = 1 + ENABLE_IF_TRUE = 2 def __str__(self): return self.description @@ -409,10 +406,10 @@ class EnablingConditionBase(models.Model): description = models.CharField(max_length=255) condition = models.IntegerField( - default=FlagBase.ENABLE_IF_TRUE, + default=ENABLE_IF_TRUE, choices=( - (FlagBase.DISABLE_IF_FALSE, _("Disable if false")), - (FlagBase.ENABLE_IF_TRUE, _("Enable if true")), + (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 " @@ -426,6 +423,7 @@ class EnablingConditionBase(models.Model): Product, blank=True, help_text=_("Products affected by this flag's condition."), + related_name="flagbase_set", ) categories = models.ManyToManyField( Category, @@ -433,9 +431,19 @@ class EnablingConditionBase(models.Model): help_text=_("Categories whose products are affected by this flag's " "condition." ), + related_name="flagbase_set", ) +class EnablingConditionBase(FlagBase): + ''' Reifies the abstract FlagBase. This is necessary because django + prevents renaming base classes in migrations. ''' + # TODO: remove this, and make subclasses subclass FlagBase once + # https://code.djangoproject.com/ticket/26488 is solved. + + objects = InheritanceManager() + + class TimeOrStockLimitFlag(EnablingConditionBase): ''' Registration product ceilings ''' diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index b1ccdc3c..e1e1c166 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -16,7 +16,7 @@ class FlagTestCases(RegistrationCartTestCase): @classmethod def add_product_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): - ''' Adds a product enabling condition: adding PROD_1 to a cart is + ''' Adds a product flag condition: adding PROD_1 to a cart is predicated on adding PROD_2 beforehand. ''' flag = rego.ProductFlag.objects.create( description="Product condition", @@ -29,7 +29,7 @@ class FlagTestCases(RegistrationCartTestCase): @classmethod def add_product_flag_on_category(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): - ''' Adds a product enabling condition that operates on a category: + ''' Adds a product flag condition that operates on a category: adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' flag = rego.ProductFlag.objects.create( description="Product condition", @@ -41,7 +41,7 @@ class FlagTestCases(RegistrationCartTestCase): flag.save() def add_category_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): - ''' Adds a category enabling condition: adding PROD_1 to a cart is + ''' Adds a category flag condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' flag = rego.CategoryFlag.objects.create( description="Category condition", @@ -114,7 +114,7 @@ class FlagTestCases(RegistrationCartTestCase): self.add_product_flag() self.add_category_flag() - # User 1 is testing the product enabling condition + # 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): @@ -122,7 +122,7 @@ class FlagTestCases(RegistrationCartTestCase): cart_1.add_to_cart(self.PROD_2, 1) cart_1.add_to_cart(self.PROD_1, 1) - # User 2 is testing the category enabling condition + # 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): From d3f7431f7d55957cb7b900f1f203783cdfacfc4d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 12 Apr 2016 08:47:17 +1000 Subject: [PATCH 136/418] Squashes migrations from rename_to_flags branch; marks as normal migration --- .../migrations/0021_auto_20160411_0748.py | 31 -------- ...1_0748_squashed_0024_auto_20160411_2230.py | 77 +++++++++++++++++++ .../migrations/0022_auto_20160411_0806.py | 31 -------- ...1_1001_squashed_0024_auto_20160411_1002.py | 37 --------- .../migrations/0024_auto_20160411_2230.py | 25 ------ 5 files changed, 77 insertions(+), 124 deletions(-) delete mode 100644 registrasion/migrations/0021_auto_20160411_0748.py create mode 100644 registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py delete mode 100644 registrasion/migrations/0022_auto_20160411_0806.py delete mode 100644 registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py delete mode 100644 registrasion/migrations/0024_auto_20160411_2230.py diff --git a/registrasion/migrations/0021_auto_20160411_0748.py b/registrasion/migrations/0021_auto_20160411_0748.py deleted file mode 100644 index 345cd4ad..00000000 --- a/registrasion/migrations/0021_auto_20160411_0748.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 07:48 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0020_auto_20160411_0258'), - ] - - operations = [ - migrations.RenameModel( - old_name='CategoryEnablingCondition', - new_name='CategoryFlag', - ), - migrations.RenameModel( - old_name='ProductEnablingCondition', - new_name='ProductFlag', - ), - migrations.RenameModel( - old_name='TimeOrStockLimitEnablingCondition', - new_name='TimeOrStockLimitFlag', - ), - migrations.RenameModel( - old_name='VoucherEnablingCondition', - new_name='VoucherFlag', - ), - ] diff --git a/registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py b/registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py new file mode 100644 index 00000000..53b82d10 --- /dev/null +++ b/registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-11 22:46 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0020_auto_20160411_0258'), + ] + + operations = [ + migrations.RenameModel( + old_name='CategoryEnablingCondition', + new_name='CategoryFlag', + ), + migrations.RenameModel( + old_name='ProductEnablingCondition', + new_name='ProductFlag', + ), + migrations.RenameModel( + old_name='TimeOrStockLimitEnablingCondition', + new_name='TimeOrStockLimitFlag', + ), + migrations.RenameModel( + old_name='VoucherEnablingCondition', + new_name='VoucherFlag', + ), + migrations.AlterModelOptions( + name='categoryflag', + options={'verbose_name': 'flag (dependency on product from category)', 'verbose_name_plural': 'flags (dependency on product from category)'}, + ), + migrations.AlterModelOptions( + name='productflag', + options={'verbose_name': 'flag (dependency on product)', 'verbose_name_plural': 'flags (dependency on product)'}, + ), + migrations.AlterModelOptions( + name='timeorstocklimitflag', + options={'verbose_name': 'flag (time/stock limit)', 'verbose_name_plural': 'flags (time/stock limit)'}, + ), + migrations.AlterModelOptions( + name='voucherflag', + options={'verbose_name': 'flag (dependency on voucher)', 'verbose_name_plural': 'flags (dependency on voucher)'}, + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='categories', + field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", to=b'registrasion.Category'), + ), + migrations.RenameField( + model_name='enablingconditionbase', + old_name='mandatory', + new_name='condition', + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='condition', + field=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.AlterField( + model_name='enablingconditionbase', + name='products', + field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", to=b'registrasion.Product'), + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='categories', + field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", related_name='flagbase_set', to=b'registrasion.Category'), + ), + migrations.AlterField( + model_name='enablingconditionbase', + name='products', + field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", related_name='flagbase_set', to=b'registrasion.Product'), + ), + ] diff --git a/registrasion/migrations/0022_auto_20160411_0806.py b/registrasion/migrations/0022_auto_20160411_0806.py deleted file mode 100644 index 436937ce..00000000 --- a/registrasion/migrations/0022_auto_20160411_0806.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 08:06 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0021_auto_20160411_0748'), - ] - - operations = [ - migrations.AlterModelOptions( - name='categoryflag', - options={'verbose_name': 'flag (dependency on product from category)', 'verbose_name_plural': 'flags (dependency on product from category)'}, - ), - migrations.AlterModelOptions( - name='productflag', - options={'verbose_name': 'flag (dependency on product)', 'verbose_name_plural': 'flags (dependency on product)'}, - ), - migrations.AlterModelOptions( - name='timeorstocklimitflag', - options={'verbose_name': 'flag (time/stock limit)', 'verbose_name_plural': 'flags (time/stock limit)'}, - ), - migrations.AlterModelOptions( - name='voucherflag', - options={'verbose_name': 'flag (dependency on voucher)', 'verbose_name_plural': 'flags (dependency on voucher)'}, - ), - ] diff --git a/registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py b/registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py deleted file mode 100644 index e9a9f6bb..00000000 --- a/registrasion/migrations/0023_auto_20160411_1001_squashed_0024_auto_20160411_1002.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 10:46 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [('registrasion', '0023_auto_20160411_1001'), ('registrasion', '0024_auto_20160411_1002')] - - dependencies = [ - ('registrasion', '0022_auto_20160411_0806'), - ] - - operations = [ - migrations.AlterField( - model_name='enablingconditionbase', - name='categories', - field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", to=b'registrasion.Category'), - ), - migrations.RenameField( - model_name='enablingconditionbase', - old_name='mandatory', - new_name='condition', - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='condition', - field=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.AlterField( - model_name='enablingconditionbase', - name='products', - field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", to=b'registrasion.Product'), - ), - ] diff --git a/registrasion/migrations/0024_auto_20160411_2230.py b/registrasion/migrations/0024_auto_20160411_2230.py deleted file mode 100644 index e1baf8c4..00000000 --- a/registrasion/migrations/0024_auto_20160411_2230.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 22:30 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0023_auto_20160411_1001_squashed_0024_auto_20160411_1002'), - ] - - operations = [ - migrations.AlterField( - model_name='enablingconditionbase', - 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.AlterField( - model_name='enablingconditionbase', - name='products', - field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", related_name='flagbase_set', to='registrasion.Product'), - ), - ] From ffa1ca678332bc5c6418d685f523367380bd72cc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 12 Apr 2016 11:43:38 +1000 Subject: [PATCH 137/418] Fixes flags admin --- registrasion/admin.py | 4 ++-- registrasion/migrations/0022_merge.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 registrasion/migrations/0022_merge.py diff --git a/registrasion/admin.py b/registrasion/admin.py index 7065b0a2..ff9b0ec4 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -151,7 +151,7 @@ class ProductFlagAdmin( return list(obj.enabling_products.all()) model = rego.ProductFlag - fields = ("description", "enabling_products", "mandatory", "products", + fields = ("description", "enabling_products", "condition", "products", "categories"), list_display = ("description", "enablers", "effects") @@ -164,7 +164,7 @@ class CategoryFlagAdmin( EffectsDisplayMixin): model = rego.CategoryFlag - fields = ("description", "enabling_category", "mandatory", "products", + fields = ("description", "enabling_category", "condition", "products", "categories"), list_display = ("description", "enabling_category", "effects") diff --git a/registrasion/migrations/0022_merge.py b/registrasion/migrations/0022_merge.py new file mode 100644 index 00000000..e3c30039 --- /dev/null +++ b/registrasion/migrations/0022_merge.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-12 01:40 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0021_auto_20160411_0748_squashed_0024_auto_20160411_2230'), + ('registrasion', '0021_auto_20160411_0820'), + ] + + operations = [ + ] From 875f736d67c52b98e9222bcb02fc486f0a608e29 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Apr 2016 15:06:24 +1000 Subject: [PATCH 138/418] Consolidates models.py into a directory module. --- registrasion/admin.py | 41 +- registrasion/controllers/cart.py | 36 +- registrasion/controllers/category.py | 11 +- registrasion/controllers/conditions.py | 40 +- registrasion/controllers/credit_note.py | 6 +- registrasion/controllers/discount.py | 13 +- registrasion/controllers/invoice.py | 46 +- registrasion/controllers/product.py | 9 +- registrasion/forms.py | 16 +- registrasion/models.py | 818 ------------------ registrasion/models/__init__.py | 4 + registrasion/models/commerce.py | 304 +++++++ registrasion/models/conditions.py | 361 ++++++++ registrasion/models/inventory.py | 172 ++++ registrasion/models/people.py | 79 ++ .../templatetags/registrasion_tags.py | 13 +- registrasion/tests/controller_helpers.py | 8 +- registrasion/tests/test_cart.py | 41 +- registrasion/tests/test_ceilings.py | 6 +- registrasion/tests/test_discount.py | 16 +- registrasion/tests/test_flag.py | 48 +- registrasion/tests/test_invoice.py | 78 +- registrasion/tests/test_voucher.py | 15 +- registrasion/views.py | 52 +- 24 files changed, 1193 insertions(+), 1040 deletions(-) delete mode 100644 registrasion/models.py create mode 100644 registrasion/models/__init__.py create mode 100644 registrasion/models/commerce.py create mode 100644 registrasion/models/conditions.py create mode 100644 registrasion/models/inventory.py create mode 100644 registrasion/models/people.py diff --git a/registrasion/admin.py b/registrasion/admin.py index ff9b0ec4..35f73e9f 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -4,7 +4,8 @@ from django.utils.translation import ugettext_lazy as _ import nested_admin -from registrasion import models as rego +from registrasion.models import conditions +from registrasion.models import inventory class EffectsDisplayMixin(object): @@ -15,12 +16,12 @@ class EffectsDisplayMixin(object): class ProductInline(admin.TabularInline): - model = rego.Product + model = inventory.Product -@admin.register(rego.Category) +@admin.register(inventory.Category) class CategoryAdmin(admin.ModelAdmin): - model = rego.Category + model = inventory.Category fields = ("name", "description", "required", "render_type", "limit_per_user", "order",) list_display = ("name", "description") @@ -29,9 +30,9 @@ class CategoryAdmin(admin.ModelAdmin): ] -@admin.register(rego.Product) +@admin.register(inventory.Product) class ProductAdmin(admin.ModelAdmin): - model = rego.Product + model = inventory.Product list_display = ("name", "category", "description") list_filter = ("category", ) @@ -39,18 +40,18 @@ class ProductAdmin(admin.ModelAdmin): # Discounts class DiscountForProductInline(admin.TabularInline): - model = rego.DiscountForProduct + model = conditions.DiscountForProduct verbose_name = _("Product included in discount") verbose_name_plural = _("Products included in discount") class DiscountForCategoryInline(admin.TabularInline): - model = rego.DiscountForCategory + model = conditions.DiscountForCategory verbose_name = _("Category included in discount") verbose_name_plural = _("Categories included in discount") -@admin.register(rego.TimeOrStockLimitDiscount) +@admin.register(conditions.TimeOrStockLimitDiscount) class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): list_display = ( "description", @@ -67,7 +68,7 @@ class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): ] -@admin.register(rego.IncludedProductDiscount) +@admin.register(conditions.IncludedProductDiscount) class IncludedProductDiscountAdmin(admin.ModelAdmin): def enablers(self, obj): @@ -87,7 +88,7 @@ class IncludedProductDiscountAdmin(admin.ModelAdmin): # Vouchers class VoucherDiscountInline(nested_admin.NestedStackedInline): - model = rego.VoucherDiscount + model = conditions.VoucherDiscount verbose_name = _("Discount") # TODO work out why we're allowed to add more than one? @@ -100,7 +101,7 @@ class VoucherDiscountInline(nested_admin.NestedStackedInline): class VoucherFlagInline(nested_admin.NestedStackedInline): - model = rego.VoucherFlag + model = conditions.VoucherFlag verbose_name = _("Product and category enabled by voucher") verbose_name_plural = _("Products and categories enabled by voucher") @@ -109,7 +110,7 @@ class VoucherFlagInline(nested_admin.NestedStackedInline): extra = 1 -@admin.register(rego.Voucher) +@admin.register(inventory.Voucher) class VoucherAdmin(nested_admin.NestedAdmin): def effects(self, obj): @@ -133,7 +134,7 @@ class VoucherAdmin(nested_admin.NestedAdmin): return "\n".join(out) - model = rego.Voucher + model = inventory.Voucher list_display = ("recipient", "code", "effects") inlines = [ VoucherDiscountInline, @@ -142,7 +143,7 @@ class VoucherAdmin(nested_admin.NestedAdmin): # Enabling conditions -@admin.register(rego.ProductFlag) +@admin.register(conditions.ProductFlag) class ProductFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): @@ -150,7 +151,7 @@ class ProductFlagAdmin( def enablers(self, obj): return list(obj.enabling_products.all()) - model = rego.ProductFlag + model = conditions.ProductFlag fields = ("description", "enabling_products", "condition", "products", "categories"), @@ -158,12 +159,12 @@ class ProductFlagAdmin( # Enabling conditions -@admin.register(rego.CategoryFlag) +@admin.register(conditions.CategoryFlag) class CategoryFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): - model = rego.CategoryFlag + model = conditions.CategoryFlag fields = ("description", "enabling_category", "condition", "products", "categories"), @@ -172,11 +173,11 @@ class CategoryFlagAdmin( # Enabling conditions -@admin.register(rego.TimeOrStockLimitFlag) +@admin.register(conditions.TimeOrStockLimitFlag) class TimeOrStockLimitFlagAdmin( nested_admin.NestedAdmin, EffectsDisplayMixin): - model = rego.TimeOrStockLimitFlag + model = conditions.TimeOrStockLimitFlag list_display = ( "description", diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 70972be5..a43115de 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -9,8 +9,10 @@ from django.db import transaction from django.db.models import Max from django.utils import timezone -from registrasion import models as rego from registrasion.exceptions import CartValidationError +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory from category import CategoryController from conditions import ConditionController @@ -28,9 +30,9 @@ class CartController(object): if there isn't one ready yet. ''' try: - existing = rego.Cart.objects.get(user=user, active=True) + existing = commerce.Cart.objects.get(user=user, active=True) except ObjectDoesNotExist: - existing = rego.Cart.objects.create( + existing = commerce.Cart.objects.create( user=user, time_last_updated=timezone.now(), reservation_duration=datetime.timedelta(), @@ -47,10 +49,10 @@ class CartController(object): # If we have vouchers, we're entitled to an hour at minimum. if len(self.cart.vouchers.all()) >= 1: - reservations.append(rego.Voucher.RESERVATION_DURATION) + reservations.append(inventory.Voucher.RESERVATION_DURATION) # Else, it's the maximum of the included products - items = rego.ProductItem.objects.filter(cart=self.cart) + items = commerce.ProductItem.objects.filter(cart=self.cart) agg = items.aggregate(Max("product__reservation_duration")) product_max = agg["product__reservation_duration__max"] @@ -79,7 +81,7 @@ class CartController(object): is violated. `product_quantities` is an iterable of (product, quantity) pairs. ''' - items_in_cart = rego.ProductItem.objects.filter(cart=self.cart) + items_in_cart = commerce.ProductItem.objects.filter(cart=self.cart) items_in_cart = items_in_cart.select_related( "product", "product__category", @@ -99,14 +101,14 @@ class CartController(object): for product, quantity in product_quantities: try: - product_item = rego.ProductItem.objects.get( + product_item = commerce.ProductItem.objects.get( cart=self.cart, product=product, ) product_item.quantity = quantity product_item.save() except ObjectDoesNotExist: - rego.ProductItem.objects.create( + commerce.ProductItem.objects.create( cart=self.cart, product=product, quantity=quantity, @@ -176,7 +178,7 @@ class CartController(object): ''' Applies the voucher with the given code to this cart. ''' # Try and find the voucher - voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + voucher = inventory.Voucher.objects.get(code=voucher_code.upper()) # Re-applying vouchers should be idempotent if voucher in self.cart.vouchers.all(): @@ -193,7 +195,7 @@ class CartController(object): Raises ValidationError if not. ''' # Is voucher exhausted? - active_carts = rego.Cart.reserved_carts() + active_carts = commerce.Cart.reserved_carts() # It's invalid for a user to enter a voucher that's exhausted carts_with_voucher = active_carts.filter(vouchers=voucher) @@ -238,7 +240,7 @@ class CartController(object): except ValidationError as ve: errors.append(ve) - items = rego.ProductItem.objects.filter(cart=cart) + items = commerce.ProductItem.objects.filter(cart=cart) product_quantities = list((i.product, i.quantity) for i in items) try: @@ -248,7 +250,7 @@ class CartController(object): errors.append(error.message[1]) # Validate the discounts - discount_items = rego.DiscountItem.objects.filter(cart=cart) + discount_items = commerce.DiscountItem.objects.filter(cart=cart) seen_discounts = set() for discount_item in discount_items: @@ -256,7 +258,7 @@ class CartController(object): if discount in seen_discounts: continue seen_discounts.add(discount) - real_discount = rego.DiscountBase.objects.get_subclass( + real_discount = conditions.DiscountBase.objects.get_subclass( pk=discount.pk) cond = ConditionController.for_condition(real_discount) @@ -287,7 +289,7 @@ class CartController(object): self.cart.vouchers.remove(voucher) # Fix products and discounts - items = rego.ProductItem.objects.filter(cart=self.cart) + items = commerce.ProductItem.objects.filter(cart=self.cart) items = items.select_related("product") products = set(i.product for i in items) available = set(ProductController.available_products( @@ -306,7 +308,7 @@ class CartController(object): ''' # Delete the existing entries. - rego.DiscountItem.objects.filter(cart=self.cart).delete() + commerce.DiscountItem.objects.filter(cart=self.cart).delete() product_items = self.cart.productitem_set.all().select_related( "product", "product__category", @@ -331,7 +333,7 @@ class CartController(object): def matches(discount): ''' Returns True if and only if the given discount apples to our product. ''' - if isinstance(discount.clause, rego.DiscountForCategory): + if isinstance(discount.clause, conditions.DiscountForCategory): return discount.clause.category == product.category else: return discount.clause.product == product @@ -356,7 +358,7 @@ class CartController(object): # Get a provisional instance for this DiscountItem # with the quantity set to as much as we have in the cart - discount_item = rego.DiscountItem.objects.create( + discount_item = commerce.DiscountItem.objects.create( product=product, cart=self.cart, discount=candidate.discount, diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index a3753600..94040b5f 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -1,4 +1,5 @@ -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from django.db.models import Sum @@ -22,7 +23,9 @@ class CategoryController(object): from product import ProductController if products is AllProducts: - products = rego.Product.objects.all().select_related("category") + products = inventory.Product.objects.all().select_related( + "category", + ) available = ProductController.available_products( user, @@ -41,13 +44,13 @@ class CategoryController(object): # We don't need to waste the following queries return 99999999 - carts = rego.Cart.objects.filter( + carts = commerce.Cart.objects.filter( user=user, active=False, released=False, ) - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( cart__in=carts, product__category=self.category, ) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index fde2f805..e96b6590 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -7,7 +7,9 @@ from collections import namedtuple from django.db.models import Sum from django.utils import timezone -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory ConditionAndRemainder = namedtuple( @@ -29,15 +31,15 @@ class ConditionController(object): @staticmethod def for_condition(condition): CONTROLLERS = { - rego.CategoryFlag: CategoryConditionController, - rego.IncludedProductDiscount: ProductConditionController, - rego.ProductFlag: ProductConditionController, - rego.TimeOrStockLimitDiscount: + conditions.CategoryFlag: CategoryConditionController, + conditions.IncludedProductDiscount: ProductConditionController, + conditions.ProductFlag: ProductConditionController, + conditions.TimeOrStockLimitDiscount: TimeOrStockLimitDiscountController, - rego.TimeOrStockLimitFlag: + conditions.TimeOrStockLimitFlag: TimeOrStockLimitFlagController, - rego.VoucherDiscount: VoucherConditionController, - rego.VoucherFlag: VoucherConditionController, + conditions.VoucherDiscount: VoucherConditionController, + conditions.VoucherFlag: VoucherConditionController, } try: @@ -121,7 +123,7 @@ class ConditionController(object): # Get all products covered by this condition, and the products # from the categories covered by this condition cond_products = condition.products.all() - from_category = rego.Product.objects.filter( + from_category = inventory.Product.objects.filter( category__in=condition.categories.all(), ).all() all_products = cond_products | from_category @@ -199,11 +201,11 @@ class CategoryConditionController(ConditionController): ''' returns True if the user has a product from a category that invokes this condition in one of their carts ''' - carts = rego.Cart.objects.filter(user=user, released=False) - enabling_products = rego.Product.objects.filter( + carts = commerce.Cart.objects.filter(user=user, released=False) + enabling_products = inventory.Product.objects.filter( category=self.condition.enabling_category, ) - products_count = rego.ProductItem.objects.filter( + products_count = commerce.ProductItem.objects.filter( cart__in=carts, product__in=enabling_products, ).count() @@ -221,8 +223,8 @@ class ProductConditionController(ConditionController): ''' returns True if the user has a product that invokes this condition in one of their carts ''' - carts = rego.Cart.objects.filter(user=user, released=False) - products_count = rego.ProductItem.objects.filter( + carts = commerce.Cart.objects.filter(user=user, released=False) + products_count = commerce.ProductItem.objects.filter( cart__in=carts, product__in=self.condition.enabling_products.all(), ).count() @@ -267,7 +269,7 @@ class TimeOrStockLimitConditionController(ConditionController): return 99999999 # We care about all reserved carts, but not the user's current cart - reserved_carts = rego.Cart.reserved_carts() + reserved_carts = commerce.Cart.reserved_carts() reserved_carts = reserved_carts.exclude( user=user, active=True, @@ -284,12 +286,12 @@ class TimeOrStockLimitFlagController( TimeOrStockLimitConditionController): def _items(self): - category_products = rego.Product.objects.filter( + category_products = inventory.Product.objects.filter( category__in=self.ceiling.categories.all(), ) products = self.ceiling.products.all() | category_products - product_items = rego.ProductItem.objects.filter( + product_items = commerce.ProductItem.objects.filter( product__in=products.all(), ) return product_items @@ -298,7 +300,7 @@ class TimeOrStockLimitFlagController( class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController): def _items(self): - discount_items = rego.DiscountItem.objects.filter( + discount_items = commerce.DiscountItem.objects.filter( discount=self.ceiling, ) return discount_items @@ -312,7 +314,7 @@ class VoucherConditionController(ConditionController): def is_met(self, user): ''' returns True if the user has the given voucher attached. ''' - carts_count = rego.Cart.objects.filter( + carts_count = commerce.Cart.objects.filter( user=user, vouchers=self.condition.voucher, ).count() diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index bd15947d..e1f0ed2b 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -1,6 +1,6 @@ from django.db import transaction -from registrasion import models as rego +from registrasion.models import commerce class CreditNoteController(object): @@ -14,7 +14,7 @@ class CreditNoteController(object): the given invoice. You need to call InvoiceController.update_status() to set the status correctly, if appropriate. ''' - credit_note = rego.CreditNote.objects.create( + credit_note = commerce.CreditNote.objects.create( invoice=invoice, amount=0-value, # Credit notes start off as a payment against inv. reference="ONE MOMENT", @@ -39,7 +39,7 @@ class CreditNoteController(object): inv.validate_allowed_to_pay() # Apply payment to invoice - rego.CreditNoteApplication.objects.create( + commerce.CreditNoteApplication.objects.create( parent=self.credit_note, invoice=invoice, amount=self.credit_note.value, diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 624e78a1..8b11c36a 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -1,7 +1,8 @@ import itertools from conditions import ConditionController -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions from django.db.models import Sum @@ -24,15 +25,15 @@ def available_discounts(user, categories, products): not including products that are pending purchase. ''' # discounts that match provided categories - category_discounts = rego.DiscountForCategory.objects.filter( + category_discounts = conditions.DiscountForCategory.objects.filter( category__in=categories ) # discounts that match provided products - product_discounts = rego.DiscountForProduct.objects.filter( + product_discounts = conditions.DiscountForProduct.objects.filter( product__in=products ) # discounts that match categories for provided products - product_category_discounts = rego.DiscountForCategory.objects.filter( + product_category_discounts = conditions.DiscountForCategory.objects.filter( category__in=(product.category for product in products) ) # (Not relevant: discounts that match products in provided categories) @@ -60,7 +61,7 @@ def available_discounts(user, categories, products): failed_discounts = set() for discount in potential_discounts: - real_discount = rego.DiscountBase.objects.get_subclass( + real_discount = conditions.DiscountBase.objects.get_subclass( pk=discount.discount.pk, ) cond = ConditionController.for_condition(real_discount) @@ -68,7 +69,7 @@ def available_discounts(user, categories, products): # Count the past uses of the given discount item. # If this user has exceeded the limit for the clause, this clause # is not available any more. - past_uses = rego.DiscountItem.objects.filter( + past_uses = commerce.DiscountItem.objects.filter( cart__user=user, cart__active=False, # Only past carts count cart__released=False, # You can reuse refunded discounts diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 58b0beb6..fff97e8d 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -5,7 +5,9 @@ from django.db import transaction from django.db.models import Sum from django.utils import timezone -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import people from cart import CartController from credit_note import CreditNoteController @@ -25,8 +27,8 @@ class InvoiceController(object): an invoice is generated.''' try: - invoice = rego.Invoice.objects.exclude( - status=rego.Invoice.STATUS_VOID, + invoice = commerce.Invoice.objects.exclude( + status=commerce.Invoice.STATUS_VOID, ).get( cart=cart, cart_revision=cart.revision, @@ -42,19 +44,19 @@ class InvoiceController(object): @classmethod def void_all_invoices(cls, cart): - invoices = rego.Invoice.objects.filter(cart=cart).all() + invoices = commerce.Invoice.objects.filter(cart=cart).all() for invoice in invoices: cls(invoice).void() @classmethod def resolve_discount_value(cls, item): try: - condition = rego.DiscountForProduct.objects.get( + condition = conditions.DiscountForProduct.objects.get( discount=item.discount, product=item.product ) except ObjectDoesNotExist: - condition = rego.DiscountForCategory.objects.get( + condition = conditions.DiscountForCategory.objects.get( discount=item.discount, category=item.product.category ) @@ -75,22 +77,22 @@ class InvoiceController(object): due = max(issued, reservation_limit) # Get the invoice recipient - profile = rego.AttendeeProfileBase.objects.get_subclass( + profile = people.AttendeeProfileBase.objects.get_subclass( id=cart.user.attendee.attendeeprofilebase.id, ) recipient = profile.invoice_recipient() - invoice = rego.Invoice.objects.create( + invoice = commerce.Invoice.objects.create( user=cart.user, cart=cart, cart_revision=cart.revision, - status=rego.Invoice.STATUS_UNPAID, + status=commerce.Invoice.STATUS_UNPAID, value=Decimal(), issue_time=issued, due_time=due, recipient=recipient, ) - product_items = rego.ProductItem.objects.filter(cart=cart) + product_items = commerce.ProductItem.objects.filter(cart=cart) if len(product_items) == 0: raise ValidationError("Your cart is empty.") @@ -98,11 +100,11 @@ class InvoiceController(object): product_items = product_items.order_by( "product__category__order", "product__order" ) - discount_items = rego.DiscountItem.objects.filter(cart=cart) + discount_items = commerce.DiscountItem.objects.filter(cart=cart) invoice_value = Decimal() for item in product_items: product = item.product - line_item = rego.LineItem.objects.create( + line_item = commerce.LineItem.objects.create( invoice=invoice, description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, @@ -112,7 +114,7 @@ class InvoiceController(object): invoice_value += line_item.quantity * line_item.price for item in discount_items: - line_item = rego.LineItem.objects.create( + line_item = commerce.LineItem.objects.create( invoice=invoice, description=item.discount.description, quantity=item.quantity, @@ -170,7 +172,7 @@ class InvoiceController(object): def total_payments(self): ''' Returns the total amount paid towards this invoice. ''' - payments = rego.PaymentBase.objects.filter(invoice=self.invoice) + payments = commerce.PaymentBase.objects.filter(invoice=self.invoice) total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0 return total_paid @@ -180,12 +182,12 @@ class InvoiceController(object): old_status = self.invoice.status total_paid = self.total_payments() - num_payments = rego.PaymentBase.objects.filter( + num_payments = commerce.PaymentBase.objects.filter( invoice=self.invoice, ).count() remainder = self.invoice.value - total_paid - if old_status == rego.Invoice.STATUS_UNPAID: + if old_status == commerce.Invoice.STATUS_UNPAID: # Invoice had an amount owing if remainder <= 0: # Invoice no longer has amount owing @@ -199,15 +201,15 @@ class InvoiceController(object): elif total_paid == 0 and num_payments > 0: # Invoice has multiple payments totalling zero self._mark_void() - elif old_status == rego.Invoice.STATUS_PAID: + elif old_status == commerce.Invoice.STATUS_PAID: if remainder > 0: # Invoice went from having a remainder of zero or less # to having a positive remainder -- must be a refund self._mark_refunded() - elif old_status == rego.Invoice.STATUS_REFUNDED: + elif old_status == commerce.Invoice.STATUS_REFUNDED: # Should not ever change from here pass - elif old_status == rego.Invoice.STATUS_VOID: + elif old_status == commerce.Invoice.STATUS_VOID: # Should not ever change from here pass @@ -218,7 +220,7 @@ class InvoiceController(object): if cart: cart.active = False cart.save() - self.invoice.status = rego.Invoice.STATUS_PAID + self.invoice.status = commerce.Invoice.STATUS_PAID self.invoice.save() def _mark_refunded(self): @@ -229,13 +231,13 @@ class InvoiceController(object): cart.active = False cart.released = True cart.save() - self.invoice.status = rego.Invoice.STATUS_REFUNDED + self.invoice.status = commerce.Invoice.STATUS_REFUNDED self.invoice.save() def _mark_void(self): ''' Marks the invoice as refunded, and updates the attached cart if necessary. ''' - self.invoice.status = rego.Invoice.STATUS_VOID + self.invoice.status = commerce.Invoice.STATUS_VOID self.invoice.save() def _invoice_matches_cart(self): diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index a28f99cb..c6f8370c 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,7 +1,8 @@ import itertools from django.db.models import Sum -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from category import CategoryController from conditions import ConditionController @@ -22,7 +23,7 @@ class ProductController(object): raise ValueError("You must provide products or a category") if category is not None: - all_products = rego.Product.objects.filter(category=category) + all_products = inventory.Product.objects.filter(category=category) all_products = all_products.select_related("category") else: all_products = [] @@ -65,13 +66,13 @@ class ProductController(object): # Don't need to run the remaining queries return 999999 # We can do better - carts = rego.Cart.objects.filter( + carts = commerce.Cart.objects.filter( user=user, active=False, released=False, ) - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( cart__in=carts, product=self.product, ) diff --git a/registrasion/forms.py b/registrasion/forms.py index 2de043f2..1037d229 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -1,4 +1,5 @@ -import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from django import forms @@ -14,8 +15,8 @@ class ApplyCreditNoteForm(forms.Form): self.fields["invoice"].choices = self._unpaid_invoices_for_user def _unpaid_invoices_for_user(self): - invoices = rego.Invoice.objects.filter( - status=rego.Invoice.STATUS_UNPAID, + invoices = commerce.Invoice.objects.filter( + status=commerce.Invoice.STATUS_UNPAID, user=self.user, ) @@ -25,7 +26,6 @@ class ApplyCreditNoteForm(forms.Form): ] invoice = forms.ChoiceField( - #choices=_unpaid_invoices_for_user, required=True, ) @@ -33,14 +33,14 @@ class ApplyCreditNoteForm(forms.Form): class ManualCreditNoteRefundForm(forms.ModelForm): class Meta: - model = rego.ManualCreditNoteRefund + model = commerce.ManualCreditNoteRefund fields = ["reference"] class ManualPaymentForm(forms.ModelForm): class Meta: - model = rego.ManualPayment + model = commerce.ManualPayment fields = ["reference", "amount"] @@ -168,8 +168,8 @@ def ProductsForm(category, products): # Each Category.RENDER_TYPE value has a subclass here. RENDER_TYPES = { - rego.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, - rego.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, + inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, + inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on diff --git a/registrasion/models.py b/registrasion/models.py deleted file mode 100644 index 58dec909..00000000 --- a/registrasion/models.py +++ /dev/null @@ -1,818 +0,0 @@ -from __future__ import unicode_literals - -import util - -import datetime -import itertools - -from django.core.exceptions import ObjectDoesNotExist -from django.core.exceptions import ValidationError -from django.contrib.auth.models import User -from django.db import models -from django.db.models import F, Q -from django.utils import timezone -from django.utils.encoding import python_2_unicode_compatible -from django.utils.translation import ugettext_lazy as _ -from model_utils.managers import InheritanceManager - - -# User models - -@python_2_unicode_compatible -class Attendee(models.Model): - ''' Miscellaneous user-related data. ''' - - def __str__(self): - return "%s" % self.user - - @staticmethod - def get_instance(user): - ''' Returns the instance of attendee for the given user, or creates - a new one. ''' - try: - return Attendee.objects.get(user=user) - except ObjectDoesNotExist: - return Attendee.objects.create(user=user) - - def save(self, *a, **k): - while not self.access_code: - access_code = util.generate_access_code() - if Attendee.objects.filter(access_code=access_code).count() == 0: - self.access_code = access_code - return super(Attendee, self).save(*a, **k) - - user = models.OneToOneField(User, on_delete=models.CASCADE) - # Badge/profile is linked - access_code = models.CharField( - max_length=6, - unique=True, - db_index=True, - ) - completed_registration = models.BooleanField(default=False) - guided_categories_complete = models.ManyToManyField("category") - - -class AttendeeProfileBase(models.Model): - ''' Information for an attendee's badge and related preferences. - Subclass this in your Django site to ask for attendee information in your - registration progess. - ''' - - objects = InheritanceManager() - - @classmethod - def name_field(cls): - ''' This is used to pre-fill the attendee's name from the - speaker profile. If it's None, that functionality is disabled. ''' - return None - - def invoice_recipient(self): - ''' Returns a representation of this attendee profile for the purpose - of rendering to an invoice. Override in subclasses. ''' - - # Manual dispatch to subclass. Fleh. - slf = AttendeeProfileBase.objects.get_subclass(id=self.id) - # Actually compare the functions. - if type(slf).invoice_recipient != type(self).invoice_recipient: - return type(slf).invoice_recipient(slf) - - # Return a default - return slf.attendee.user.username - - attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) - - -# Inventory Models - -@python_2_unicode_compatible -class Category(models.Model): - ''' Registration product categories ''' - - class Meta: - verbose_name = _("inventory - category") - verbose_name_plural = _("inventory - categories") - ordering = ("order", ) - - def __str__(self): - return self.name - - RENDER_TYPE_RADIO = 1 - RENDER_TYPE_QUANTITY = 2 - - CATEGORY_RENDER_TYPES = [ - (RENDER_TYPE_RADIO, _("Radio button")), - (RENDER_TYPE_QUANTITY, _("Quantity boxes")), - ] - - name = models.CharField( - max_length=65, - verbose_name=_("Name"), - ) - description = models.CharField( - max_length=255, - verbose_name=_("Description"), - ) - limit_per_user = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit per user"), - help_text=_("The total number of items from this category one " - "attendee may purchase."), - ) - required = models.BooleanField( - blank=True, - help_text=_("If enabled, a user must select an " - "item from this category."), - ) - order = models.PositiveIntegerField( - verbose_name=("Display order"), - db_index=True, - ) - render_type = models.IntegerField( - choices=CATEGORY_RENDER_TYPES, - verbose_name=_("Render type"), - help_text=_("The registration form will render this category in this " - "style."), - ) - - -@python_2_unicode_compatible -class Product(models.Model): - ''' Registration products ''' - - class Meta: - verbose_name = _("inventory - product") - ordering = ("category__order", "order") - - def __str__(self): - return "%s - %s" % (self.category.name, self.name) - - name = models.CharField( - max_length=65, - verbose_name=_("Name"), - ) - description = models.CharField( - max_length=255, - verbose_name=_("Description"), - null=True, - blank=True, - ) - category = models.ForeignKey( - Category, - verbose_name=_("Product category") - ) - price = models.DecimalField( - max_digits=8, - decimal_places=2, - verbose_name=_("Price"), - ) - limit_per_user = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit per user"), - ) - reservation_duration = models.DurationField( - default=datetime.timedelta(hours=1), - verbose_name=_("Reservation duration"), - help_text=_("The length of time this product will be reserved before " - "it is released for someone else to purchase."), - ) - order = models.PositiveIntegerField( - verbose_name=("Display order"), - db_index=True, - ) - - -@python_2_unicode_compatible -class Voucher(models.Model): - ''' Registration vouchers ''' - - # Vouchers reserve a cart for a fixed amount of time, so that - # items may be added without the voucher being swiped by someone else - RESERVATION_DURATION = datetime.timedelta(hours=1) - - def __str__(self): - return "Voucher for %s" % self.recipient - - @classmethod - def normalise_code(cls, code): - return code.upper() - - def save(self, *a, **k): - ''' Normalise the voucher code to be uppercase ''' - self.code = self.normalise_code(self.code) - super(Voucher, self).save(*a, **k) - - recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) - code = models.CharField(max_length=16, - unique=True, - verbose_name=_("Voucher code")) - limit = models.PositiveIntegerField(verbose_name=_("Voucher use limit")) - - -# Product Modifiers - -@python_2_unicode_compatible -class DiscountBase(models.Model): - ''' Base class for discounts. Each subclass has controller code that - determines whether or not the given discount is available to be added to - the current cart. ''' - - objects = InheritanceManager() - - def __str__(self): - return "Discount: " + self.description - - def effects(self): - ''' Returns all of the effects of this discount. ''' - products = self.discountforproduct_set.all() - categories = self.discountforcategory_set.all() - return itertools.chain(products, categories) - - description = models.CharField( - max_length=255, - verbose_name=_("Description"), - help_text=_("A description of this discount. This will be included on " - "invoices where this discount is applied."), - ) - - -@python_2_unicode_compatible -class DiscountForProduct(models.Model): - ''' Represents a discount on an individual product. Each Discount can - contain multiple products and categories. Discounts can either be a - percentage or a fixed amount, but not both. ''' - - def __str__(self): - if self.percentage: - return "%s%% off %s" % (self.percentage, self.product) - elif self.price: - return "$%s off %s" % (self.price, self.product) - - def clean(self): - if self.percentage is None and self.price is None: - raise ValidationError( - _("Discount must have a percentage or a price.")) - elif self.percentage is not None and self.price is not None: - raise ValidationError( - _("Discount may only have a percentage or only a price.")) - - prods = DiscountForProduct.objects.filter( - discount=self.discount, - product=self.product) - cats = DiscountForCategory.objects.filter( - discount=self.discount, - category=self.product.category) - if len(prods) > 1: - raise ValidationError( - _("You may only have one discount line per product")) - if len(cats) != 0: - raise ValidationError( - _("You may only have one discount for " - "a product or its category")) - - discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) - product = models.ForeignKey(Product, on_delete=models.CASCADE) - percentage = models.DecimalField( - max_digits=4, decimal_places=1, null=True, blank=True) - price = models.DecimalField( - max_digits=8, decimal_places=2, null=True, blank=True) - quantity = models.PositiveIntegerField() - - -@python_2_unicode_compatible -class DiscountForCategory(models.Model): - ''' Represents a discount for a category of products. Each discount can - contain multiple products. Category discounts can only be a percentage. ''' - - def __str__(self): - return "%s%% off %s" % (self.percentage, self.category) - - def clean(self): - prods = DiscountForProduct.objects.filter( - discount=self.discount, - product__category=self.category) - cats = DiscountForCategory.objects.filter( - discount=self.discount, - category=self.category) - if len(prods) != 0: - raise ValidationError( - _("You may only have one discount for " - "a product or its category")) - if len(cats) > 1: - raise ValidationError( - _("You may only have one discount line per category")) - - discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) - category = models.ForeignKey(Category, on_delete=models.CASCADE) - percentage = models.DecimalField( - max_digits=4, - decimal_places=1) - quantity = models.PositiveIntegerField() - - -class TimeOrStockLimitDiscount(DiscountBase): - ''' Discounts that are generally available, but are limited by timespan or - usage count. This is for e.g. Early Bird discounts. ''' - - class Meta: - verbose_name = _("discount (time/stock limit)") - verbose_name_plural = _("discounts (time/stock limit)") - - start_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("Start time"), - help_text=_("This discount will only be available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("End time"), - help_text=_("This discount will only be available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit"), - help_text=_("This discount may only be applied this many times."), - ) - - -class VoucherDiscount(DiscountBase): - ''' Discounts that are enabled when a voucher code is in the current - cart. ''' - - class Meta: - verbose_name = _("discount (enabled by voucher)") - verbose_name_plural = _("discounts (enabled by voucher)") - - voucher = models.OneToOneField( - Voucher, - on_delete=models.CASCADE, - verbose_name=_("Voucher"), - db_index=True, - ) - - -class IncludedProductDiscount(DiscountBase): - ''' Discounts that are enabled because another product has been purchased. - e.g. A conference ticket includes a free t-shirt. ''' - - class Meta: - verbose_name = _("discount (product inclusions)") - verbose_name_plural = _("discounts (product inclusions)") - - enabling_products = models.ManyToManyField( - Product, - verbose_name=_("Including product"), - help_text=_("If one of these products are purchased, the discounts " - "below will be enabled."), - ) - - -class RoleDiscount(object): - ''' Discounts that are enabled because the active user has a specific - role. This is for e.g. volunteers who can get a discount ticket. ''' - # TODO: implement RoleDiscount - pass - - -@python_2_unicode_compatible -class FlagBase(models.Model): - ''' This defines a condition which allows products or categories to - be made visible, or be prevented from being visible. - - The various subclasses of this can define the conditions that enable - or disable products, by the following rules: - - If there is at least one 'disable if false' flag defined on a product or - category, all such flag conditions must be met. If there is at least one - 'enable if true' flag, at least one such condition must be met. - - If both types of conditions exist on a product, both of these rules apply. - ''' - - class Meta: - # TODO: make concrete once https://code.djangoproject.com/ticket/26488 - # is solved. - abstract = True - - DISABLE_IF_FALSE = 1 - ENABLE_IF_TRUE = 2 - - def __str__(self): - return self.description - - def effects(self): - ''' Returns all of the items affected by this condition. ''' - return itertools.chain(self.products.all(), self.categories.all()) - - @property - def is_disable_if_false(self): - return self.condition == FlagBase.DISABLE_IF_FALSE - - @property - def is_enable_if_true(self): - return self.condition == FlagBase.ENABLE_IF_TRUE - - description = models.CharField(max_length=255) - condition = models.IntegerField( - default=ENABLE_IF_TRUE, - choices=( - (DISABLE_IF_FALSE, _("Disable if false")), - (ENABLE_IF_TRUE, _("Enable if true")), - ), - help_text=_("If there is at least one 'disable if false' flag " - "defined on a product or category, all such flag " - " conditions must be met. If there is at least one " - "'enable if true' flag, at least one such condition must " - "be met. If both types of conditions exist on a product, " - "both of these rules apply." - ), - ) - products = models.ManyToManyField( - Product, - blank=True, - help_text=_("Products affected by this flag's condition."), - related_name="flagbase_set", - ) - categories = models.ManyToManyField( - Category, - blank=True, - help_text=_("Categories whose products are affected by this flag's " - "condition." - ), - related_name="flagbase_set", - ) - - -class EnablingConditionBase(FlagBase): - ''' Reifies the abstract FlagBase. This is necessary because django - prevents renaming base classes in migrations. ''' - # TODO: remove this, and make subclasses subclass FlagBase once - # https://code.djangoproject.com/ticket/26488 is solved. - - objects = InheritanceManager() - - -class TimeOrStockLimitFlag(EnablingConditionBase): - ''' Registration product ceilings ''' - - class Meta: - verbose_name = _("flag (time/stock limit)") - verbose_name_plural = _("flags (time/stock limit)") - - start_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - help_text=_("The number of items under this grouping that can be " - "purchased."), - ) - - -@python_2_unicode_compatible -class ProductFlag(EnablingConditionBase): - ''' The condition is met because a specific product is purchased. ''' - - class Meta: - verbose_name = _("flag (dependency on product)") - verbose_name_plural = _("flags (dependency on product)") - - def __str__(self): - return "Enabled by products: " + str(self.enabling_products.all()) - - enabling_products = models.ManyToManyField( - Product, - help_text=_("If one of these products are purchased, this condition " - "is met."), - ) - - -@python_2_unicode_compatible -class CategoryFlag(EnablingConditionBase): - ''' The condition is met because a product in a particular product is - purchased. ''' - - class Meta: - verbose_name = _("flag (dependency on product from category)") - verbose_name_plural = _("flags (dependency on product from category)") - - def __str__(self): - return "Enabled by product in category: " + str(self.enabling_category) - - enabling_category = models.ForeignKey( - Category, - help_text=_("If a product from this category is purchased, this " - "condition is met."), - ) - - -@python_2_unicode_compatible -class VoucherFlag(EnablingConditionBase): - ''' The condition is met because a Voucher is present. This is for e.g. - enabling sponsor tickets. ''' - - class Meta: - verbose_name = _("flag (dependency on voucher)") - verbose_name_plural = _("flags (dependency on voucher)") - - def __str__(self): - return "Enabled by voucher: %s" % self.voucher - - voucher = models.OneToOneField(Voucher) - - -# @python_2_unicode_compatible -class RoleFlag(object): - ''' The condition is met because the active user has a particular Role. - This is for e.g. enabling Team tickets. ''' - # TODO: implement RoleFlag - pass - - -# Commerce Models - -@python_2_unicode_compatible -class Cart(models.Model): - ''' Represents a set of product items that have been purchased, or are - pending purchase. ''' - - class Meta: - index_together = [ - ("active", "time_last_updated"), - ("active", "released"), - ("active", "user"), - ("released", "user"), - ] - - def __str__(self): - return "%d rev #%d" % (self.id, self.revision) - - user = models.ForeignKey(User) - # ProductItems (foreign key) - vouchers = models.ManyToManyField(Voucher, blank=True) - time_last_updated = models.DateTimeField( - db_index=True, - ) - reservation_duration = models.DurationField() - revision = models.PositiveIntegerField(default=1) - active = models.BooleanField( - default=True, - db_index=True, - ) - released = models.BooleanField( - default=False, - db_index=True - ) # Refunds etc - - @classmethod - def reserved_carts(cls): - ''' Gets all carts that are 'reserved' ''' - return Cart.objects.filter( - (Q(active=True) & - Q(time_last_updated__gt=( - timezone.now()-F('reservation_duration') - ))) | - (Q(active=False) & Q(released=False)) - ) - - -@python_2_unicode_compatible -class ProductItem(models.Model): - ''' Represents a product-quantity pair in a Cart. ''' - - class Meta: - ordering = ("product", ) - - def __str__(self): - return "product: %s * %d in Cart: %s" % ( - self.product, self.quantity, self.cart) - - cart = models.ForeignKey(Cart) - product = models.ForeignKey(Product) - quantity = models.PositiveIntegerField(db_index=True) - - -@python_2_unicode_compatible -class DiscountItem(models.Model): - ''' Represents a discount-product-quantity relation in a Cart. ''' - - class Meta: - ordering = ("product", ) - - def __str__(self): - return "%s: %s * %d in Cart: %s" % ( - self.discount, self.product, self.quantity, self.cart) - - cart = models.ForeignKey(Cart) - product = models.ForeignKey(Product) - discount = models.ForeignKey(DiscountBase) - quantity = models.PositiveIntegerField() - - -@python_2_unicode_compatible -class Invoice(models.Model): - ''' An invoice. Invoices can be automatically generated when checking out - a Cart, in which case, it is attached to a given revision of a Cart. ''' - - STATUS_UNPAID = 1 - STATUS_PAID = 2 - STATUS_REFUNDED = 3 - STATUS_VOID = 4 - - STATUS_TYPES = [ - (STATUS_UNPAID, _("Unpaid")), - (STATUS_PAID, _("Paid")), - (STATUS_REFUNDED, _("Refunded")), - (STATUS_VOID, _("VOID")), - ] - - def __str__(self): - return "Invoice #%d" % self.id - - def clean(self): - if self.cart is not None and self.cart_revision is None: - raise ValidationError( - "If this is a cart invoice, it must have a revision") - - @property - def is_unpaid(self): - return self.status == self.STATUS_UNPAID - - @property - def is_void(self): - return self.status == self.STATUS_VOID - - @property - def is_paid(self): - return self.status == self.STATUS_PAID - - @property - def is_refunded(self): - return self.status == self.STATUS_REFUNDED - - # Invoice Number - user = models.ForeignKey(User) - cart = models.ForeignKey(Cart, null=True) - cart_revision = models.IntegerField( - null=True, - db_index=True, - ) - # Line Items (foreign key) - status = models.IntegerField( - choices=STATUS_TYPES, - db_index=True, - ) - recipient = models.CharField(max_length=1024) - issue_time = models.DateTimeField() - due_time = models.DateTimeField() - value = models.DecimalField(max_digits=8, decimal_places=2) - - -@python_2_unicode_compatible -class LineItem(models.Model): - ''' Line items for an invoice. These are denormalised from the ProductItems - and DiscountItems that belong to a cart (for consistency), but also allow - for arbitrary line items when required. ''' - - class Meta: - ordering = ("id", ) - - def __str__(self): - return "Line: %s * %d @ %s" % ( - self.description, self.quantity, self.price) - - invoice = models.ForeignKey(Invoice) - description = models.CharField(max_length=255) - quantity = models.PositiveIntegerField() - price = models.DecimalField(max_digits=8, decimal_places=2) - product = models.ForeignKey(Product, null=True, blank=True) - - -@python_2_unicode_compatible -class PaymentBase(models.Model): - ''' The base payment type for invoices. Payment apps should subclass this - class to handle implementation-specific issues. ''' - - class Meta: - ordering = ("time", ) - - objects = InheritanceManager() - - def __str__(self): - return "Payment: ref=%s amount=%s" % (self.reference, self.amount) - - invoice = models.ForeignKey(Invoice) - time = models.DateTimeField(default=timezone.now) - reference = models.CharField(max_length=255) - amount = models.DecimalField(max_digits=8, decimal_places=2) - - -class ManualPayment(PaymentBase): - ''' Payments that are manually entered by staff. ''' - pass - - -class CreditNote(PaymentBase): - ''' Credit notes represent money accounted for in the system that do not - belong to specific invoices. They may be paid into other invoices, or - cashed out as refunds. - - Each CreditNote may either be used to pay towards another Invoice in the - system (by attaching a CreditNoteApplication), or may be marked as - refunded (by attaching a CreditNoteRefund).''' - - @classmethod - def unclaimed(cls): - return cls.objects.filter( - creditnoteapplication=None, - creditnoterefund=None, - ) - - @property - def status(self): - if self.is_unclaimed: - return "Unclaimed" - - if hasattr(self, 'creditnoteapplication'): - destination = self.creditnoteapplication.invoice.id - return "Applied to invoice %d" % destination - - elif hasattr(self, 'creditnoterefund'): - reference = self.creditnoterefund.reference - print reference - return "Refunded with reference: %s" % reference - - raise ValueError("This should never happen.") - - @property - def is_unclaimed(self): - return not ( - hasattr(self, 'creditnoterefund') or - hasattr(self, 'creditnoteapplication') - ) - - @property - def value(self): - ''' Returns the value of the credit note. Because CreditNotes are - implemented as PaymentBase objects internally, the amount is a - negative payment against an invoice. ''' - return -self.amount - - -class CleanOnSave(object): - - def save(self, *a, **k): - self.full_clean() - super(CleanOnSave, self).save(*a, **k) - - -class CreditNoteApplication(CleanOnSave, PaymentBase): - ''' Represents an application of a credit note to an Invoice. ''' - - def clean(self): - if not hasattr(self, "parent"): - return - if hasattr(self.parent, 'creditnoterefund'): - raise ValidationError( - "Cannot apply a refunded credit note to an invoice" - ) - - parent = models.OneToOneField(CreditNote) - - -class CreditNoteRefund(CleanOnSave, models.Model): - ''' Represents a refund of a credit note to an external payment. - Credit notes may only be refunded in full. How those refunds are handled - is left as an exercise to the payment app. ''' - - def clean(self): - if not hasattr(self, "parent"): - return - if hasattr(self.parent, 'creditnoteapplication'): - raise ValidationError( - "Cannot refund a credit note that has been paid to an invoice" - ) - - parent = models.OneToOneField(CreditNote) - time = models.DateTimeField(default=timezone.now) - reference = models.CharField(max_length=255) - - -class ManualCreditNoteRefund(CreditNoteRefund): - ''' Credit notes that are entered by a staff member. ''' - - entered_by = models.ForeignKey(User) diff --git a/registrasion/models/__init__.py b/registrasion/models/__init__.py new file mode 100644 index 00000000..944229f4 --- /dev/null +++ b/registrasion/models/__init__.py @@ -0,0 +1,4 @@ +from commerce import * # NOQA +from conditions import * # NOQA +from inventory import * # NOQA +from people import * # NOQA diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py new file mode 100644 index 00000000..5df01f49 --- /dev/null +++ b/registrasion/models/commerce.py @@ -0,0 +1,304 @@ +from . import conditions +from . import inventory + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import F, Q +from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ +from model_utils.managers import InheritanceManager + + +# Commerce Models + +@python_2_unicode_compatible +class Cart(models.Model): + ''' Represents a set of product items that have been purchased, or are + pending purchase. ''' + + class Meta: + app_label = "registrasion" + index_together = [ + ("active", "time_last_updated"), + ("active", "released"), + ("active", "user"), + ("released", "user"), + ] + + def __str__(self): + return "%d rev #%d" % (self.id, self.revision) + + user = models.ForeignKey(User) + # ProductItems (foreign key) + vouchers = models.ManyToManyField(inventory.Voucher, blank=True) + time_last_updated = models.DateTimeField( + db_index=True, + ) + reservation_duration = models.DurationField() + revision = models.PositiveIntegerField(default=1) + active = models.BooleanField( + default=True, + db_index=True, + ) + released = models.BooleanField( + default=False, + db_index=True + ) # Refunds etc + + @classmethod + def reserved_carts(cls): + ''' Gets all carts that are 'reserved' ''' + return Cart.objects.filter( + (Q(active=True) & + Q(time_last_updated__gt=( + timezone.now()-F('reservation_duration') + ))) | + (Q(active=False) & Q(released=False)) + ) + + +@python_2_unicode_compatible +class ProductItem(models.Model): + ''' Represents a product-quantity pair in a Cart. ''' + + class Meta: + app_label = "registrasion" + ordering = ("product", ) + + def __str__(self): + return "product: %s * %d in Cart: %s" % ( + self.product, self.quantity, self.cart) + + cart = models.ForeignKey(Cart) + product = models.ForeignKey(inventory.Product) + quantity = models.PositiveIntegerField(db_index=True) + + +@python_2_unicode_compatible +class DiscountItem(models.Model): + ''' Represents a discount-product-quantity relation in a Cart. ''' + + class Meta: + app_label = "registrasion" + ordering = ("product", ) + + def __str__(self): + return "%s: %s * %d in Cart: %s" % ( + self.discount, self.product, self.quantity, self.cart) + + cart = models.ForeignKey(Cart) + product = models.ForeignKey(inventory.Product) + discount = models.ForeignKey(conditions.DiscountBase) + quantity = models.PositiveIntegerField() + + +@python_2_unicode_compatible +class Invoice(models.Model): + ''' An invoice. Invoices can be automatically generated when checking out + a Cart, in which case, it is attached to a given revision of a Cart. ''' + + class Meta: + app_label = "registrasion" + + STATUS_UNPAID = 1 + STATUS_PAID = 2 + STATUS_REFUNDED = 3 + STATUS_VOID = 4 + + STATUS_TYPES = [ + (STATUS_UNPAID, _("Unpaid")), + (STATUS_PAID, _("Paid")), + (STATUS_REFUNDED, _("Refunded")), + (STATUS_VOID, _("VOID")), + ] + + def __str__(self): + return "Invoice #%d" % self.id + + def clean(self): + if self.cart is not None and self.cart_revision is None: + raise ValidationError( + "If this is a cart invoice, it must have a revision") + + @property + def is_unpaid(self): + return self.status == self.STATUS_UNPAID + + @property + def is_void(self): + return self.status == self.STATUS_VOID + + @property + def is_paid(self): + return self.status == self.STATUS_PAID + + @property + def is_refunded(self): + return self.status == self.STATUS_REFUNDED + + # Invoice Number + user = models.ForeignKey(User) + cart = models.ForeignKey(Cart, null=True) + cart_revision = models.IntegerField( + null=True, + db_index=True, + ) + # Line Items (foreign key) + status = models.IntegerField( + choices=STATUS_TYPES, + db_index=True, + ) + recipient = models.CharField(max_length=1024) + issue_time = models.DateTimeField() + due_time = models.DateTimeField() + value = models.DecimalField(max_digits=8, decimal_places=2) + + +@python_2_unicode_compatible +class LineItem(models.Model): + ''' Line items for an invoice. These are denormalised from the ProductItems + and DiscountItems that belong to a cart (for consistency), but also allow + for arbitrary line items when required. ''' + + class Meta: + app_label = "registrasion" + ordering = ("id", ) + + def __str__(self): + return "Line: %s * %d @ %s" % ( + self.description, self.quantity, self.price) + + invoice = models.ForeignKey(Invoice) + description = models.CharField(max_length=255) + quantity = models.PositiveIntegerField() + price = models.DecimalField(max_digits=8, decimal_places=2) + product = models.ForeignKey(inventory.Product, null=True, blank=True) + + +@python_2_unicode_compatible +class PaymentBase(models.Model): + ''' The base payment type for invoices. Payment apps should subclass this + class to handle implementation-specific issues. ''' + + class Meta: + ordering = ("time", ) + + objects = InheritanceManager() + + def __str__(self): + return "Payment: ref=%s amount=%s" % (self.reference, self.amount) + + invoice = models.ForeignKey(Invoice) + time = models.DateTimeField(default=timezone.now) + reference = models.CharField(max_length=255) + amount = models.DecimalField(max_digits=8, decimal_places=2) + + +class ManualPayment(PaymentBase): + ''' Payments that are manually entered by staff. ''' + + class Meta: + app_label = "registrasion" + + +class CreditNote(PaymentBase): + ''' Credit notes represent money accounted for in the system that do not + belong to specific invoices. They may be paid into other invoices, or + cashed out as refunds. + + Each CreditNote may either be used to pay towards another Invoice in the + system (by attaching a CreditNoteApplication), or may be marked as + refunded (by attaching a CreditNoteRefund).''' + + class Meta: + app_label = "registrasion" + + @classmethod + def unclaimed(cls): + return cls.objects.filter( + creditnoteapplication=None, + creditnoterefund=None, + ) + + @property + def status(self): + if self.is_unclaimed: + return "Unclaimed" + + if hasattr(self, 'creditnoteapplication'): + destination = self.creditnoteapplication.invoice.id + return "Applied to invoice %d" % destination + + elif hasattr(self, 'creditnoterefund'): + reference = self.creditnoterefund.reference + print reference + return "Refunded with reference: %s" % reference + + raise ValueError("This should never happen.") + + @property + def is_unclaimed(self): + return not ( + hasattr(self, 'creditnoterefund') or + hasattr(self, 'creditnoteapplication') + ) + + @property + def value(self): + ''' Returns the value of the credit note. Because CreditNotes are + implemented as PaymentBase objects internally, the amount is a + negative payment against an invoice. ''' + return -self.amount + + +class CleanOnSave(object): + + def save(self, *a, **k): + self.full_clean() + super(CleanOnSave, self).save(*a, **k) + + +class CreditNoteApplication(CleanOnSave, PaymentBase): + ''' Represents an application of a credit note to an Invoice. ''' + + class Meta: + app_label = "registrasion" + + def clean(self): + if not hasattr(self, "parent"): + return + if hasattr(self.parent, 'creditnoterefund'): + raise ValidationError( + "Cannot apply a refunded credit note to an invoice" + ) + + parent = models.OneToOneField(CreditNote) + + +class CreditNoteRefund(CleanOnSave, models.Model): + ''' Represents a refund of a credit note to an external payment. + Credit notes may only be refunded in full. How those refunds are handled + is left as an exercise to the payment app. ''' + + def clean(self): + if not hasattr(self, "parent"): + return + if hasattr(self.parent, 'creditnoteapplication'): + raise ValidationError( + "Cannot refund a credit note that has been paid to an invoice" + ) + + parent = models.OneToOneField(CreditNote) + time = models.DateTimeField(default=timezone.now) + reference = models.CharField(max_length=255) + + +class ManualCreditNoteRefund(CreditNoteRefund): + ''' Credit notes that are entered by a staff member. ''' + + class Meta: + app_label = "registrasion" + + entered_by = models.ForeignKey(User) diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py new file mode 100644 index 00000000..804f3162 --- /dev/null +++ b/registrasion/models/conditions.py @@ -0,0 +1,361 @@ +import itertools + +from . import inventory + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ +from model_utils.managers import InheritanceManager + + +# Product Modifiers + +@python_2_unicode_compatible +class DiscountBase(models.Model): + ''' Base class for discounts. Each subclass has controller code that + determines whether or not the given discount is available to be added to + the current cart. ''' + + class Meta: + app_label = "registrasion" + + objects = InheritanceManager() + + def __str__(self): + return "Discount: " + self.description + + def effects(self): + ''' Returns all of the effects of this discount. ''' + products = self.discountforproduct_set.all() + categories = self.discountforcategory_set.all() + return itertools.chain(products, categories) + + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + help_text=_("A description of this discount. This will be included on " + "invoices where this discount is applied."), + ) + + +@python_2_unicode_compatible +class DiscountForProduct(models.Model): + ''' Represents a discount on an individual product. Each Discount can + contain multiple products and categories. Discounts can either be a + percentage or a fixed amount, but not both. ''' + + class Meta: + app_label = "registrasion" + + def __str__(self): + if self.percentage: + return "%s%% off %s" % (self.percentage, self.product) + elif self.price: + return "$%s off %s" % (self.price, self.product) + + def clean(self): + if self.percentage is None and self.price is None: + raise ValidationError( + _("Discount must have a percentage or a price.")) + elif self.percentage is not None and self.price is not None: + raise ValidationError( + _("Discount may only have a percentage or only a price.")) + + prods = DiscountForProduct.objects.filter( + discount=self.discount, + product=self.product) + cats = DiscountForCategory.objects.filter( + discount=self.discount, + category=self.product.category) + if len(prods) > 1: + raise ValidationError( + _("You may only have one discount line per product")) + if len(cats) != 0: + raise ValidationError( + _("You may only have one discount for " + "a product or its category")) + + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) + product = models.ForeignKey(inventory.Product, on_delete=models.CASCADE) + percentage = models.DecimalField( + max_digits=4, decimal_places=1, null=True, blank=True) + price = models.DecimalField( + max_digits=8, decimal_places=2, null=True, blank=True) + quantity = models.PositiveIntegerField() + + +@python_2_unicode_compatible +class DiscountForCategory(models.Model): + ''' Represents a discount for a category of products. Each discount can + contain multiple products. Category discounts can only be a percentage. ''' + + class Meta: + app_label = "registrasion" + + def __str__(self): + return "%s%% off %s" % (self.percentage, self.category) + + def clean(self): + prods = DiscountForProduct.objects.filter( + discount=self.discount, + product__category=self.category) + cats = DiscountForCategory.objects.filter( + discount=self.discount, + category=self.category) + if len(prods) != 0: + raise ValidationError( + _("You may only have one discount for " + "a product or its category")) + if len(cats) > 1: + raise ValidationError( + _("You may only have one discount line per category")) + + discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) + category = models.ForeignKey(inventory.Category, on_delete=models.CASCADE) + percentage = models.DecimalField( + max_digits=4, + decimal_places=1) + quantity = models.PositiveIntegerField() + + +class TimeOrStockLimitDiscount(DiscountBase): + ''' Discounts that are generally available, but are limited by timespan or + usage count. This is for e.g. Early Bird discounts. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("discount (time/stock limit)") + verbose_name_plural = _("discounts (time/stock limit)") + + start_time = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Start time"), + help_text=_("This discount will only be available after this time."), + ) + end_time = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("End time"), + help_text=_("This discount will only be available before this time."), + ) + limit = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit"), + help_text=_("This discount may only be applied this many times."), + ) + + +class VoucherDiscount(DiscountBase): + ''' Discounts that are enabled when a voucher code is in the current + cart. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("discount (enabled by voucher)") + verbose_name_plural = _("discounts (enabled by voucher)") + + voucher = models.OneToOneField( + inventory.Voucher, + on_delete=models.CASCADE, + verbose_name=_("Voucher"), + db_index=True, + ) + + +class IncludedProductDiscount(DiscountBase): + ''' Discounts that are enabled because another product has been purchased. + e.g. A conference ticket includes a free t-shirt. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("discount (product inclusions)") + verbose_name_plural = _("discounts (product inclusions)") + + enabling_products = models.ManyToManyField( + inventory.Product, + verbose_name=_("Including product"), + help_text=_("If one of these products are purchased, the discounts " + "below will be enabled."), + ) + + +class RoleDiscount(object): + ''' Discounts that are enabled because the active user has a specific + role. This is for e.g. volunteers who can get a discount ticket. ''' + # TODO: implement RoleDiscount + pass + + +@python_2_unicode_compatible +class FlagBase(models.Model): + ''' This defines a condition which allows products or categories to + be made visible, or be prevented from being visible. + + The various subclasses of this can define the conditions that enable + or disable products, by the following rules: + + If there is at least one 'disable if false' flag defined on a product or + category, all such flag conditions must be met. If there is at least one + 'enable if true' flag, at least one such condition must be met. + + If both types of conditions exist on a product, both of these rules apply. + ''' + + class Meta: + # TODO: make concrete once https://code.djangoproject.com/ticket/26488 + # is solved. + abstract = True + + DISABLE_IF_FALSE = 1 + ENABLE_IF_TRUE = 2 + + def __str__(self): + return self.description + + def effects(self): + ''' Returns all of the items affected by this condition. ''' + return itertools.chain(self.products.all(), self.categories.all()) + + @property + def is_disable_if_false(self): + return self.condition == FlagBase.DISABLE_IF_FALSE + + @property + def is_enable_if_true(self): + return self.condition == FlagBase.ENABLE_IF_TRUE + + description = models.CharField(max_length=255) + condition = models.IntegerField( + default=ENABLE_IF_TRUE, + choices=( + (DISABLE_IF_FALSE, _("Disable if false")), + (ENABLE_IF_TRUE, _("Enable if true")), + ), + help_text=_("If there is at least one 'disable if false' flag " + "defined on a product or category, all such flag " + " conditions must be met. If there is at least one " + "'enable if true' flag, at least one such condition must " + "be met. If both types of conditions exist on a product, " + "both of these rules apply." + ), + ) + products = models.ManyToManyField( + inventory.Product, + blank=True, + help_text=_("Products affected by this flag's condition."), + related_name="flagbase_set", + ) + categories = models.ManyToManyField( + inventory.Category, + blank=True, + help_text=_("Categories whose products are affected by this flag's " + "condition." + ), + related_name="flagbase_set", + ) + + +class EnablingConditionBase(FlagBase): + ''' Reifies the abstract FlagBase. This is necessary because django + prevents renaming base classes in migrations. ''' + # TODO: remove this, and make subclasses subclass FlagBase once + # https://code.djangoproject.com/ticket/26488 is solved. + + class Meta: + app_label = "registrasion" + + objects = InheritanceManager() + + +class TimeOrStockLimitFlag(EnablingConditionBase): + ''' Registration product ceilings ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (time/stock limit)") + verbose_name_plural = _("flags (time/stock limit)") + + start_time = models.DateTimeField( + null=True, + blank=True, + help_text=_("Products included in this condition will only be " + "available after this time."), + ) + end_time = models.DateTimeField( + null=True, + blank=True, + help_text=_("Products included in this condition will only be " + "available before this time."), + ) + limit = models.PositiveIntegerField( + null=True, + blank=True, + help_text=_("The number of items under this grouping that can be " + "purchased."), + ) + + +@python_2_unicode_compatible +class ProductFlag(EnablingConditionBase): + ''' The condition is met because a specific product is purchased. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (dependency on product)") + verbose_name_plural = _("flags (dependency on product)") + + def __str__(self): + return "Enabled by products: " + str(self.enabling_products.all()) + + enabling_products = models.ManyToManyField( + inventory.Product, + help_text=_("If one of these products are purchased, this condition " + "is met."), + ) + + +@python_2_unicode_compatible +class CategoryFlag(EnablingConditionBase): + ''' The condition is met because a product in a particular product is + purchased. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (dependency on product from category)") + verbose_name_plural = _("flags (dependency on product from category)") + + def __str__(self): + return "Enabled by product in category: " + str(self.enabling_category) + + enabling_category = models.ForeignKey( + inventory.Category, + help_text=_("If a product from this category is purchased, this " + "condition is met."), + ) + + +@python_2_unicode_compatible +class VoucherFlag(EnablingConditionBase): + ''' The condition is met because a Voucher is present. This is for e.g. + enabling sponsor tickets. ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("flag (dependency on voucher)") + verbose_name_plural = _("flags (dependency on voucher)") + + def __str__(self): + return "Enabled by voucher: %s" % self.voucher + + voucher = models.OneToOneField(inventory.Voucher) + + +# @python_2_unicode_compatible +class RoleFlag(object): + ''' The condition is met because the active user has a particular Role. + This is for e.g. enabling Team tickets. ''' + # TODO: implement RoleFlag + pass diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py new file mode 100644 index 00000000..e4e27feb --- /dev/null +++ b/registrasion/models/inventory.py @@ -0,0 +1,172 @@ +import datetime + +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + + +# Inventory Models + +@python_2_unicode_compatible +class Category(models.Model): + ''' Registration product categories, used as logical groupings for Products + in registration forms. + + Attributes: + name (str): The display name for the category. + + description (str): Some explanatory text for the category. This is + displayed alongside the forms where your attendees choose their + items. + + required (bool): Requires a user to select an item from this category + during initial registration. You can use this, e.g., for making + sure that the user has a ticket before they select whether they + want a t-shirt. + + render_type (int): This is used to determine what sort of form the + attendee will be presented with when choosing Products from this + category. These may be either of the following: + + ``RENDER_TYPE_RADIO`` presents the Products in the Category as a + list of radio buttons. At most one item can be chosen at a time. + This works well when setting limit_per_user to 1. + + ``RENDER_TYPE_QUANTITY`` shows each Product next to an input field, + where the user can specify a quantity of each Product type. This is + useful for additional extras, like Dinner Tickets. + + limit_per_user (Optional[int]): This restricts the number of items + from this Category that each attendee may claim. This extends + across multiple Invoices. + + display_order (int): An ascending order for displaying the Categories + available. By convention, your Category for ticket types should + have the lowest display order. + ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("inventory - category") + verbose_name_plural = _("inventory - categories") + ordering = ("order", ) + + def __str__(self): + return self.name + + RENDER_TYPE_RADIO = 1 + RENDER_TYPE_QUANTITY = 2 + + CATEGORY_RENDER_TYPES = [ + (RENDER_TYPE_RADIO, _("Radio button")), + (RENDER_TYPE_QUANTITY, _("Quantity boxes")), + ] + + name = models.CharField( + max_length=65, + verbose_name=_("Name"), + ) + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + ) + limit_per_user = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit per user"), + help_text=_("The total number of items from this category one " + "attendee may purchase."), + ) + required = models.BooleanField( + blank=True, + help_text=_("If enabled, a user must select an " + "item from this category."), + ) + order = models.PositiveIntegerField( + verbose_name=("Display order"), + db_index=True, + ) + render_type = models.IntegerField( + choices=CATEGORY_RENDER_TYPES, + verbose_name=_("Render type"), + help_text=_("The registration form will render this category in this " + "style."), + ) + + +@python_2_unicode_compatible +class Product(models.Model): + ''' Registration products ''' + + class Meta: + app_label = "registrasion" + verbose_name = _("inventory - product") + ordering = ("category__order", "order") + + def __str__(self): + return "%s - %s" % (self.category.name, self.name) + + name = models.CharField( + max_length=65, + verbose_name=_("Name"), + ) + description = models.CharField( + max_length=255, + verbose_name=_("Description"), + null=True, + blank=True, + ) + category = models.ForeignKey( + Category, + verbose_name=_("Product category") + ) + price = models.DecimalField( + max_digits=8, + decimal_places=2, + verbose_name=_("Price"), + ) + limit_per_user = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit per user"), + ) + reservation_duration = models.DurationField( + default=datetime.timedelta(hours=1), + verbose_name=_("Reservation duration"), + help_text=_("The length of time this product will be reserved before " + "it is released for someone else to purchase."), + ) + order = models.PositiveIntegerField( + verbose_name=("Display order"), + db_index=True, + ) + + +@python_2_unicode_compatible +class Voucher(models.Model): + ''' Registration vouchers ''' + + class Meta: + app_label = "registrasion" + + # Vouchers reserve a cart for a fixed amount of time, so that + # items may be added without the voucher being swiped by someone else + RESERVATION_DURATION = datetime.timedelta(hours=1) + + def __str__(self): + return "Voucher for %s" % self.recipient + + @classmethod + def normalise_code(cls, code): + return code.upper() + + def save(self, *a, **k): + ''' Normalise the voucher code to be uppercase ''' + self.code = self.normalise_code(self.code) + super(Voucher, self).save(*a, **k) + + recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) + code = models.CharField(max_length=16, + unique=True, + verbose_name=_("Voucher code")) + limit = models.PositiveIntegerField(verbose_name=_("Voucher use limit")) diff --git a/registrasion/models/people.py b/registrasion/models/people.py new file mode 100644 index 00000000..bdd682ed --- /dev/null +++ b/registrasion/models/people.py @@ -0,0 +1,79 @@ +from registrasion import util + +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from model_utils.managers import InheritanceManager + + +# User models + +@python_2_unicode_compatible +class Attendee(models.Model): + ''' Miscellaneous user-related data. ''' + + class Meta: + app_label = "registrasion" + + def __str__(self): + return "%s" % self.user + + @staticmethod + def get_instance(user): + ''' Returns the instance of attendee for the given user, or creates + a new one. ''' + try: + return Attendee.objects.get(user=user) + except ObjectDoesNotExist: + return Attendee.objects.create(user=user) + + def save(self, *a, **k): + while not self.access_code: + access_code = util.generate_access_code() + if Attendee.objects.filter(access_code=access_code).count() == 0: + self.access_code = access_code + return super(Attendee, self).save(*a, **k) + + user = models.OneToOneField(User, on_delete=models.CASCADE) + # Badge/profile is linked + access_code = models.CharField( + max_length=6, + unique=True, + db_index=True, + ) + completed_registration = models.BooleanField(default=False) + guided_categories_complete = models.ManyToManyField("category") + + +class AttendeeProfileBase(models.Model): + ''' Information for an attendee's badge and related preferences. + Subclass this in your Django site to ask for attendee information in your + registration progess. + ''' + + class Meta: + app_label = "registrasion" + + objects = InheritanceManager() + + @classmethod + def name_field(cls): + ''' This is used to pre-fill the attendee's name from the + speaker profile. If it's None, that functionality is disabled. ''' + return None + + def invoice_recipient(self): + ''' Returns a representation of this attendee profile for the purpose + of rendering to an invoice. Override in subclasses. ''' + + # Manual dispatch to subclass. Fleh. + slf = AttendeeProfileBase.objects.get_subclass(id=self.id) + # Actually compare the functions. + if type(slf).invoice_recipient != type(self).invoice_recipient: + return type(slf).invoice_recipient(slf) + + # Return a default + return slf.attendee.user.username + + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 07ea7c14..b3d3cba3 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -1,4 +1,5 @@ -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory from registrasion.controllers.category import CategoryController from collections import namedtuple @@ -19,7 +20,7 @@ def available_categories(context): @register.assignment_tag(takes_context=True) def available_credit(context): ''' Returns the amount of unclaimed credit available for this user. ''' - notes = rego.CreditNote.unclaimed().filter( + notes = commerce.CreditNote.unclaimed().filter( invoice__user=context.request.user, ) ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0 @@ -29,7 +30,7 @@ def available_credit(context): @register.assignment_tag(takes_context=True) def invoices(context): ''' Returns all of the invoices that this user has. ''' - return rego.Invoice.objects.filter(cart__user=context.request.user) + return commerce.Invoice.objects.filter(cart__user=context.request.user) @register.assignment_tag(takes_context=True) @@ -37,7 +38,7 @@ def items_pending(context): ''' Returns all of the items that this user has in their current cart, and is awaiting payment. ''' - all_items = rego.ProductItem.objects.filter( + all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, cart__active=True, ).select_related( @@ -55,7 +56,7 @@ def items_purchased(context, category=None): ''' Returns all of the items that this user has purchased, optionally from the given category. ''' - all_items = rego.ProductItem.objects.filter( + all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, cart__active=False, cart__released=False, @@ -65,7 +66,7 @@ def items_purchased(context, category=None): all_items = all_items.filter(product__category=category) pq = all_items.values("product").annotate(quantity=Sum("quantity")).all() - products = rego.Product.objects.all() + products = inventory.Product.objects.all() out = [] for item in pq: prod = products.get(pk=item["product"]) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index fe41f316..ac676c03 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -1,7 +1,7 @@ from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController -from registrasion import models as rego +from registrasion.models import commerce from django.core.exceptions import ObjectDoesNotExist @@ -19,7 +19,7 @@ class TestingCartController(CartController): ValidationError if constraints are violated.''' try: - product_item = rego.ProductItem.objects.get( + product_item = commerce.ProductItem.objects.get( cart=self.cart, product=product) old_quantity = product_item.quantity @@ -41,7 +41,7 @@ class TestingInvoiceController(InvoiceController): self.validate_allowed_to_pay() ''' Adds a payment ''' - rego.ManualPayment.objects.create( + commerce.ManualPayment.objects.create( invoice=self.invoice, reference=reference, amount=amount, @@ -53,7 +53,7 @@ class TestingInvoiceController(InvoiceController): class TestingCreditNoteController(CreditNoteController): def refund(self): - rego.CreditNoteRefund.objects.create( + commerce.CreditNoteRefund.objects.create( parent=self.credit_note, reference="Whoops." ) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 066bf377..0f5565e5 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -7,7 +7,10 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.test import TestCase -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory +from registrasion.models import people from registrasion.controllers.product import ProductController from controller_helpers import TestingCartController @@ -36,24 +39,28 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', password='top_secret') - attendee1 = rego.Attendee.get_instance(cls.USER_1) + attendee1 = people.Attendee.get_instance(cls.USER_1) attendee1.save() - profile1 = rego.AttendeeProfileBase.objects.create(attendee=attendee1) + profile1 = people.AttendeeProfileBase.objects.create( + attendee=attendee1, + ) profile1.save() - attendee2 = rego.Attendee.get_instance(cls.USER_2) + attendee2 = people.Attendee.get_instance(cls.USER_2) attendee2.save() - profile2 = rego.AttendeeProfileBase.objects.create(attendee=attendee2) + profile2 = people.AttendeeProfileBase.objects.create( + attendee=attendee2, + ) profile2.save() cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] for i in xrange(2): - cat = rego.Category.objects.create( + cat = inventory.Category.objects.create( name="Category " + str(i + 1), description="This is a test category", order=i, - render_type=rego.Category.RENDER_TYPE_RADIO, + render_type=inventory.Category.RENDER_TYPE_RADIO, required=False, ) cat.save() @@ -64,7 +71,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.products = [] for i in xrange(4): - prod = rego.Product.objects.create( + prod = inventory.Product.objects.create( name="Product " + str(i + 1), description="This is a test product.", category=cls.categories[i / 2], # 2 products per category @@ -95,9 +102,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): - limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( + limit_ceiling = conditions.TimeOrStockLimitFlag.objects.create( description=name, - condition=rego.FlagBase.DISABLE_IF_FALSE, + condition=conditions.FlagBase.DISABLE_IF_FALSE, limit=limit, start_time=start_time, end_time=end_time @@ -109,9 +116,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def make_category_ceiling( cls, name, limit=None, start_time=None, end_time=None): - limit_ceiling = rego.TimeOrStockLimitFlag.objects.create( + limit_ceiling = conditions.TimeOrStockLimitFlag.objects.create( description=name, - condition=rego.FlagBase.DISABLE_IF_FALSE, + condition=conditions.FlagBase.DISABLE_IF_FALSE, limit=limit, start_time=start_time, end_time=end_time @@ -124,14 +131,14 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): def make_discount_ceiling( cls, name, limit=None, start_time=None, end_time=None, percentage=100): - limit_ceiling = rego.TimeOrStockLimitDiscount.objects.create( + limit_ceiling = conditions.TimeOrStockLimitDiscount.objects.create( description=name, start_time=start_time, end_time=end_time, limit=limit, ) limit_ceiling.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=limit_ceiling, product=cls.PROD_1, percentage=percentage, @@ -140,7 +147,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def new_voucher(self, code="VOUCHER", limit=1): - voucher = rego.Voucher.objects.create( + voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code=code, limit=limit, @@ -176,7 +183,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) # Count of products for a given user should be collapsed. - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( cart=current_cart.cart, product=self.PROD_1) self.assertEqual(1, len(items)) @@ -187,7 +194,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart = TestingCartController.for_user(self.USER_1) def get_item(): - return rego.ProductItem.objects.get( + return commerce.ProductItem.objects.get( cart=current_cart.cart, product=self.PROD_1) diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index c5664481..d3841ca8 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase -from registrasion import models as rego +from registrasion.models import conditions UTC = pytz.timezone('UTC') @@ -155,12 +155,12 @@ class CeilingsTestCases(RegistrationCartTestCase): self.make_discount_ceiling("Limit ceiling", limit=1, percentage=50) voucher = self.new_voucher(code="VOUCHER") - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=100, diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index e84ca283..3229a381 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -2,7 +2,7 @@ import pytz from decimal import Decimal -from registrasion import models as rego +from registrasion.models import conditions from registrasion.controllers import discount from controller_helpers import TestingCartController @@ -19,13 +19,13 @@ class DiscountTestCase(RegistrationCartTestCase): amount=Decimal(100), quantity=2, ): - discount = rego.IncludedProductDiscount.objects.create( + discount = conditions.IncludedProductDiscount.objects.create( description="PROD_1 includes PROD_2 " + str(amount) + "%", ) discount.save() discount.enabling_products.add(cls.PROD_1) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_2, percentage=amount, @@ -39,13 +39,13 @@ class DiscountTestCase(RegistrationCartTestCase): amount=Decimal(100), quantity=2, ): - discount = rego.IncludedProductDiscount.objects.create( + discount = conditions.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) discount.save() discount.enabling_products.add(cls.PROD_1) discount.save() - rego.DiscountForCategory.objects.create( + conditions.DiscountForCategory.objects.create( discount=discount, category=cls.CAT_2, percentage=amount, @@ -59,20 +59,20 @@ class DiscountTestCase(RegistrationCartTestCase): amount=Decimal(100), quantity=2, ): - discount = rego.IncludedProductDiscount.objects.create( + discount = conditions.IncludedProductDiscount.objects.create( description="PROD_1 includes PROD_3 and PROD_4 " + str(amount) + "%", ) discount.save() discount.enabling_products.add(cls.PROD_1) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_3, percentage=amount, quantity=quantity, ).save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_4, percentage=amount, diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index e1e1c166..1c03c6c8 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -2,7 +2,8 @@ import pytz from django.core.exceptions import ValidationError -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions from registrasion.controllers.category import CategoryController from controller_helpers import TestingCartController from registrasion.controllers.product import ProductController @@ -15,10 +16,10 @@ UTC = pytz.timezone('UTC') class FlagTestCases(RegistrationCartTestCase): @classmethod - def add_product_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): + def add_product_flag(cls, condition=conditions.FlagBase.ENABLE_IF_TRUE): ''' Adds a product flag condition: adding PROD_1 to a cart is predicated on adding PROD_2 beforehand. ''' - flag = rego.ProductFlag.objects.create( + flag = conditions.ProductFlag.objects.create( description="Product condition", condition=condition, ) @@ -28,10 +29,13 @@ class FlagTestCases(RegistrationCartTestCase): flag.save() @classmethod - def add_product_flag_on_category(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): + def add_product_flag_on_category( + cls, + condition=conditions.FlagBase.ENABLE_IF_TRUE, + ): ''' Adds a product flag condition that operates on a category: adding an item from CAT_1 is predicated on adding PROD_3 beforehand ''' - flag = rego.ProductFlag.objects.create( + flag = conditions.ProductFlag.objects.create( description="Product condition", condition=condition, ) @@ -40,10 +44,10 @@ class FlagTestCases(RegistrationCartTestCase): flag.enabling_products.add(cls.PROD_3) flag.save() - def add_category_flag(cls, condition=rego.FlagBase.ENABLE_IF_TRUE): + def add_category_flag(cls, condition=conditions.FlagBase.ENABLE_IF_TRUE): ''' Adds a category flag condition: adding PROD_1 to a cart is predicated on adding an item from CAT_2 beforehand.''' - flag = rego.CategoryFlag.objects.create( + flag = conditions.CategoryFlag.objects.create( description="Category condition", condition=condition, enabling_category=cls.CAT_2, @@ -131,8 +135,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_1, 1) def test_multiple_dif_conditions(self): - self.add_product_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) - self.add_category_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) + self.add_product_flag(condition=conditions.FlagBase.DISABLE_IF_FALSE) + self.add_category_flag(condition=conditions.FlagBase.DISABLE_IF_FALSE) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -145,8 +149,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_1.add_to_cart(self.PROD_1, 1) def test_eit_and_dif_conditions_work_together(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) - self.add_category_flag(condition=rego.FlagBase.DISABLE_IF_FALSE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) + self.add_category_flag(condition=conditions.FlagBase.DISABLE_IF_FALSE) cart_1 = TestingCartController.for_user(self.USER_1) # Cannot add PROD_1 until both conditions are met @@ -200,7 +204,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_4 in prods) def test_available_products_on_category_works_when_condition_not_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) prods = ProductController.available_products( self.USER_1, @@ -211,7 +215,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_category_works_when_condition_is_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -225,7 +229,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_not_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) prods = ProductController.available_products( self.USER_1, @@ -236,7 +240,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_available_products_on_products_works_when_condition_is_met(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.add_to_cart(self.PROD_2, 1) @@ -250,7 +254,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.PROD_2 in prods) def test_category_flag_fails_if_cart_refunded(self): - self.add_category_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_category_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) @@ -268,7 +272,7 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_product_flag_fails_if_cart_refunded(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -286,7 +290,9 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.set_quantity(self.PROD_1, 1) def test_available_categories(self): - self.add_product_flag_on_category(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag_on_category( + condition=conditions.FlagBase.ENABLE_IF_TRUE, + ) cart_1 = TestingCartController.for_user(self.USER_1) @@ -307,7 +313,7 @@ class FlagTestCases(RegistrationCartTestCase): self.assertTrue(self.CAT_2 in cats) def test_validate_cart_when_flags_become_unmet(self): - self.add_product_flag(condition=rego.FlagBase.ENABLE_IF_TRUE) + self.add_product_flag(condition=conditions.FlagBase.ENABLE_IF_TRUE) cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_2, 1) @@ -332,7 +338,7 @@ class FlagTestCases(RegistrationCartTestCase): cart.validate_cart() # Should keep PROD_2 in the cart - items = rego.ProductItem.objects.filter(cart=cart.cart) + items = commerce.ProductItem.objects.filter(cart=cart.cart) self.assertFalse([i for i in items if i.product == self.PROD_1]) def test_fix_simple_errors_does_not_remove_limited_items(self): @@ -348,5 +354,5 @@ class FlagTestCases(RegistrationCartTestCase): # Should keep PROD_2 in the cart # and also PROD_1, which is now exhausted for user. - items = rego.ProductItem.objects.filter(cart=cart.cart) + items = commerce.ProductItem.objects.filter(cart=cart.cart) self.assertTrue([i for i in items if i.product == self.PROD_1]) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index f9ea59b6..3a655bb1 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -4,7 +4,9 @@ import pytz from decimal import Decimal from django.core.exceptions import ValidationError -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.models import inventory from controller_helpers import TestingCartController from controller_helpers import TestingCreditNoteController from controller_helpers import TestingInvoiceController @@ -23,7 +25,9 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have a single line item - line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) + line_items = commerce.LineItem.objects.filter( + invoice=invoice_1.invoice, + ) self.assertEqual(1, len(line_items)) # That invoice should have a value equal to cost of PROD_1 self.assertEqual(self.PROD_1.price, invoice_1.invoice.value) @@ -34,13 +38,15 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) # The old invoice should automatically be voided - invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id) - invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id) + invoice_1_new = commerce.Invoice.objects.get(pk=invoice_1.invoice.id) + invoice_2_new = commerce.Invoice.objects.get(pk=invoice_2.invoice.id) self.assertTrue(invoice_1_new.is_void) self.assertFalse(invoice_2_new.is_void) # Invoice should have two line items - line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) + line_items = commerce.LineItem.objects.filter( + invoice=invoice_2.invoice, + ) self.assertEqual(2, len(line_items)) # Invoice should have a value equal to cost of PROD_1 and PROD_2 self.assertEqual( @@ -79,16 +85,16 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertNotEqual(current_cart.cart, new_cart.cart) def test_invoice_includes_discounts(self): - voucher = rego.Voucher.objects.create( + voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code="VOUCHER", limit=1 ) - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(50), @@ -103,7 +109,9 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have two line items - line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) + line_items = commerce.LineItem.objects.filter( + invoice=invoice_1.invoice, + ) self.assertEqual(2, len(line_items)) # That invoice should have a value equal to 50% of the cost of PROD_1 self.assertEqual( @@ -111,16 +119,16 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.invoice.value) def test_zero_value_invoice_is_automatically_paid(self): - voucher = rego.Voucher.objects.create( + voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code="VOUCHER", limit=1 ) - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(100), @@ -239,7 +247,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_paid) # There should be a credit note generated out of the invoice. - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(1, credit_notes.count()) self.assertEqual(to_pay - invoice.invoice.value, credit_notes[0].value) @@ -257,7 +267,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_paid) # There should be no credit notes - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(0, credit_notes.count()) def test_refund_partially_paid_invoice_generates_correct_credit_note(self): @@ -276,7 +288,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_void) # There should be a credit note generated out of the invoice. - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(1, credit_notes.count()) self.assertEqual(to_pay, credit_notes[0].value) @@ -297,7 +311,9 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_refunded) # There should be a credit note generated out of the invoice. - credit_notes = rego.CreditNote.objects.filter(invoice=invoice.invoice) + credit_notes = commerce.CreditNote.objects.filter( + invoice=invoice.invoice, + ) self.assertEqual(1, credit_notes.count()) self.assertEqual(to_pay, credit_notes[0].value) @@ -314,11 +330,11 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) # That credit note should be in the unclaimed pile - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) # Create a new (identical) cart with invoice cart = TestingCartController.for_user(self.USER_1) @@ -330,7 +346,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice2.invoice.is_paid) # That invoice should not show up as unclaimed any more - self.assertEquals(0, rego.CreditNote.unclaimed().count()) + self.assertEquals(0, commerce.CreditNote.unclaimed().count()) def test_apply_credit_note_generates_new_credit_note_if_overpaying(self): cart = TestingCartController.for_user(self.USER_1) @@ -345,10 +361,10 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) # Create a new cart (of half value of inv 1) and get invoice cart = TestingCartController.for_user(self.USER_1) @@ -361,9 +377,11 @@ class InvoiceTestCase(RegistrationCartTestCase): # We generated a new credit note, and spent the old one, # unclaimed should still be 1. - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note2 = rego.CreditNote.objects.get(invoice=invoice2.invoice) + credit_note2 = commerce.CreditNote.objects.get( + invoice=invoice2.invoice, + ) # The new credit note should be the residual of the cost of cart 1 # minus the cost of cart 2. @@ -385,7 +403,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) # Create a new cart with invoice, pay it @@ -426,15 +444,15 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) cn.refund() # Refunding a credit note should mark it as claimed - self.assertEquals(0, rego.CreditNote.unclaimed().count()) + self.assertEquals(0, commerce.CreditNote.unclaimed().count()) # Create a new cart with invoice cart = TestingCartController.for_user(self.USER_1) @@ -458,9 +476,9 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() - self.assertEquals(1, rego.CreditNote.unclaimed().count()) + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = rego.CreditNote.objects.get(invoice=invoice.invoice) + credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) cn = TestingCreditNoteController(credit_note) @@ -471,7 +489,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) cn.apply_to_invoice(invoice_2.invoice) - self.assertEquals(0, rego.CreditNote.unclaimed().count()) + self.assertEquals(0, commerce.CreditNote.unclaimed().count()) # Cannot refund this credit note as it is already applied. with self.assertRaises(ValidationError): diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 5f1e07f0..46b2270a 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -5,7 +5,8 @@ from decimal import Decimal from django.core.exceptions import ValidationError from django.db import IntegrityError -from registrasion import models as rego +from registrasion.models import conditions +from registrasion.models import inventory from controller_helpers import TestingCartController from controller_helpers import TestingInvoiceController @@ -32,14 +33,14 @@ class VoucherTestCases(RegistrationCartTestCase): # After the reservation duration # user 2 should be able to apply voucher - self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) + self.add_timedelta(inventory.Voucher.RESERVATION_DURATION * 2) cart_2.apply_voucher(voucher.code) cart_2.next_cart() # After the reservation duration, even though the voucher has applied, # it exceeds the number of vouchers available. - self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) + self.add_timedelta(inventory.Voucher.RESERVATION_DURATION * 2) with self.assertRaises(ValidationError): cart_1.validate_cart() @@ -58,10 +59,10 @@ class VoucherTestCases(RegistrationCartTestCase): def test_voucher_enables_item(self): voucher = self.new_voucher() - flag = rego.VoucherFlag.objects.create( + flag = conditions.VoucherFlag.objects.create( description="Voucher condition", voucher=voucher, - condition=rego.FlagBase.ENABLE_IF_TRUE, + condition=conditions.FlagBase.ENABLE_IF_TRUE, ) flag.save() flag.products.add(self.PROD_1) @@ -79,12 +80,12 @@ class VoucherTestCases(RegistrationCartTestCase): def test_voucher_enables_discount(self): voucher = self.new_voucher() - discount = rego.VoucherDiscount.objects.create( + discount = conditions.VoucherDiscount.objects.create( description="VOUCHER RECIPIENT", voucher=voucher, ) discount.save() - rego.DiscountForProduct.objects.create( + conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(100), diff --git a/registrasion/views.py b/registrasion/views.py index b2ca8eca..ea4acc8b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,7 +1,9 @@ import sys from registrasion import forms -from registrasion import models as rego +from registrasion.models import commerce +from registrasion.models import inventory +from registrasion.models import people from registrasion.controllers import discount from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController @@ -60,7 +62,7 @@ def guided_registration(request, page_id=0): sections = [] - attendee = rego.Attendee.get_instance(request.user) + attendee = people.Attendee.get_instance(request.user) if attendee.completed_registration: return render( @@ -111,7 +113,7 @@ def guided_registration(request, page_id=0): starting = attendee.guided_categories_complete.count() == 0 # Get the next category - cats = rego.Category.objects + cats = inventory.Category.objects if SESSION_KEY in request.session: _cats = request.session[SESSION_KEY] cats = cats.filter(id__in=_cats) @@ -134,7 +136,7 @@ def guided_registration(request, page_id=0): current_step = 3 title = "Additional items" - all_products = rego.Product.objects.filter( + all_products = inventory.Product.objects.filter( category__in=cats, ).select_related("category") @@ -217,11 +219,13 @@ def edit_profile(request): def handle_profile(request, prefix): ''' Returns a profile form instance, and a boolean which is true if the form was handled. ''' - attendee = rego.Attendee.get_instance(request.user) + attendee = people.Attendee.get_instance(request.user) try: profile = attendee.attendeeprofilebase - profile = rego.AttendeeProfileBase.objects.get_subclass(pk=profile.id) + profile = people.AttendeeProfileBase.objects.get_subclass( + pk=profile.id, + ) except ObjectDoesNotExist: profile = None @@ -270,7 +274,7 @@ def product_category(request, category_id): voucher_form, voucher_handled = v category_id = int(category_id) # Routing is [0-9]+ - category = rego.Category.objects.get(pk=category_id) + category = inventory.Category.objects.get(pk=category_id) products = ProductController.available_products( request.user, @@ -316,7 +320,7 @@ def handle_products(request, category, products, prefix): ProductsForm = forms.ProductsForm(category, products) # Create initial data for each of products in category - items = rego.ProductItem.objects.filter( + items = commerce.ProductItem.objects.filter( product__in=products, cart=current_cart.cart, ).select_related("product") @@ -344,8 +348,8 @@ def handle_products(request, category, products, prefix): # If category is required, the user must have at least one # in an active+valid cart if category.required: - carts = rego.Cart.objects.filter(user=request.user) - items = rego.ProductItem.objects.filter( + carts = commerce.Cart.objects.filter(user=request.user) + items = commerce.ProductItem.objects.filter( product__category=category, cart=carts, ) @@ -366,7 +370,7 @@ def set_quantities_from_products_form(products_form, current_cart): quantities = list(products_form.product_quantities()) pks = [i[0] for i in quantities] - products = rego.Product.objects.filter( + products = inventory.Product.objects.filter( id__in=pks, ).select_related("category") @@ -384,7 +388,7 @@ def set_quantities_from_products_form(products_form, current_cart): product, message = ve_field.message if product in field_names: field = field_names[product] - elif isinstance(product, rego.Product): + elif isinstance(product, inventory.Product): continue else: field = None @@ -402,7 +406,7 @@ def handle_voucher(request, prefix): voucher_form.cleaned_data["voucher"].strip()): voucher = voucher_form.cleaned_data["voucher"] - voucher = rego.Voucher.normalise_code(voucher) + voucher = inventory.Voucher.normalise_code(voucher) if len(current_cart.cart.vouchers.filter(code=voucher)) > 0: # This voucher has already been applied to this cart. @@ -457,9 +461,9 @@ def invoice_access(request, access_code): ''' Redirects to the first unpaid invoice for the attendee that matches the given access code, if any. ''' - invoices = rego.Invoice.objects.filter( + invoices = commerce.Invoice.objects.filter( user__attendee__access_code=access_code, - status=rego.Invoice.STATUS_UNPAID, + status=commerce.Invoice.STATUS_UNPAID, ).order_by("issue_time") if not invoices: @@ -478,7 +482,7 @@ def invoice(request, invoice_id, access_code=None): ''' invoice_id = int(invoice_id) - inv = rego.Invoice.objects.get(pk=invoice_id) + inv = commerce.Invoice.objects.get(pk=invoice_id) current_invoice = InvoiceController(inv) @@ -505,7 +509,7 @@ def manual_payment(request, invoice_id): raise Http404() invoice_id = int(invoice_id) - inv = get_object_or_404(rego.Invoice, pk=invoice_id) + inv = get_object_or_404(commerce.Invoice, pk=invoice_id) current_invoice = InvoiceController(inv) form = forms.ManualPaymentForm( @@ -536,7 +540,7 @@ def refund(request, invoice_id): raise Http404() invoice_id = int(invoice_id) - inv = get_object_or_404(rego.Invoice, pk=invoice_id) + inv = get_object_or_404(commerce.Invoice, pk=invoice_id) current_invoice = InvoiceController(inv) try: @@ -557,7 +561,7 @@ def credit_note(request, note_id, access_code=None): raise Http404() note_id = int(note_id) - note = rego.CreditNote.objects.get(pk=note_id) + note = commerce.CreditNote.objects.get(pk=note_id) current_note = CreditNoteController(note) @@ -574,10 +578,11 @@ def credit_note(request, note_id, access_code=None): if request.POST and apply_form.is_valid(): inv_id = apply_form.cleaned_data["invoice"] - invoice = rego.Invoice.objects.get(pk=inv_id) + invoice = commerce.Invoice.objects.get(pk=inv_id) current_note.apply_to_invoice(invoice) - messages.success(request, - "Applied credit note %d to invoice." % note_id + messages.success( + request, + "Applied credit note %d to invoice." % note_id, ) return redirect("invoice", invoice.id) @@ -585,7 +590,8 @@ def credit_note(request, note_id, access_code=None): refund_form.instance.entered_by = request.user refund_form.instance.parent = note refund_form.save() - messages.success(request, + messages.success( + request, "Applied manual refund to credit note." ) return redirect("invoice", invoice.id) From e9ebf5da03ea02cd453e8f95cedcbbb8bfb6ec0b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Apr 2016 14:08:13 +1000 Subject: [PATCH 139/418] Writes inventory and overview documentation, and updates docstrings on a LOT of modules. --- README.md | 2 - README.rst | 30 +++ docs/Makefile | 231 +++++++++++++++++++++++ docs/conf.py | 297 ++++++++++++++++++++++++++++++ docs/django_settings.py | 122 ++++++++++++ docs/for-zookeepr-users.rst | 26 +++ docs/index.rst | 29 +++ docs/inventory.rst | 153 +++++++++++++++ docs/make.bat | 281 ++++++++++++++++++++++++++++ docs/overview.rst | 51 +++++ registrasion/models/conditions.py | 133 +++++++++++-- registrasion/models/inventory.py | 45 ++++- requirements/base.txt | 1 - requirements/docs.txt | 4 + requirements/extern.txt | 4 + 15 files changed, 1387 insertions(+), 22 deletions(-) delete mode 100644 README.md create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/django_settings.py create mode 100644 docs/for-zookeepr-users.rst create mode 100644 docs/index.rst create mode 100644 docs/inventory.rst create mode 100644 docs/make.bat create mode 100644 docs/overview.rst create mode 100644 requirements/docs.txt create mode 100644 requirements/extern.txt diff --git a/README.md b/README.md deleted file mode 100644 index e1ab38c4..00000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# registrasion -A conference registration app, built on top of the Symposion conference management system diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..be049b72 --- /dev/null +++ b/README.rst @@ -0,0 +1,30 @@ +============ +Registrasion +============ + +Symposion +--------- +``symposion`` is an Open Source conference management solution built with Pinax +apps for Django. For more information, see https://github.com/pinax/symposion. + +registrasion +------------ +``registrasion`` is a registration package that you use alongside Symposion. It +handles inventory management, as well as complex product inclusions, automatic +calculation of discounts, and invoicing. Payment of invoices can be faciliated +by manual filings of payments by staff, or through plugging in a payment app. + +Initial development of ``registration`` was funded with the generous support of +the Python Software Foundation. + +Quickstart +---------- +``registrasion`` is a Django app. You will need to create a Django project to +customize and manage your Registrasion and Symposion installation. A +demonstration app project with templates is available at +https://github.com/chrisjrn/registrasion-demo + +Documentation +------------- +The documentation for ``registrasion`` is available at +http://registrasion.readthedocs.org/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..88006e7a --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,231 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) + $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Registrasion.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Registrasion.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Registrasion" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Registrasion" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..99d8902e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +# +# Registrasion documentation build configuration file, created by +# sphinx-quickstart on Thu Apr 21 11:29:51 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +# Autodoc requires django to be ready to go, otherwise we can't import rego's +# things... +sys.path.insert(0, ".") +os.environ["DJANGO_SETTINGS_MODULE"] = "django_settings" + +import django +django.setup() + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Registrasion' +copyright = u'2016, Christopher Neugebauer' +author = u'Christopher Neugebauer' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = u'0.1a1' +# The full version, including alpha/beta/rc tags. +release = u'0.1a1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org +on_rtd = os.environ.get('READTHEDOCS', None) == 'True' + +if not on_rtd: # only import and set the theme if we're building docs locally + import sphinx_rtd_theme + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + + +# The name for this set of Sphinx documents. +# " v documentation" by default. +#html_title = u'Registrasion v0.1a1' + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +#html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Registrasiondoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Registrasion.tex', u'Registrasion Documentation', + u'Christopher Neugebauer', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'registrasion', u'Registrasion Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Registrasion', u'Registrasion Documentation', + author, 'Registrasion', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/django_settings.py b/docs/django_settings.py new file mode 100644 index 00000000..56a07eca --- /dev/null +++ b/docs/django_settings.py @@ -0,0 +1,122 @@ +""" +Django settings for djangoenv project. + +Generated by 'django-admin startproject' using Django 1.9.5. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'z$cu8&jcnm#qa=xbrkss-4w8do+(pp16j*usmp9j&bg=)&1@-a' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'registrasion', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'djangoenv.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'djangoenv.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/docs/for-zookeepr-users.rst b/docs/for-zookeepr-users.rst new file mode 100644 index 00000000..f4d39f87 --- /dev/null +++ b/docs/for-zookeepr-users.rst @@ -0,0 +1,26 @@ +================================ +Registrasion for Zookeepr Keeprs +================================ + +Things that are the same +------------------------ +* You have an inventory of products +* Complete registrations are made up of multiple products +* Products are split into categories +* Products can be listed under ceilings +* Products can be included for free by purchasing other items + * e.g. a Professional Ticket includes a dinner ticket +* Products can be enabled by user roles + * e.g. Speakers Dinner tickets are visible to speakers +* Vouchers can be used to discount products + +Things that are different +------------------------- +* Ceilings can be used to apply discounts, so Early Bird ticket rates can be + implemented by applying a ceiling-type discount, rather than duplicating the + ticket type. +* Vouchers can be used to enable products + * e.g. Sponsor tickets do not appear until you supply a sponsor's voucher +* Items may be enabled by having other specific items present + * e.g. Extra accommodation nights do not appear until you purchase the main + week worth of accommodation. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..266fa463 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,29 @@ +.. Registrasion documentation master file, created by + sphinx-quickstart on Thu Apr 21 11:29:51 2016. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Registrasion +============ + +Registrasion is a conference registration package that goes well with the Symposion suite of conference management apps for Django. It's designed to manage the sorts of inventories that large conferences need to manage, build up complex tickets with multiple items, and handle payments using whatever payment gateway you happen to have access to + +Development of registrasion was commenced by Christopher Neugebauer in 2016, with the gracious support of the Python Software Foundation. + + +Contents: +--------- +.. toctree:: + :maxdepth: 2 + + overview + inventory + for-zookeepr-users + + +Indices and tables +================== + +* :ref:`genindex` +.. * :ref:`modindex` +* :ref:`search` diff --git a/docs/inventory.rst b/docs/inventory.rst new file mode 100644 index 00000000..65ac9c46 --- /dev/null +++ b/docs/inventory.rst @@ -0,0 +1,153 @@ + +Inventory Management +==================== + +Registrasion uses an inventory model to keep track of tickets, and the other various products that attendees of your conference might want to have, such as t-shirts and dinner tickets. + +The inventory model is split up into Categories and Products. Categories are used to group Products. + +Registrasion uses conditionals to build up complex tickets, or enable/disable specific items to specific users: + +Often, you will want to offer free items, such as t-shirts or dinner tickets to your attendees. Registrasion has a Discounts facility that lets you automatically offer free items to your attendees as part of their tickets. You can also automatically offer promotional discounts, such as Early Bird discounts. + +Sometimes, you may want to restrict parts of the conference to specific attendees, for example, you might have a Speakers Dinner to only speakers. Or you might want to restrict certain Products to attendees who have purchased other items, for example, you might want to sell Comfy Chairs to people who've bought VIP tickets. You can control showing and hiding specific products using Flags. + + +.. automodule:: registrasion.models.inventory + +Categories +---------- + +Categories are logical groups of Products. Generally, you should keep like products in the same category, and use as many categories as you need. + +You will need at least one Category to be able to sell tickets to your attendees. + +Each category has the following attributes: + +.. autoclass :: Category + + +Products +-------- + +Products represent the different items that comprise a user's conference ticket. + +Each product has the following attributes: + +.. autoclass :: Product + + +Vouchers +-------- + +Vouchers are used to enable Discounts or Flags for people who enter a voucher +code. + +.. autoclass :: Voucher + +If an attendee enters a voucher code, they have at least an hour to finalise +their registration before the voucher becomes unreserved. Only as many people +as allowed by ``limit`` are allowed to have a voucher reserved. + + +.. automodule:: registrasion.models.conditions + +Discounts +--------- + +Discounts serve multiple purposes: they can be used to build up complex tickets by automatically waiving the costs for sub-products; they can be used to offer freebie tickets to specific people, or people who hold voucher codes; or they can be used to enable short-term promotional discounts. + +Registrasion has several types of discounts, which enable themselves under specific conditions. We'll explain how these work later on, but first: + +Common features +~~~~~~~~~~~~~~~ +Each discount type has the following common attributes: + +.. autoclass :: DiscountBase + +You can apply a discount to individual products, or to whole categories, or both. All of the products and categories affected by the discount are displayed on the discount's admin page. + +If you choose to specify individual products, you have these options: + +.. autoclass :: DiscountForProduct + +If you choose to specify whole categories, you have these options: + +.. autoclass :: DiscountForCategory + +Note that you cannot have a discount apply to both a category, and a product within that category. + +Product Inclusions +~~~~~~~~~~~~~~~~~~ +Product inclusion discounts allow you to enable a discount when an attendee has selected a specific enabling Product. + +For example, if you want to give everyone with a ticket a free t-shirt, you can use a product inclusion to offer a 100% discount on the t-shirt category, if the attendee has selected one of your ticket Products. + +Once a discount has been enabled in one Invoice, it is available until the quantities are exhausted for that attendee. + +.. autoclass :: IncludedProductDiscount + +Time/stock limit discounts +~~~~~~~~~~~~~~~~~~~~~~~~~~ +These discounts allow you to offer a limited promotion that is automatically offered to all attendees. You can specify a time range for when the discount should be enabled, you can also specify a stock limit. + +.. autoclass :: TimeOrStockLimitDiscount + +Voucher discounts +~~~~~~~~~~~~~~~~~ +Vouchers can be used to enable discounts. + +.. autoclass :: VoucherDiscount + +How discounts get applied +~~~~~~~~~~~~~~~~~~~~~~~~~ +It's possible for multiple discounts to be available on any given Product. This means there need to be rules for how discounts get applied. It works like so: + +#. Take all of the Products that the user currently has selected, and sort them so that the most expensive comes first. +#. Apply the highest-value discount line for the first Product, until either all such products have a discount applied, or the discount's Quantity has been exhausted for that user for that Product. +#. Repeat until all products have been processed. + +In summary, the system greedily applies the highest-value discounts for each product. This may not provide a global optimum, but it'll do. + +As an example: say a user has a voucher available for a 100% discount of tickets, and there's a promotional discount for 15% off tickets. In this case, the 100% discount will apply, and the 15% discount will not be disturbed. + + +Flags +----- + +Flags are conditions that can be used to enable or disable Products or Categories, depending on whether conditions are met. They can be used to restrict specific products to specific people, or to place time limits on availability for products. + +Common Features +~~~~~~~~~~~~~~~ + +.. autoclass :: FlagBase + + +Dependencies on products from category +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Category Dependency flags have their condition met if a product from the enabling category has been selected by the attendee. For example, if there is an *Accommodation* Category, this flag could be used to enable an *Accommodation Breakfast* category, allowing only attendees with accommodation to purchase breakfast. + +.. autoclass :: CategoryFlag + + +Dependencies on products +~~~~~~~~~~~~~~~~~~~~~~~~ +Product dependency flags have their condition met if one of the enabling products have been selected by the attendee. + +.. autoclass :: ProductFlag + +Time/stock limit flags +~~~~~~~~~~~~~~~~~~~~~~ +These flags allow the products that they cover to be made available for a limited time, or to set a global ceiling on the products covered. + +These can be used to remove items from sale once a sales deadline has been met, or if a venue for a specific event has reached capacity. If there are items that fall under multiple such groupings, it makes sense to set all of these flags to be ``DISABLE_IF_FALSE``. + +.. autoclass :: TimeOrStockLimitFlag + +If any of the attributes are omitted, then only the remaining attributes affect the availablility of the products covered. If there's no attributes set at all, then the grouping has no effect, but it can be used to group products for reporting purposes. + +Voucher flags +~~~~~~~~~~~~~ +Vouchers can be used to enable flags. + +.. autoclass :: VoucherFlag diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..da9a651f --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Registrasion.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Registrasion.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 00000000..5f6b25fa --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,51 @@ +Overview +======== + +Registrasion's approach to handling conference registrations is to use a cart and inventory model, where the various things sold by the conference to attendees are handled as Products, which can be added to a Cart. Carts can be used to generate Invoices, and Invoices can then be paid. + + +Guided registration +------------------- + +Unlike a generic e-commerce platform, Registrasion is designed for building up conference tickets. + +When they first attempt registration, attendees start off in a process called *guided mode*. Guided mode is multi-step form that takes users through a complete registration process: + +#. The attendee fills out their profile +#. The attendee selects a ticket type +#. The attendee selects additional products such as t-shirts and dinner tickets, which may be sold at a cost, or have waivers applied. +#. The attendee is offered the opportunity to check out their cart, generating an invoice; or to enter amendments mode. + +For specifics on how guided mode works, see *code guide to be written*. + + +Amendments mode +--------------- + +Once attendees have reached the end of guided registration, they are permanently added to *amendments mode*. Amendments mode allows attendees to change their product selections in a given category, with one rule: once an invoice has been paid, product selections cannot be changed without voiding that invoice (and optionally releasing a Credit Note). + +Users can check out their current selections at any time, and generate an Invoice for their selections. That invoice can then be paid, and the attendee will then be making selections on a new cart. + + +Invoices +-------- + +When an attendee checks out their Cart, an Invoice is generated for their cart. + +An invoice is valid for as long as the items in the cart do not change, and remain generally available. If a user amends their cart after generating an invoice, the user will need to check out their cart again, and generate a new invoice. + +Once an invoice is paid, it is no longer possible for an invoice to be void, instead, it needs to have a refund generated. + + +User-Attendee Model +------------------- + +Registrasion uses a User-Attendee model. This means that Registrasion expects each user account on the system to represent a single attendee at the conference. It also expects that the attendee themselves fill out the registration form. + +This means that each attendee has a confirmed e-mail address for conference-related communications. It's usually a good idea for the conference to make sure that their account sign-up page points this out, so that administrative assistants at companies don't end up being the ones getting communicated at. + +How do people get their employers to pay for their tickets? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Registrasion provides a semi-private URL that allows anyone in possession of this URL to view that attendee's most recent invoice, and make payments against that invoice. + +A future release will add the ability to bulk-pay multiple invoices at once. diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index 804f3162..6f259d90 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -13,9 +13,14 @@ from model_utils.managers import InheritanceManager @python_2_unicode_compatible class DiscountBase(models.Model): - ''' Base class for discounts. Each subclass has controller code that - determines whether or not the given discount is available to be added to - the current cart. ''' + ''' 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" @@ -43,7 +48,23 @@ class DiscountBase(models.Model): 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. ''' + 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" @@ -88,7 +109,21 @@ class DiscountForProduct(models.Model): @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. ''' + 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" @@ -121,7 +156,19 @@ class DiscountForCategory(models.Model): class TimeOrStockLimitDiscount(DiscountBase): ''' Discounts that are generally available, but are limited by timespan or - usage count. This is for e.g. Early Bird discounts. ''' + 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" @@ -150,7 +197,13 @@ class TimeOrStockLimitDiscount(DiscountBase): class VoucherDiscount(DiscountBase): ''' Discounts that are enabled when a voucher code is in the current - cart. ''' + 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" @@ -167,7 +220,13 @@ class VoucherDiscount(DiscountBase): class IncludedProductDiscount(DiscountBase): ''' Discounts that are enabled because another product has been purchased. - e.g. A conference ticket includes a free t-shirt. ''' + 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" @@ -194,14 +253,30 @@ class FlagBase(models.Model): ''' This defines a condition which allows products or categories to be made visible, or be prevented from being visible. - The various subclasses of this can define the conditions that enable - or disable products, by the following rules: + 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. - 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. + condition (int): This determines the effect of this flag's condition + being met. There are two types of condition: - If both types of conditions exist on a product, both of these rules apply. + ``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. ''' class Meta: @@ -271,7 +346,21 @@ class EnablingConditionBase(FlagBase): class TimeOrStockLimitFlag(EnablingConditionBase): - ''' Registration product ceilings ''' + ''' 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" @@ -300,7 +389,12 @@ class TimeOrStockLimitFlag(EnablingConditionBase): @python_2_unicode_compatible class ProductFlag(EnablingConditionBase): - ''' The condition is met because a specific product is purchased. ''' + ''' 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" @@ -320,7 +414,12 @@ class ProductFlag(EnablingConditionBase): @python_2_unicode_compatible class CategoryFlag(EnablingConditionBase): ''' The condition is met because a product in a particular product is - purchased. ''' + purchased. + + Attributes: + enabling_category (inventory.Category): The category that causes this + condition to be met. + ''' class Meta: app_label = "registrasion" diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py index e4e27feb..84f386e1 100644 --- a/registrasion/models/inventory.py +++ b/registrasion/models/inventory.py @@ -96,7 +96,35 @@ class Category(models.Model): @python_2_unicode_compatible class Product(models.Model): - ''' Registration products ''' + ''' 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. + + display_order (int): An ascending order for displaying the Products + within each Category. + + ''' class Meta: app_label = "registrasion" @@ -144,7 +172,20 @@ class Product(models.Model): @python_2_unicode_compatible class Voucher(models.Model): - ''' Registration vouchers ''' + ''' 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" diff --git a/requirements/base.txt b/requirements/base.txt index 29d5ff62..25c5df9b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1 @@ django-nested-admin==2.2.6 -#-e git+https://github.com/pinax/symposion.git#egg=SymposionMaster # Needs Symposion diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 00000000..f070a205 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,4 @@ +# requirements needed to build the docs + +Sphinx==1.4.1 +sphinx-rtd-theme==0.1.9 diff --git a/requirements/extern.txt b/requirements/extern.txt new file mode 100644 index 00000000..3dc3c610 --- /dev/null +++ b/requirements/extern.txt @@ -0,0 +1,4 @@ +# Requirements that currently live in git land, so are necessary to make the +# project build, but can't live in setup.py + +-e git+https://github.com/pinax/symposion.git#egg=SymposionMaster # Symposion lives on git at the moment From 135aa7e3334f2d152029c9d62fc6b4b89d8bc314 Mon Sep 17 00:00:00 2001 From: Katie McLaughlin Date: Fri, 22 Apr 2016 16:20:11 +1000 Subject: [PATCH 140/418] Nested list RST formatting --- docs/for-zookeepr-users.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/for-zookeepr-users.rst b/docs/for-zookeepr-users.rst index f4d39f87..b2cbce44 100644 --- a/docs/for-zookeepr-users.rst +++ b/docs/for-zookeepr-users.rst @@ -9,9 +9,9 @@ Things that are the same * Products are split into categories * Products can be listed under ceilings * Products can be included for free by purchasing other items - * e.g. a Professional Ticket includes a dinner ticket + * e.g. a Professional Ticket includes a dinner ticket * Products can be enabled by user roles - * e.g. Speakers Dinner tickets are visible to speakers + * e.g. Speakers Dinner tickets are visible to speakers * Vouchers can be used to discount products Things that are different @@ -20,7 +20,7 @@ Things that are different implemented by applying a ceiling-type discount, rather than duplicating the ticket type. * Vouchers can be used to enable products - * e.g. Sponsor tickets do not appear until you supply a sponsor's voucher + * e.g. Sponsor tickets do not appear until you supply a sponsor's voucher * Items may be enabled by having other specific items present - * e.g. Extra accommodation nights do not appear until you purchase the main - week worth of accommodation. + * e.g. Extra accommodation nights do not appear until you purchase the main + week worth of accommodation. From 670671a3b34e662e80bff7f87009c71268655b71 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Apr 2016 16:41:59 +1000 Subject: [PATCH 141/418] Adds a CONTRIBUTING guide --- CONTRIBUTING.rst | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 CONTRIBUTING.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..dc3621bd --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,39 @@ +Contributing to Registrasion +============================ + +I'm glad that you're interested in helping make Registrasion better! Thanks! This guide is meant to help make sure your contributions to the project fit in well. + +Making a contribution +--------------------- + +This project makes use of GitHub issues to track pending work, so new features that need implementation can be found there. If you think a new feature needs to be added, raise an issue for discussion before submitting a Pull Request. + + +Code Style +---------- + +We use PEP8. Your code should pass checks by ``flake8`` with no errors or warnings before it will be merged. + +We use `Google-style docstrings `_, primarily because they're far far more readable than ReST docstrings. New functions should have complete docstrings, so that new contributors have a chance of understanding how the API works. + + +Structure +--------- + +Django Models live in ``registrasion/models``; we separate our models out into separate files, because there's a lot of them. Models are grouped by logical functionality. + +Actions that operate on Models live in ``registrasion/controllers``. + + +Testing +------- + +Functionality that lives in ``regsistrasion/controllers`` was developed in a test-driven fashion, which is sensible, given it's where most of the business logic for registrasion lives. If you're changing behaviour of a controller, either submit a test with your pull request, or modify an existing test. + + +Documentation +------------- + +Registrasion aims towards high-quality documentation, so that conference registration managers can understand how the system works, and so that webmasters working for conferences understand how the system fits together. Make sure that you have docstrings :) + +The documentation is written in Australian English: *-ise* and not *-ize*, *-our* and not *-or*; *vegemite* and not *peanut butter*, etc etc etc. From 86ac7bdd03662fc6d4be8bee77cb99e093c70413 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Apr 2016 16:43:32 +1000 Subject: [PATCH 142/418] =?UTF-8?q?Explains=20the=20name=20=E2=80=9Cregist?= =?UTF-8?q?ration=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.rst | 4 +++- docs/index.rst | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index be049b72..bd695934 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,9 @@ -============ Registrasion ============ +**Registra** (tion for Sympo) **sion**. A conference registration app for Django, +letting conferences big and small sell tickets from within Symposion. + Symposion --------- ``symposion`` is an Open Source conference management solution built with Pinax diff --git a/docs/index.rst b/docs/index.rst index 266fa463..e9b23305 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,8 @@ Registrasion ============ +Registra(tion for Sympo)sion. + Registrasion is a conference registration package that goes well with the Symposion suite of conference management apps for Django. It's designed to manage the sorts of inventories that large conferences need to manage, build up complex tickets with multiple items, and handle payments using whatever payment gateway you happen to have access to Development of registrasion was commenced by Christopher Neugebauer in 2016, with the gracious support of the Python Software Foundation. From 3b3744578ebcac4d0aca93f9477dc59f3480b237 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 22 Apr 2016 17:30:00 +1000 Subject: [PATCH 143/418] First pass at the payments documentation. --- docs/index.rst | 3 +- docs/payments.rst | 147 ++++++++++++++++++++++++++++++++ registrasion/models/commerce.py | 28 +++++- 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 docs/payments.rst diff --git a/docs/index.rst b/docs/index.rst index e9b23305..30fde1cc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,7 +10,7 @@ Registra(tion for Sympo)sion. Registrasion is a conference registration package that goes well with the Symposion suite of conference management apps for Django. It's designed to manage the sorts of inventories that large conferences need to manage, build up complex tickets with multiple items, and handle payments using whatever payment gateway you happen to have access to -Development of registrasion was commenced by Christopher Neugebauer in 2016, with the gracious support of the Python Software Foundation. +Development of registrasion was commenced by Christopher Neugebauer in 2016, with the generous support of the Python Software Foundation. Contents: @@ -20,6 +20,7 @@ Contents: overview inventory + payments for-zookeepr-users diff --git a/docs/payments.rst b/docs/payments.rst new file mode 100644 index 00000000..51ff01da --- /dev/null +++ b/docs/payments.rst @@ -0,0 +1,147 @@ +.. automodule:: registrasion.models.commerce + +Payments and Refunds +==================== + +Registrasion aims to support whatever payment platform you have available to use. Therefore, Registrasion uses a bare minimum payments model to track money within the system. It's the role of you, as a deployer of Registrasion, to implement a payment application that communicates with your own payment platform. + +Invoices may have multiple ``PaymentBase`` objects attached to them; each of these represent a payment against the invoice. Payments can be negative (and this represents a refund against the Invoice), however, this approach is not recommended for use by implementers. + +Registrasion also keeps track of money that is not currently attached to invoices through `credit notes`_. Credit notes may be applied to unpaid invoices *in full*, if there is an amount left over from the credit note, a new credit note will be created from that amount. Credit notes may also be released, at which point they're the responsibility of the payment application to create a refund. + +Finally, Registrasion provides a `manual payments`_ feature, which allows for staff members to manually report payments into the system. This is the only payment facility built into Registrasion, but it's not intended as a reference implementation. + + +Making payments +--------------- + +Making payments is a three-step process: + +#. Validate that the invoice is ready to be paid, +#. Create a payment object for the amount that you are paying towards an invoice, +#. Ask the invoice to calculate its status now that the payment has been made. + +Pre-validation +~~~~~~~~~~~~~~ +Registrasion's ``InvoiceController`` has a ``validate_allowed_to_pay`` method, which performs all of the pre-payment checks (is the invoice still unpaid and non-void? has the registration been amended?). + +If the pre-payment check fails, ``InvoiceController`` will raise a Django ``ValidationError``. + +Our the ``demopay`` view from the ``registrasion-demo`` project implements pre-validation like so:: + + from registrasion.controllers.invoice import InvoiceController + from django.core.exceptions import ValidationError + + # Get the Registrasion Invoice model + inv = get_object_or_404(rego.Invoice.objects, pk=invoice_id) + + invoice = InvoiceController(inv) + + try: + invoice.validate_allowed_to_pay() # Verify that we're allowed to do this. + except ValidationError as ve: + messages.error(request, ve.message) + return REDIRECT_TO_INVOICE # And display the validation message. + +In most cases, you don't engage your actual payment application until after pre-validation is finished, as this gives you an opportunity to bail out if the invoice isn't able to have funds applied to it. + +Applying payments +~~~~~~~~~~~~~~~~~ +Payments in Registrasion are represented as subclasses of the ``PaymentBase`` model. ``PaymentBase`` looks like this: + +.. autoclass :: PaymentBase + +When you implement your own payment application, you'll need to subclass ``PaymentBase``, as this will allow you to add metadata that lets you link the Registrasion payment object with your payment platform's object. + +Generally, the ``reference`` field should be something that lets your end-users identify the payment on their credit card statement, and to provide to your team's tech support in case something goes wrong. + +Once you've subclassed ``PaymentBase``, applying a payment is really quite simple. In the ``demopay`` view, we have a subclass called ``DemoPayment``:: + + invoice = InvoiceController(some_invoice_model) + + # Create the payment object + models.DemoPayment.objects.create( + invoice=invoice.invoice, + reference="Demo payment by user: " + request.user.username, + amount=invoice.invoice.value, + ) + +Note that multiple payments can be provided against an ``Invoice``, however, payments that exceed the total value of the invoice will have credit notes generated. + +Updating an invoice's status +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``InvoiceController`` has a method called ``update_status``. You should call ``update_status`` immediately after you create a ``PaymentBase`` object, as this keeps invoice and its payments synchronised:: + + invoice = InvoiceController(some_invoice_model) + invoice.update_status() + +Calling ``update_status`` collects the ``PaymentBase`` objects that are attached to the ``Invoice``, and will update the status of the invoice: + +* If an invoice is ``VOID``, it will remain void. +* If an invoice is ``UNPAID`` and it now has ``PaymentBase`` objects whose value meets or exceed's the invoice's value, the invoice becomes ``PAID``. +* If an invoice is ``UNPAID`` and it now has ``PaymentBase`` objects whose values sum to zero, the invoice becomes ``VOID``. +* If an invoice is ``PAID`` and it now has ``PaymentBase`` objects of less than the invoice's value, the invoice becomes ``REFUNDED``. + +When your invoice becomes ``PAID`` for the first time, if there's a cart of inventory items attached to it, that cart becomes permanently reserved -- that is, all of the items within it are no longer available for other users to purchase. If an invoice becomes ``REFUNDED``, the items in the cart are released, which means that they are available for anyone to purchase again. + +(One GitHub Issue #37 is completed) If you overpay an invoice, or pay into an invoice that should not have funds attached, a credit note for the residual payments will also be issued. + +In general, although this means you *can* use negative payments to take an invoice into a *REFUNDED* state, it's still much more sensible to use the credit notes facility, as this makes sure that any leftover funds remain tracked in the system. + + +Credit Notes +------------ + +When you refund an invoice, often you're doing so in order to make a minor amendment to items that the attendee has purchased. In order to make it easy to transfer funds from a refunded invoice to a new invoice, Registrasion provides an automatic credit note facility. + +Credit notes are created when you mark an invoice as refunded, but they're also created if you overpay an invoice, or if you direct money into an invoice that can no longer take payment. + +Once created, Credit Notes act as a payment that can be put towards other invoices, or that can be cashed out, back to the original payment platform. Credits can only be applied or cashed out in full. + +This means that it's easy to track funds that aren't accounted for by invoices -- it's just the sum of the credit notes that haven't been applied to new invoices, or haven't been cashed out. + +We recommend using credit notes to track all of your refunds for consistency; it also allows you to invoice for cancellation fees and the like. + +Creating credit notes +~~~~~~~~~~~~~~~~~~~~~ +In Registrasion, credit notes originate against invoices, and are represented as negative payments to an invoice. + +Credit notes are usually created automatically. In most cases, Credit Notes come about from asking to refund an invoice:: + + InvoiceController(invoice).refund() + +Calling ``refund()`` will generate a refund of all of the payments applied to that invoice. + +Otherwise, credit notes come about when invoices are overpaid, in this case, a credit for the overpay amount will be generated. + +Applying credits to new invoices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Credits can be applied to invoices:: + + CreditNoteController(credit_not).apply_to_invoice(invoice) + +This will result in an instance of ``CreditNoteApplication`` being applied as a payment to ``invoice``. ``CreditNoteApplication`` will always be a payment of the full amount of its parent credit note. If this payment overpays the invoice it's being applied to, a credit note for the residual will be generated. + +Refunding credit notes +~~~~~~~~~~~~~~~~~~~~~ +It is possible to release a credit note back to the original payment platform. To do so, you attach an instance of ``CreditNoteRefund`` to the original ``CreditNote``: + +.. autoclass :: CreditNoteRefund + +You'll usually want to make a subclass of ``CreditNoteRefund`` for your own purposes, usually so that you can tie Registrasion's internal representation of the refund to a concrete refund on the side of your payment platform. + +Note that you can only release a credit back to the payment platform for the full amount of the credit. + + +Manual payments +--------------- + +Registrasion provides a *manual payments* feature, which allows for staff members to manually report payments into the system. This is the only payment facility built into Registrasion, but it's not intended as a reference implementation. + +The main use case for manual payments is to record the receipt of funds from bank transfers or cheques sent on behalf of attendees. + +It's not intended as a reference implementation is because it does not perform validation of the cart before the payment is applied to the invoice. + +This means that it's possible for a staff member to apply a payment with a specific invoice reference into the invoice matching that reference. Registrasion will generate a credit note if the invoice is not able to receive payment (e.g. because it has since been voided), you can use that credit note to pay into a valid invoice if necessary. + +It is possible for staff to enter a negative amount on a manual payment. This will be treated as a refund. Generally, it's preferred to issue a credit note to an invoice rather than enter a negative amount manually. diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 5df01f49..27a228e0 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -180,7 +180,20 @@ class LineItem(models.Model): @python_2_unicode_compatible class PaymentBase(models.Model): ''' The base payment type for invoices. Payment apps should subclass this - class to handle implementation-specific issues. ''' + class to handle implementation-specific issues. + + Attributes: + invoice (inventory.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", ) @@ -280,7 +293,18 @@ class CreditNoteApplication(CleanOnSave, PaymentBase): 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. ''' + 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"): From ca8f67c2f3961101462e341085befc6af582d800 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 07:13:37 +1000 Subject: [PATCH 144/418] Adds for_id, which lets you get an InvoiceController or CreditNoteController by the ID of the invoice/credit note/. Closes #38. --- registrasion/controllers/credit_note.py | 6 +++++- registrasion/controllers/for_id.py | 24 ++++++++++++++++++++++++ registrasion/controllers/invoice.py | 4 +++- registrasion/tests/test_invoice.py | 14 ++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 registrasion/controllers/for_id.py diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index e1f0ed2b..182c10e9 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -2,8 +2,12 @@ from django.db import transaction from registrasion.models import commerce +from for_id import ForId -class CreditNoteController(object): + +class CreditNoteController(ForId, object): + + __MODEL__ = commerce.CreditNote def __init__(self, credit_note): self.credit_note = credit_note diff --git a/registrasion/controllers/for_id.py b/registrasion/controllers/for_id.py new file mode 100644 index 00000000..3748b151 --- /dev/null +++ b/registrasion/controllers/for_id.py @@ -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: + return Http404 diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index fff97e8d..d2b6bf3f 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -11,9 +11,11 @@ from registrasion.models import people from cart import CartController from credit_note import CreditNoteController +from for_id import ForId +class InvoiceController(ForId, object): -class InvoiceController(object): + __MODEL__ = commerce.Invoice def __init__(self, invoice): self.invoice = invoice diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 3a655bb1..8e1c7ac0 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -53,6 +53,20 @@ class InvoiceTestCase(RegistrationCartTestCase): self.PROD_1.price + self.PROD_2.price, invoice_2.invoice.value) + def test_invoice_controller_for_id_works(self): + current_cart = TestingCartController.for_user(self.USER_1) + current_cart.add_to_cart(self.PROD_1, 1) + + invoice = TestingInvoiceController.for_cart(current_cart.cart) + + 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, 01, 01, tzinfo=UTC)) From 9f72b67510ec06f2a3788bb6c56f249c8694e9ab Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 07:14:14 +1000 Subject: [PATCH 145/418] Uses for_id_or_404 in views.py --- registrasion/views.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index ea4acc8b..6a67a44b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -481,10 +481,7 @@ def invoice(request, invoice_id, access_code=None): access code. ''' - invoice_id = int(invoice_id) - inv = commerce.Invoice.objects.get(pk=invoice_id) - - current_invoice = InvoiceController(inv) + current_invoice = InvoiceController.for_id_or_404(invoice_id) if not current_invoice.can_view( user=request.user, @@ -508,9 +505,7 @@ def manual_payment(request, invoice_id): if not request.user.is_staff: raise Http404() - invoice_id = int(invoice_id) - inv = get_object_or_404(commerce.Invoice, pk=invoice_id) - current_invoice = InvoiceController(inv) + current_invoice = InvoiceController.for_id_or_404(invoice_id) form = forms.ManualPaymentForm( request.POST or None, @@ -539,9 +534,7 @@ def refund(request, invoice_id): if not request.user.is_staff: raise Http404() - invoice_id = int(invoice_id) - inv = get_object_or_404(commerce.Invoice, pk=invoice_id) - current_invoice = InvoiceController(inv) + current_invoice = InvoiceController.for_id_or_404(invoice_id) try: current_invoice.refund() @@ -560,10 +553,7 @@ def credit_note(request, note_id, access_code=None): if not request.user.is_staff: raise Http404() - note_id = int(note_id) - note = commerce.CreditNote.objects.get(pk=note_id) - - current_note = CreditNoteController(note) + current_note = CreditNoteController.for_id_or_404(note_id) apply_form = forms.ApplyCreditNoteForm( note.invoice.user, From 67b047e7b30e0b10306b4723771a85cf5592a436 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 07:15:03 +1000 Subject: [PATCH 146/418] Simplifies invoice-getting documentation. --- docs/payments.rst | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/payments.rst b/docs/payments.rst index 51ff01da..3a2d6262 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -32,10 +32,7 @@ Our the ``demopay`` view from the ``registrasion-demo`` project implements pre-v from registrasion.controllers.invoice import InvoiceController from django.core.exceptions import ValidationError - # Get the Registrasion Invoice model - inv = get_object_or_404(rego.Invoice.objects, pk=invoice_id) - - invoice = InvoiceController(inv) + invoice = InvoiceController.for_id_or_404(invoice.id) try: invoice.validate_allowed_to_pay() # Verify that we're allowed to do this. From 9a4574ef2c3b5e41cc8e45ee171be734bb0a11f2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 07:42:05 +1000 Subject: [PATCH 147/418] DRYs up test_invoice a bit --- registrasion/tests/test_invoice.py | 98 ++++++++---------------------- 1 file changed, 25 insertions(+), 73 deletions(-) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 8e1c7ac0..b64ae58c 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -18,6 +18,12 @@ UTC = pytz.timezone('UTC') class InvoiceTestCase(RegistrationCartTestCase): + 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 test_create_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -54,10 +60,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_2.invoice.value) def test_invoice_controller_for_id_works(self): - current_cart = TestingCartController.for_user(self.USER_1) - current_cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(current_cart.cart) + invoice = self._invoice_containing_prod_1(1) id_ = invoice.invoice.id @@ -82,10 +85,8 @@ class InvoiceTestCase(RegistrationCartTestCase): TestingInvoiceController.for_cart(current_cart.cart) def test_paying_invoice_makes_new_cart(self): - current_cart = TestingCartController.for_user(self.USER_1) - current_cart.add_to_cart(self.PROD_1, 1) + invoice = self._invoice_containing_prod_1(1) - invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A payment!", invoice.invoice.value) # This payment is for the correct amount invoice should be paid. @@ -96,7 +97,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Asking for a cart should generate a new one new_cart = TestingCartController.for_user(self.USER_1) - self.assertNotEqual(current_cart.cart, new_cart.cart) + self.assertNotEqual(invoice.invoice.cart, new_cart.cart) def test_invoice_includes_discounts(self): voucher = inventory.Voucher.objects.create( @@ -181,24 +182,16 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertFalse(invoice_2_new.invoice.is_void) def test_voiding_invoice_creates_new_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) + invoice_1 = self._invoice_containing_prod_1(1) self.assertFalse(invoice_1.invoice.is_void) invoice_1.void() - invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(invoice_1.invoice.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) def test_cannot_pay_void_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) + invoice_1 = self._invoice_containing_prod_1(1) invoice_1.void() @@ -206,11 +199,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.validate_allowed_to_pay() def test_cannot_void_paid_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 = TestingInvoiceController.for_cart(current_cart.cart) + invoice = self._invoice_containing_prod_1(1) invoice.pay("Reference", invoice.invoice.value) @@ -218,11 +207,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.void() def test_cannot_void_partially_paid_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 = TestingInvoiceController.for_cart(current_cart.cart) + invoice = self._invoice_containing_prod_1(1) invoice.pay("Reference", invoice.invoice.value - 1) self.assertTrue(invoice.invoice.is_unpaid) @@ -247,10 +232,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.validate_allowed_to_pay() def test_overpaid_invoice_results_in_credit_note(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) # Invoice is overpaid by 1 unit to_pay = invoice.invoice.value + 1 @@ -268,10 +250,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEqual(to_pay - invoice.invoice.value, credit_notes[0].value) def test_full_paid_invoice_does_not_generate_credit_note(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) # Invoice is paid evenly invoice.pay("Reference", invoice.invoice.value) @@ -287,10 +266,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEqual(0, credit_notes.count()) def test_refund_partially_paid_invoice_generates_correct_credit_note(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) # Invoice is underpaid by 1 unit to_pay = invoice.invoice.value - 1 @@ -309,10 +285,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEqual(to_pay, credit_notes[0].value) def test_refund_fully_paid_invoice_generates_correct_credit_note(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -332,10 +305,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEqual(to_pay, credit_notes[0].value) def test_apply_credit_note_pays_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)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -363,10 +333,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(0, commerce.CreditNote.unclaimed().count()) def test_apply_credit_note_generates_new_credit_note_if_overpaying(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 2) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(2) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -405,10 +372,7 @@ class InvoiceTestCase(RegistrationCartTestCase): ) def test_cannot_apply_credit_note_on_invalid_invoices(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -421,10 +385,7 @@ class InvoiceTestCase(RegistrationCartTestCase): cn = TestingCreditNoteController(credit_note) # Create a new cart with invoice, pay it - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice_2 = self._invoice_containing_prod_1(1) invoice_2.pay("LOL", invoice_2.invoice.value) # Cannot pay paid invoice @@ -437,20 +398,14 @@ class InvoiceTestCase(RegistrationCartTestCase): cn.apply_to_invoice(invoice_2.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)) + 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): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) @@ -479,10 +434,7 @@ class InvoiceTestCase(RegistrationCartTestCase): cn.apply_to_invoice(invoice_2.invoice) def test_cannot_refund_an_applied_credit_note(self): - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + invoice = self._invoice_containing_prod_1(1) to_pay = invoice.invoice.value invoice.pay("Reference", to_pay) From 12e04c248fb1969a0796a03e493e1a14569d18ec Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 08:13:44 +1000 Subject: [PATCH 148/418] Credit notes are now generated when invoices are overpaid, or invoices are paid into void or refunded invoices. Closes #37. --- registrasion/controllers/invoice.py | 17 +++++-- registrasion/tests/controller_helpers.py | 7 ++- registrasion/tests/test_invoice.py | 58 +++++++++++++++++++----- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index d2b6bf3f..ef4ee320 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -13,6 +13,7 @@ from cart import CartController from credit_note import CreditNoteController from for_id import ForId + class InvoiceController(ForId, object): __MODEL__ = commerce.Invoice @@ -195,11 +196,6 @@ class InvoiceController(ForId, object): # Invoice no longer has amount owing self._mark_paid() - if remainder < 0: - CreditNoteController.generate_from_invoice( - self.invoice, - 0 - remainder, - ) elif total_paid == 0 and num_payments > 0: # Invoice has multiple payments totalling zero self._mark_void() @@ -215,6 +211,17 @@ class InvoiceController(ForId, object): # 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) + def _mark_paid(self): ''' Marks the invoice as paid, and updates the attached cart if necessary. ''' diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index ac676c03..3ede49c2 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -34,11 +34,14 @@ class TestingCartController(CartController): class TestingInvoiceController(InvoiceController): - def pay(self, reference, amount): + def pay(self, reference, amount, pre_validate=True): ''' Testing method for simulating an invoice paymenht by the given amount. ''' - self.validate_allowed_to_pay() + 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.ManualPayment.objects.create( diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index b64ae58c..a7db2849 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -24,6 +24,10 @@ class InvoiceTestCase(RegistrationCartTestCase): return TestingInvoiceController.for_cart(self.reget(cart.cart)) + def _credit_note_for_invoice(self, invoice): + note = commerce.CreditNote.objects.get(invoice=invoice) + return TestingCreditNoteController(note) + def test_create_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -314,8 +318,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) # That credit note should be in the unclaimed pile self.assertEquals(1, commerce.CreditNote.unclaimed().count()) @@ -342,8 +345,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) self.assertEquals(1, commerce.CreditNote.unclaimed().count()) @@ -381,8 +383,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) # Create a new cart with invoice, pay it invoice_2 = self._invoice_containing_prod_1(1) @@ -415,9 +416,8 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) + cn = self._credit_note_for_invoice(invoice.invoice) - cn = TestingCreditNoteController(credit_note) cn.refund() # Refunding a credit note should mark it as claimed @@ -444,9 +444,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - credit_note = commerce.CreditNote.objects.get(invoice=invoice.invoice) - - cn = TestingCreditNoteController(credit_note) + cn = self._credit_note_for_invoice(invoice.invoice) # Create a new cart with invoice cart = TestingCartController.for_user(self.USER_1) @@ -460,3 +458,41 @@ class InvoiceTestCase(RegistrationCartTestCase): # 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) From 00b79a4beceadbe2c6df04d7ba32b570fccadc59 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 08:27:20 +1000 Subject: [PATCH 149/418] Documentation now reflects that issue #37 is solved. --- docs/payments.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/payments.rst b/docs/payments.rst index 3a2d6262..058b64bc 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -81,7 +81,7 @@ Calling ``update_status`` collects the ``PaymentBase`` objects that are attached When your invoice becomes ``PAID`` for the first time, if there's a cart of inventory items attached to it, that cart becomes permanently reserved -- that is, all of the items within it are no longer available for other users to purchase. If an invoice becomes ``REFUNDED``, the items in the cart are released, which means that they are available for anyone to purchase again. -(One GitHub Issue #37 is completed) If you overpay an invoice, or pay into an invoice that should not have funds attached, a credit note for the residual payments will also be issued. +If you overpay an invoice, or pay into an invoice that should not have funds attached, a credit note for the residual payments will also be issued. In general, although this means you *can* use negative payments to take an invoice into a *REFUNDED* state, it's still much more sensible to use the credit notes facility, as this makes sure that any leftover funds remain tracked in the system. From f309d92a24fc6f8ba6441ee5865d6ca3e0eae337 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 08:34:54 +1000 Subject: [PATCH 150/418] Discusses access control for payments --- docs/payments.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/payments.rst b/docs/payments.rst index 058b64bc..f671895b 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -12,6 +12,26 @@ Registrasion also keeps track of money that is not currently attached to invoice Finally, Registrasion provides a `manual payments`_ feature, which allows for staff members to manually report payments into the system. This is the only payment facility built into Registrasion, but it's not intended as a reference implementation. +Invoice and payment access control +---------------------------------- + +Conferences are interesting: usually you want attendees to fill in their own registration so that they get their catering options right, so that they can personally agree to codes of conduct, and so that you can make sure that you're communicating key information directly with them. + +On the other hand, employees at companies often need for their employers to directly pay for their registration. + +Registrasion solves this problem by having attendees complete their own registration, and then providing an access URL that allows anyone who holds that URL to view their invoice and make payment. + +You can call ``InvoiceController.can_view`` to determine whether or not you're allowed to show the invoice. It returns true if the user is allowed to view the invoice:: + + InvoiceController.can_view(self, user=request.user, access_code="CODE") + +As a rule, you should call ``can_view`` before doing any operations that amend the status of an invoice. This includes taking payments or requesting refunds. + +The access code is unique for each attendee -- this means that every invoice that an attendee generates can be viewed with the same access code. This is useful if the user amends their registration between giving the URL to their employer, and their employer making payment. + + + + Making payments --------------- From b0d1f69f1aa59b6e918728193515171770491322 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 09:49:19 +1000 Subject: [PATCH 151/418] Writes the integration guide --- docs/index.rst | 1 + docs/integration.rst | 37 +++++++++++++++++++++++++++++++++++ docs/payments.rst | 1 + registrasion/models/people.py | 17 ++++++++++++---- 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 docs/integration.rst diff --git a/docs/index.rst b/docs/index.rst index 30fde1cc..c860ceeb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Contents: :maxdepth: 2 overview + integration inventory payments for-zookeepr-users diff --git a/docs/integration.rst b/docs/integration.rst new file mode 100644 index 00000000..bdf7845b --- /dev/null +++ b/docs/integration.rst @@ -0,0 +1,37 @@ +Integrating Registrasion +======================== + +Registrasion is a Django app. It does not provide any templates -- you'll need to develop these yourself. You can use the ``registrasion-demo`` project as a starting point. + +To use Registrasion for your own conference, you'll need to do a small amount of development work, usually in your own Django App. + +The first is to define a model and form for your attendee profile, and the second is to implement a payment app. + + +Attendee profile +---------------- + +.. automodule:: registrasion.models.people + +Attendee profiles are where you ask for information such as what your attendee wants on their badge, and what the attendee's dietary and accessibility requirements are. + +Because every conference is different, Registrasion lets you define your own attendee profile model, and your own form for requesting this information. The only requirement is that you derive your model from ``AttendeeProfileBase``. + +.. autoclass :: AttendeeProfileBase + :members: name_field, invoice_recipient + +Once you've subclassed ``AttendeeProfileBase``, you'll need to implement a form that lets attendees fill out their profile. + +You specify how to find that form in your Django ``settings.py`` file:: + + ATTENDEE_PROFILE_FORM = "democon.forms.AttendeeProfileForm" + +The only contract is that this form creates an instance of ``AttendeeProfileBase`` when saved, and that it can take an instance of your subclass on creation (so that your attendees can edit their profile). + + +Payments +-------- + +Registrasion does not implement its own credit card processing. You'll need to do that yourself. Registrasion *does* provide a mechanism for recording cheques and direct deposits, if you do end up taking registrations that way. + +See :ref:`payments_and_refunds` for a guide on how to correctly implement payments. diff --git a/docs/payments.rst b/docs/payments.rst index f671895b..597b0e27 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -1,4 +1,5 @@ .. automodule:: registrasion.models.commerce +.. _payments_and_refunds: Payments and Refunds ==================== diff --git a/registrasion/models/people.py b/registrasion/models/people.py index bdd682ed..e36bd728 100644 --- a/registrasion/models/people.py +++ b/registrasion/models/people.py @@ -59,13 +59,22 @@ class AttendeeProfileBase(models.Model): @classmethod def name_field(cls): - ''' This is used to pre-fill the attendee's name from the - speaker profile. If it's None, that functionality is disabled. ''' + ''' + 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 invoice_recipient(self): - ''' Returns a representation of this attendee profile for the purpose - of rendering to an invoice. Override in subclasses. ''' + ''' + + 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) From d921860614c6f512b1b3fb7d5496fecda1423da0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 09:52:53 +1000 Subject: [PATCH 152/418] Updates for-zookeeper-users --- docs/for-zookeepr-users.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/for-zookeepr-users.rst b/docs/for-zookeepr-users.rst index b2cbce44..28cd1932 100644 --- a/docs/for-zookeepr-users.rst +++ b/docs/for-zookeepr-users.rst @@ -1,4 +1,3 @@ -================================ Registrasion for Zookeepr Keeprs ================================ @@ -16,11 +15,8 @@ Things that are the same Things that are different ------------------------- -* Ceilings can be used to apply discounts, so Early Bird ticket rates can be - implemented by applying a ceiling-type discount, rather than duplicating the - ticket type. +* Ceilings can be used to apply discounts, so Early Bird ticket rates can be implemented by applying a ceiling-type discount, rather than duplicating the ticket type. * Vouchers can be used to enable products * e.g. Sponsor tickets do not appear until you supply a sponsor's voucher * Items may be enabled by having other specific items present - * e.g. Extra accommodation nights do not appear until you purchase the main - week worth of accommodation. + * e.g. Extra accommodation nights do not appear until you purchase the main week worth of accommodation. From 32ffa258953c4ab9ae72e058063ccd46f37fdb36 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 09:57:36 +1000 Subject: [PATCH 153/418] Adds util.all_arguments_optional --- registrasion/util.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/registrasion/util.py b/registrasion/util.py index 3df54800..7179ceb5 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -14,3 +14,14 @@ def generate_access_code(): chars = string.uppercase + string.digits[1:] # 4 chars => 35 ** 4 = 1500625 (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 From 9c289acadda4c2e073993a2e334424c325e5539f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 10:15:13 +1000 Subject: [PATCH 154/418] Starts documenting the public views. --- docs/index.rst | 1 + docs/views.rst | 5 ++++ registrasion/models/conditions.py | 4 +-- registrasion/views.py | 50 ++++++++++++++++++++++++------- 4 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 docs/views.rst diff --git a/docs/index.rst b/docs/index.rst index c860ceeb..7e3ff951 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,7 @@ Contents: inventory payments for-zookeepr-users + views Indices and tables diff --git a/docs/views.rst b/docs/views.rst new file mode 100644 index 00000000..7e626e08 --- /dev/null +++ b/docs/views.rst @@ -0,0 +1,5 @@ +Public-facing views +=================== + +.. automodule:: registrasion.views + :members: diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index 6f259d90..2186ef1d 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -392,8 +392,8 @@ class ProductFlag(EnablingConditionBase): ''' The condition is met because a specific product is purchased. Attributes: - enabling_products ([inventory.Product, ...]): The products that cause this - condition to be met. + enabling_products ([inventory.Product, ...]): The products that cause + this condition to be met. ''' class Meta: diff --git a/registrasion/views.py b/registrasion/views.py index 6a67a44b..19d9292b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,6 +1,7 @@ import sys from registrasion import forms +from registrasion import util from registrasion.models import commerce from registrasion.models import inventory from registrasion.models import people @@ -24,7 +25,7 @@ from django.shortcuts import redirect from django.shortcuts import render -GuidedRegistrationSection = namedtuple( +_GuidedRegistrationSection = namedtuple( "GuidedRegistrationSection", ( "title", @@ -33,9 +34,26 @@ GuidedRegistrationSection = namedtuple( "form", ) ) -GuidedRegistrationSection.__new__.__defaults__ = ( - (None,) * len(GuidedRegistrationSection._fields) -) + + +@util.all_arguments_optional +class GuidedRegistrationSection(_GuidedRegistrationSection): + ''' Represents a section of a guided registration page. + + Attributes: + title (str): The title of the section. + + discounts ([registrasion.contollers.discount.DiscountAndQuantity, ...]): + A list of discount objects that are available in the section. You + can display ``.clause`` to show what the discount applies to, and + ``.quantity`` to display the number of times that discount can be + applied. + + description (str): A description of the section. + + form (forms.Form): A form to display. + ''' + pass def get_form(name): @@ -46,13 +64,25 @@ def get_form(name): @login_required -def guided_registration(request, page_id=0): - ''' Goes through the registration process in order, - making sure user sees all valid categories. +def guided_registration(request): + ''' Goes through the registration process in order, making sure user sees + all valid categories. + + The user must be logged in to see this view. + + Returns: + render: Renders ``registrasion/guided_registration.html``, + with the following data:: + + { + "current_step": int(), # The current step in the + # registration + "sections": sections, # A list of + # GuidedRegistrationSections + "title": str(), # The title of the page + "total_steps": int(), # The total number of steps + } - WORK IN PROGRESS: the finalised version of this view will allow - grouping of categories into a specific page. Currently, it just goes - through each category one by one ''' SESSION_KEY = "guided_registration_categories" From eefec32cb0fd94556bc4acdf51aae584c3ace1fc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 10:19:20 +1000 Subject: [PATCH 155/418] Makes private helper functions private --- registrasion/views.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 19d9292b..bc9705e4 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -120,8 +120,8 @@ def guided_registration(request): # Keep asking for the profile until everything passes. request.session[SESSION_KEY] = ASK_FOR_PROFILE - voucher_form, voucher_handled = handle_voucher(request, "voucher") - profile_form, profile_handled = handle_profile(request, "profile") + voucher_form, voucher_handled = _handle_voucher(request, "voucher") + profile_form, profile_handled = _handle_profile(request, "profile") voucher_section = GuidedRegistrationSection( title="Voucher Code", @@ -188,7 +188,7 @@ def guided_registration(request): ] prefix = "category_" + str(category.id) - p = handle_products(request, category, products, prefix) + p = _handle_products(request, category, products, prefix) products_form, discounts, products_handled = p section = GuidedRegistrationSection( @@ -231,7 +231,7 @@ def guided_registration(request): @login_required def edit_profile(request): - form, handled = handle_profile(request, "profile") + form, handled = _handle_profile(request, "profile") if handled and not form.errors: messages.success( @@ -246,7 +246,7 @@ def edit_profile(request): return render(request, "registrasion/profile_form.html", data) -def handle_profile(request, prefix): +def _handle_profile(request, prefix): ''' Returns a profile form instance, and a boolean which is true if the form was handled. ''' attendee = people.Attendee.get_instance(request.user) @@ -300,7 +300,7 @@ def product_category(request, category_id): # Handle the voucher form *before* listing products. # Products can change as vouchers are entered. - v = handle_voucher(request, VOUCHERS_FORM_PREFIX) + v = _handle_voucher(request, VOUCHERS_FORM_PREFIX) voucher_form, voucher_handled = v category_id = int(category_id) # Routing is [0-9]+ @@ -318,7 +318,7 @@ def product_category(request, category_id): ) return redirect("dashboard") - p = handle_products(request, category, products, PRODUCTS_FORM_PREFIX) + p = _handle_products(request, category, products, PRODUCTS_FORM_PREFIX) products_form, discounts, products_handled = p if request.POST and not voucher_handled and not products_form.errors: @@ -340,7 +340,7 @@ def product_category(request, category_id): return render(request, "registrasion/product_category.html", data) -def handle_products(request, category, products, prefix): +def _handle_products(request, category, products, prefix): ''' Handles a products list form in the given request. Returns the form instance, the discounts applicable to this form, and whether the contents were handled. ''' @@ -373,7 +373,7 @@ def handle_products(request, category, products, prefix): if request.method == "POST" and products_form.is_valid(): if products_form.has_changed(): - set_quantities_from_products_form(products_form, current_cart) + _set_quantities_from_products_form(products_form, current_cart) # If category is required, the user must have at least one # in an active+valid cart @@ -395,7 +395,7 @@ def handle_products(request, category, products, prefix): return products_form, discounts, handled -def set_quantities_from_products_form(products_form, current_cart): +def _set_quantities_from_products_form(products_form, current_cart): quantities = list(products_form.product_quantities()) @@ -425,7 +425,7 @@ def set_quantities_from_products_form(products_form, current_cart): products_form.add_error(field, message) -def handle_voucher(request, prefix): +def _handle_voucher(request, prefix): ''' Handles a voucher form in the given request. Returns the voucher form instance, and whether the voucher code was handled. ''' From 2d8602a6da312505411e070c7819db16942e0448 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 11:14:17 +1000 Subject: [PATCH 156/418] `views` documentation --- registrasion/views.py | 103 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index bc9705e4..bcb36eaf 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -231,6 +231,22 @@ def guided_registration(request): @login_required def edit_profile(request): + ''' View for editing an attendee's profile + + The user must be logged in to edit their profile. + + Returns: + redirect or render: + In the case of a ``POST`` request, it'll redirect to ``dashboard``, + or otherwise, it will render ``registrasion/profile_form.html`` + with data:: + + { + "form": form, # Instance of ATTENDEE_PROFILE_FORM. + } + + ''' + form, handled = _handle_profile(request, "profile") if handled and not form.errors: @@ -292,7 +308,28 @@ def _handle_profile(request, prefix): @login_required def product_category(request, category_id): - ''' Registration selections form for a specific category of items. + ''' Form for selecting products from an individual product category. + + Arguments: + category_id (castable to int): The id of the category to display. + + Returns: + redirect or render: + If the form has been sucessfully submitted, redirect to + ``dashboard``. Otherwise, render + ``registrasion/product_category.html`` with data:: + + { + "category": category, # An inventory.Category for + # category_id + "discounts": discounts, # A list of + # DiscountAndQuantity + "form": products_form, # A form for selecting + # products + "voucher_form": voucher_form, # A form for entering a + # voucher code + } + ''' PRODUCTS_FORM_PREFIX = "products" @@ -456,7 +493,26 @@ def _handle_voucher(request, prefix): @login_required def checkout(request): - ''' Runs checkout for the current cart of items, ideally generating an + ''' Runs the checkout process for the current cart. + + If the query string contains ``fix_errors=true``, Registrasion will attempt + to fix errors preventing the system from checking out, including by + cancelling expired discounts and vouchers, and removing any unavailable + products. + + Returns: + render or redirect: + If the invoice is generated successfully, or there's already a + valid invoice for the current cart, redirect to ``invoice``. + If there are errors when generating the invoice, render + ``registrasion/checkout_errors.html`` with the following data:: + + { + "error_list", [str, ...] # The errors to display. + } + + + Runs checkout for the current cart of items, ideally generating an invoice. ''' current_cart = CartController.for_user(request.user) @@ -467,12 +523,12 @@ def checkout(request): try: current_invoice = InvoiceController.for_cart(current_cart.cart) except ValidationError as ve: - return checkout_errors(request, ve) + return _checkout_errors(request, ve) return redirect("invoice", current_invoice.invoice.id) -def checkout_errors(request, errors): +def _checkout_errors(request, errors): error_list = [] for error in errors.error_list: @@ -489,7 +545,20 @@ def checkout_errors(request, errors): def invoice_access(request, access_code): ''' Redirects to the first unpaid invoice for the attendee that matches - the given access code, if any. ''' + the given access code, if any. + + Arguments: + + access_code (castable to int): The access code for the user whose + invoice you want to see. + + Returns: + redirect: + Redirect to the first unpaid invoice for that user. + + Raises: + Http404: If there is no such invoice. + ''' invoices = commerce.Invoice.objects.filter( user__attendee__access_code=access_code, @@ -505,10 +574,32 @@ def invoice_access(request, access_code): def invoice(request, invoice_id, access_code=None): - ''' Displays an invoice for a given invoice id. + ''' Displays an invoice. + This view is not authenticated, but it will only allow access to either: the user the invoice belongs to; staff; or a request made with the correct access code. + + Arguments: + + invoice_id (castable to int): The invoice_id for the invoice you want + to view. + + access_code (Optional[str]): The access code for the user who owns + this invoice. + + Returns: + render: + Renders ``registrasion/invoice.html``, with the following data:: + + { + "invoice": models.commerce.Invoice(), + } + + Raises: + Http404: if the current user cannot view this invoice and the correct + access_code is not provided. + ''' current_invoice = InvoiceController.for_id_or_404(invoice_id) From 4373b7260dae73c8ded2b0211beb7f24dd5241f2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 12:53:01 +1000 Subject: [PATCH 157/418] Finishes the public views documentation --- docs/views.rst | 2 ++ registrasion/views.py | 76 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/views.rst b/docs/views.rst index 7e626e08..c79abe5b 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -1,5 +1,7 @@ Public-facing views =================== +Here's all of the views that Registrasion exposes to the public. + .. automodule:: registrasion.views :members: diff --git a/registrasion/views.py b/registrasion/views.py index bcb36eaf..15f35d74 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -619,7 +619,28 @@ def invoice(request, invoice_id, access_code=None): @login_required def manual_payment(request, invoice_id): - ''' Allows staff to make manual payments or refunds on an invoice.''' + ''' Allows staff to make manual payments or refunds on an invoice. + + This form requires a login, and the logged in user needs to be staff. + + Arguments: + invoice_id (castable to int): The invoice ID to be paid + + Returns: + render: + Renders ``registrasion/manual_payment.html`` with the following + data:: + + { + "invoice": models.commerce.Invoice(), + "form": form, # A form that saves a ``ManualPayment`` + # object. + } + + Raises: + Http404: if the logged in user is not staff. + + ''' FORM_PREFIX = "manual_payment" @@ -649,8 +670,22 @@ def manual_payment(request, invoice_id): @login_required def refund(request, invoice_id): - ''' Allows staff to refund payments against an invoice and request a - credit note.''' + ''' Marks an invoice as refunded and requests a credit note for the + full amount paid against the invoice. + + This view requires a login, and the logged in user must be staff. + + Arguments: + invoice_id (castable to int): The ID of the invoice to refund. + + Returns: + redirect: + Redirects to ``invoice``. + + Raises: + Http404: if the logged in user is not staff. + + ''' if not request.user.is_staff: raise Http404() @@ -666,9 +701,36 @@ def refund(request, invoice_id): return redirect("invoice", invoice_id) +@login_required def credit_note(request, note_id, access_code=None): - ''' Displays an credit note for a given id. - This view can only be seen by staff. + ''' Displays a credit note. + + If ``request`` is a ``POST`` request, forms for applying or refunding + a credit note will be processed. + + This view requires a login, and the logged in user must be staff. + + Arguments: + note_id (castable to int): The ID of the credit note to view. + + Returns: + render or redirect: + If the "apply to invoice" form is correctly processed, redirect to + that invoice, otherwise, render ``registration/credit_note.html`` + with the following data:: + + { + "credit_note": models.commerce.CreditNote(), + "apply_form": form, # A form for applying credit note + # to an invoice. + "refund_form": form, # A form for applying a *manual* + # refund of the credit note. + } + + Raises: + Http404: If the logged in user is not staff. + + ''' if not request.user.is_staff: @@ -705,7 +767,9 @@ def credit_note(request, note_id, access_code=None): request, "Applied manual refund to credit note." ) - return redirect("invoice", invoice.id) + refund_form = forms.ManualCreditNoteRefundForm( + prefix="refund_note", + ) data = { "credit_note": current_note.credit_note, From 1b9f76823f6a11cd5db3dd6a413ebe5df3cad10d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 12:59:43 +1000 Subject: [PATCH 158/418] Etc --- registrasion/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 15f35d74..c71cdc9d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -511,9 +511,7 @@ def checkout(request): "error_list", [str, ...] # The errors to display. } - - Runs checkout for the current cart of items, ideally generating an - invoice. ''' + ''' current_cart = CartController.for_user(request.user) @@ -590,7 +588,8 @@ def invoice(request, invoice_id, access_code=None): Returns: render: - Renders ``registrasion/invoice.html``, with the following data:: + Renders ``registrasion/invoice.html``, with the following + data:: { "invoice": models.commerce.Invoice(), From 9d25725514cbac1db90c1a0bf833ce974aef4842 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 13:15:06 +1000 Subject: [PATCH 159/418] Documents the template tags --- docs/views.rst | 9 +++ .../templatetags/registrasion_tags.py | 77 ++++++++++++++++--- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/docs/views.rst b/docs/views.rst index c79abe5b..ea30f748 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -5,3 +5,12 @@ Here's all of the views that Registrasion exposes to the public. .. automodule:: registrasion.views :members: + + +Template tags +------------- + +Registrasion makes template tags available: + +.. automodule:: registrasion.templatetags.registrasion_tags + :members: diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index b3d3cba3..bb721708 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -8,18 +8,42 @@ from django.db.models import Sum register = template.Library() -ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) +_ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) + +class ProductAndQuantity(_ProductAndQuantity): + ''' Class that holds a product and a quantity. + + Attributes: + product (models.inventory.Product) + + quantity (int) + + ''' + pass @register.assignment_tag(takes_context=True) def available_categories(context): - ''' Returns all of the available product categories ''' + ''' 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(context.request.user) @register.assignment_tag(takes_context=True) def available_credit(context): - ''' Returns the amount of unclaimed credit available for this user. ''' + ''' 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=context.request.user, ) @@ -29,14 +53,23 @@ def available_credit(context): @register.assignment_tag(takes_context=True) def invoices(context): - ''' Returns all of the invoices that this user has. ''' - return commerce.Invoice.objects.filter(cart__user=context.request.user) + ''' + + Returns: + [models.commerce.Invoice, ...]: All of the current user's invoices. ''' + return commerce.Invoice.objects.filter(user=context.request.user) @register.assignment_tag(takes_context=True) def items_pending(context): - ''' Returns all of the items that this user has in their current cart, - and is awaiting payment. ''' + ''' 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. + + ''' all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, @@ -53,8 +86,17 @@ def items_pending(context): @register.assignment_tag(takes_context=True) def items_purchased(context, category=None): - ''' Returns all of the items that this user has purchased, optionally - from the given category. ''' + ''' 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. + + ''' all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, @@ -76,5 +118,20 @@ def items_purchased(context, category=None): @register.filter def multiply(value, arg): - ''' Multiplies value by arg ''' + ''' Multiplies value by arg. + + This is useful when displaying invoices, as it lets you multiply the + quantity by the unit value. + + Arguments: + + value (number) + + arg (number) + + Returns: + number: value * arg + + ''' + return value * arg From c0b0ae780d0730bb16fec8aa1a9038856cd17248 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 13:20:48 +1000 Subject: [PATCH 160/418] Removes confusingness from cart.py --- registrasion/controllers/cart.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index a43115de..0a10e5f8 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -311,7 +311,7 @@ class CartController(object): commerce.DiscountItem.objects.filter(cart=self.cart).delete() product_items = self.cart.productitem_set.all().select_related( - "product", "product__category", + "product", "product__category", "product__price" ) products = [i.product for i in product_items] @@ -319,9 +319,6 @@ class CartController(object): # The highest-value discounts will apply to the highest-value # products first. - product_items = self.cart.productitem_set.all() - product_items = product_items.select_related("product") - product_items = product_items.order_by('product__price') product_items = reversed(product_items) for item in product_items: self._add_discount(item.product, item.quantity, discounts) From 213c11ac110161a478877e624aaa57ce98c8de86 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 13:24:57 +1000 Subject: [PATCH 161/418] Removes sphinx warnings --- docs/index.rst | 3 ++- docs/payments.rst | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 7e3ff951..75d0c4fa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,5 +30,6 @@ Indices and tables ================== * :ref:`genindex` -.. * :ref:`modindex` * :ref:`search` + +.. * :ref:`modindex` diff --git a/docs/payments.rst b/docs/payments.rst index 597b0e27..d0105afb 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -141,7 +141,7 @@ Credits can be applied to invoices:: This will result in an instance of ``CreditNoteApplication`` being applied as a payment to ``invoice``. ``CreditNoteApplication`` will always be a payment of the full amount of its parent credit note. If this payment overpays the invoice it's being applied to, a credit note for the residual will be generated. Refunding credit notes -~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~ It is possible to release a credit note back to the original payment platform. To do so, you attach an instance of ``CreditNoteRefund`` to the original ``CreditNote``: .. autoclass :: CreditNoteRefund From 63dfd353c16e031ca6622e9e2fb10dc4c13f628c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 14:31:25 +1000 Subject: [PATCH 162/418] Replaces active/released flags in Cart with a single int flag. Closes #41 --- registrasion/controllers/cart.py | 11 ++++--- registrasion/controllers/category.py | 3 +- registrasion/controllers/conditions.py | 8 +++-- registrasion/controllers/discount.py | 3 +- registrasion/controllers/invoice.py | 5 ++- registrasion/controllers/product.py | 3 +- .../migrations/0023_auto_20160425_0409.py | 19 +++++++++++ .../migrations/0024_auto_20160425_0410.py | 33 +++++++++++++++++++ .../migrations/0025_auto_20160425_0411.py | 20 +++++++++++ registrasion/models/commerce.py | 29 +++++++++------- .../templatetags/registrasion_tags.py | 5 ++- registrasion/tests/controller_helpers.py | 5 +-- registrasion/tests/test_cart.py | 16 ++------- registrasion/tests/test_ceilings.py | 12 +++---- registrasion/tests/test_discount.py | 20 ++++------- registrasion/tests/test_flag.py | 14 +++----- registrasion/tests/test_invoice.py | 7 ++-- registrasion/tests/test_refund.py | 12 +++++-- registrasion/tests/test_voucher.py | 7 ++-- 19 files changed, 147 insertions(+), 85 deletions(-) create mode 100644 registrasion/migrations/0023_auto_20160425_0409.py create mode 100644 registrasion/migrations/0024_auto_20160425_0410.py create mode 100644 registrasion/migrations/0025_auto_20160425_0411.py diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 0a10e5f8..89e660d6 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -30,14 +30,16 @@ class CartController(object): if there isn't one ready yet. ''' try: - existing = commerce.Cart.objects.get(user=user, active=True) + 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(), - ) - existing.save() + ) return cls(existing) def extend_reservation(self): @@ -367,11 +369,10 @@ class CartController(object): 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 - - discount_item.save() diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 94040b5f..a986eea7 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -46,8 +46,7 @@ class CategoryController(object): carts = commerce.Cart.objects.filter( user=user, - active=False, - released=False, + status=commerce.Cart.STATUS_PAID, ) items = commerce.ProductItem.objects.filter( diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index e96b6590..db40d0c2 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -201,7 +201,8 @@ class CategoryConditionController(ConditionController): ''' returns True if the user has a product from a category that invokes this condition in one of their carts ''' - carts = commerce.Cart.objects.filter(user=user, released=False) + carts = commerce.Cart.objects.filter(user=user) + carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) enabling_products = inventory.Product.objects.filter( category=self.condition.enabling_category, ) @@ -223,7 +224,8 @@ class ProductConditionController(ConditionController): ''' returns True if the user has a product that invokes this condition in one of their carts ''' - carts = commerce.Cart.objects.filter(user=user, released=False) + carts = commerce.Cart.objects.filter(user=user) + carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) products_count = commerce.ProductItem.objects.filter( cart__in=carts, product__in=self.condition.enabling_products.all(), @@ -272,7 +274,7 @@ class TimeOrStockLimitConditionController(ConditionController): reserved_carts = commerce.Cart.reserved_carts() reserved_carts = reserved_carts.exclude( user=user, - active=True, + status=commerce.Cart.STATUS_ACTIVE, ) items = self._items() diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 8b11c36a..6b9b7582 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -71,8 +71,7 @@ def available_discounts(user, categories, products): # is not available any more. past_uses = commerce.DiscountItem.objects.filter( cart__user=user, - cart__active=False, # Only past carts count - cart__released=False, # You can reuse refunded discounts + cart__status=commerce.Cart.STATUS_PAID, # Only past carts count discount=real_discount, ) agg = past_uses.aggregate(Sum("quantity")) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index ef4ee320..62bab0b6 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -227,7 +227,7 @@ class InvoiceController(ForId, object): necessary. ''' cart = self.invoice.cart if cart: - cart.active = False + cart.status = commerce.Cart.STATUS_PAID cart.save() self.invoice.status = commerce.Invoice.STATUS_PAID self.invoice.save() @@ -237,8 +237,7 @@ class InvoiceController(ForId, object): necessary. ''' cart = self.invoice.cart if cart: - cart.active = False - cart.released = True + cart.status = commerce.Cart.STATUS_RELEASED cart.save() self.invoice.status = commerce.Invoice.STATUS_REFUNDED self.invoice.save() diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index c6f8370c..99618ded 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -68,8 +68,7 @@ class ProductController(object): carts = commerce.Cart.objects.filter( user=user, - active=False, - released=False, + status=commerce.Cart.STATUS_PAID, ) items = commerce.ProductItem.objects.filter( diff --git a/registrasion/migrations/0023_auto_20160425_0409.py b/registrasion/migrations/0023_auto_20160425_0409.py new file mode 100644 index 00000000..234e9cda --- /dev/null +++ b/registrasion/migrations/0023_auto_20160425_0409.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-25 04:09 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0022_merge'), + ] + + operations = [ + migrations.AlterIndexTogether( + name='cart', + index_together=set([]), + ), + ] diff --git a/registrasion/migrations/0024_auto_20160425_0410.py b/registrasion/migrations/0024_auto_20160425_0410.py new file mode 100644 index 00000000..5cf7cdbc --- /dev/null +++ b/registrasion/migrations/0024_auto_20160425_0410.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-25 04:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0023_auto_20160425_0409'), + ] + + operations = [ + migrations.AddField( + model_name='cart', + name='status', + field=models.IntegerField(choices=[(1, 'Active'), (2, 'Paid'), (3, 'Released')], db_index=True, default=1), + preserve_default=False, + ), + migrations.RemoveField( + model_name='cart', + name='active', + ), + migrations.RemoveField( + model_name='cart', + name='released', + ), + migrations.AlterIndexTogether( + name='cart', + index_together=set([('status', 'user'), ('status', 'time_last_updated')]), + ), + ] diff --git a/registrasion/migrations/0025_auto_20160425_0411.py b/registrasion/migrations/0025_auto_20160425_0411.py new file mode 100644 index 00000000..aa084246 --- /dev/null +++ b/registrasion/migrations/0025_auto_20160425_0411.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-25 04:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0024_auto_20160425_0410'), + ] + + operations = [ + migrations.AlterField( + model_name='cart', + name='status', + field=models.IntegerField(choices=[(1, 'Active'), (2, 'Paid'), (3, 'Released')], db_index=True, default=1), + ), + ] diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 27a228e0..cea8cc11 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -21,15 +21,23 @@ class Cart(models.Model): class Meta: app_label = "registrasion" index_together = [ - ("active", "time_last_updated"), - ("active", "released"), - ("active", "user"), - ("released", "user"), + ("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) @@ -38,24 +46,21 @@ class Cart(models.Model): ) reservation_duration = models.DurationField() revision = models.PositiveIntegerField(default=1) - active = models.BooleanField( - default=True, + status = models.IntegerField( + choices=STATUS_TYPES, db_index=True, + default=STATUS_ACTIVE, ) - released = models.BooleanField( - default=False, - db_index=True - ) # Refunds etc @classmethod def reserved_carts(cls): ''' Gets all carts that are 'reserved' ''' return Cart.objects.filter( - (Q(active=True) & + (Q(status=Cart.STATUS_ACTIVE) & Q(time_last_updated__gt=( timezone.now()-F('reservation_duration') ))) | - (Q(active=False) & Q(released=False)) + Q(status=Cart.STATUS_PAID) ) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index bb721708..bd31d1f4 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -73,7 +73,7 @@ def items_pending(context): all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, - cart__active=True, + cart__status=commerce.Cart.STATUS_ACTIVE, ).select_related( "product", "product__category", @@ -100,8 +100,7 @@ def items_purchased(context, category=None): all_items = commerce.ProductItem.objects.filter( cart__user=context.request.user, - cart__active=False, - cart__released=False, + cart__status=commerce.Cart.STATUS_PAID, ).select_related("product", "product__category") if category: diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 3ede49c2..1022ffe8 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -28,8 +28,9 @@ class TestingCartController(CartController): self.set_quantity(product, old_quantity + quantity) def next_cart(self): - self.cart.active = False - self.cart.save() + if self.cart.status == commerce.Cart.STATUS_ACTIVE: + self.cart.status = commerce.Cart.STATUS_PAID + self.cart.save() class TestingInvoiceController(InvoiceController): diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 0f5565e5..c60b7551 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -40,17 +40,13 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): password='top_secret') attendee1 = people.Attendee.get_instance(cls.USER_1) - attendee1.save() profile1 = people.AttendeeProfileBase.objects.create( attendee=attendee1, ) - profile1.save() attendee2 = people.Attendee.get_instance(cls.USER_2) - attendee2.save() profile2 = people.AttendeeProfileBase.objects.create( attendee=attendee2, ) - profile2.save() cls.RESERVATION = datetime.timedelta(hours=1) @@ -63,7 +59,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): render_type=inventory.Category.RENDER_TYPE_RADIO, required=False, ) - cat.save() cls.categories.append(cat) cls.CAT_1 = cls.categories[0] @@ -80,7 +75,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): limit_per_user=10, order=1, ) - prod.save() cls.products.append(prod) cls.PROD_1 = cls.products[0] @@ -91,7 +85,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_4.price = Decimal("5.00") cls.PROD_4.save() - # Burn through some carts -- this made some past EC tests fail + # Burn through some carts -- this made some past flag tests fail current_cart = TestingCartController.for_user(cls.USER_1) current_cart.next_cart() @@ -109,9 +103,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): start_time=start_time, end_time=end_time ) - limit_ceiling.save() limit_ceiling.products.add(cls.PROD_1, cls.PROD_2) - limit_ceiling.save() @classmethod def make_category_ceiling( @@ -123,9 +115,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): start_time=start_time, end_time=end_time ) - limit_ceiling.save() limit_ceiling.categories.add(cls.CAT_1) - limit_ceiling.save() @classmethod def make_discount_ceiling( @@ -137,13 +127,12 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): end_time=end_time, limit=limit, ) - limit_ceiling.save() conditions.DiscountForProduct.objects.create( discount=limit_ceiling, product=cls.PROD_1, percentage=percentage, quantity=10, - ).save() + ) @classmethod def new_voucher(self, code="VOUCHER", limit=1): @@ -152,7 +141,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): code=code, limit=limit, ) - voucher.save() return voucher @classmethod diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index d3841ca8..877556d0 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase +from registrasion.models import commerce from registrasion.models import conditions UTC = pytz.timezone('UTC') @@ -146,8 +147,8 @@ class CeilingsTestCases(RegistrationCartTestCase): with self.assertRaises(ValidationError): second_cart.add_to_cart(self.PROD_1, 1) - first_cart.cart.released = True - first_cart.next_cart() + first_cart.cart.status = commerce.Cart.STATUS_RELEASED + first_cart.cart.save() second_cart.add_to_cart(self.PROD_1, 1) @@ -159,13 +160,12 @@ class CeilingsTestCases(RegistrationCartTestCase): description="VOUCHER RECIPIENT", voucher=voucher, ) - discount.save() conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=100, quantity=1 - ).save() + ) # Buy two of PROD_1, in separate carts: cart = TestingCartController.for_user(self.USER_1) @@ -173,7 +173,7 @@ class CeilingsTestCases(RegistrationCartTestCase): # and not the ceiling discount. cart.apply_voucher("VOUCHER") cart.add_to_cart(self.PROD_1, 1) - self.assertEqual(1, len(cart.cart.discountitem_set.all())) + self.assertEqual(1, cart.cart.discountitem_set.count()) cart.next_cart() @@ -181,4 +181,4 @@ class CeilingsTestCases(RegistrationCartTestCase): # ceiling discount cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) - self.assertEqual(1, len(cart.cart.discountitem_set.all())) + self.assertEqual(1, cart.cart.discountitem_set.count()) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 3229a381..71d09d97 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -2,6 +2,7 @@ import pytz from decimal import Decimal +from registrasion.models import commerce from registrasion.models import conditions from registrasion.controllers import discount from controller_helpers import TestingCartController @@ -22,15 +23,13 @@ class DiscountTestCase(RegistrationCartTestCase): discount = conditions.IncludedProductDiscount.objects.create( description="PROD_1 includes PROD_2 " + str(amount) + "%", ) - discount.save() discount.enabling_products.add(cls.PROD_1) - discount.save() conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_2, percentage=amount, quantity=quantity, - ).save() + ) return discount @classmethod @@ -42,15 +41,13 @@ class DiscountTestCase(RegistrationCartTestCase): discount = conditions.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) - discount.save() discount.enabling_products.add(cls.PROD_1) - discount.save() conditions.DiscountForCategory.objects.create( discount=discount, category=cls.CAT_2, percentage=amount, quantity=quantity, - ).save() + ) return discount @classmethod @@ -63,21 +60,19 @@ class DiscountTestCase(RegistrationCartTestCase): description="PROD_1 includes PROD_3 and PROD_4 " + str(amount) + "%", ) - discount.save() discount.enabling_products.add(cls.PROD_1) - discount.save() conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_3, percentage=amount, quantity=quantity, - ).save() + ) conditions.DiscountForProduct.objects.create( discount=discount, product=cls.PROD_4, percentage=amount, quantity=quantity, - ).save() + ) return discount def test_discount_is_applied(self): @@ -386,7 +381,6 @@ class DiscountTestCase(RegistrationCartTestCase): ) self.assertEqual(1, len(discounts)) - cart.cart.active = False # Keep discount enabled cart.next_cart() cart = TestingCartController.for_user(self.USER_1) @@ -401,8 +395,8 @@ class DiscountTestCase(RegistrationCartTestCase): ) self.assertEqual(0, len(discounts)) - cart.cart.released = True - cart.next_cart() + cart.cart.status = commerce.Cart.STATUS_RELEASED + cart.cart.save() discounts = discount.available_discounts( self.USER_1, diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index 1c03c6c8..094ac1a4 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -23,10 +23,8 @@ class FlagTestCases(RegistrationCartTestCase): description="Product condition", condition=condition, ) - flag.save() flag.products.add(cls.PROD_1) flag.enabling_products.add(cls.PROD_2) - flag.save() @classmethod def add_product_flag_on_category( @@ -39,10 +37,8 @@ class FlagTestCases(RegistrationCartTestCase): description="Product condition", condition=condition, ) - flag.save() flag.categories.add(cls.CAT_1) flag.enabling_products.add(cls.PROD_3) - flag.save() def add_category_flag(cls, condition=conditions.FlagBase.ENABLE_IF_TRUE): ''' Adds a category flag condition: adding PROD_1 to a cart is @@ -52,9 +48,7 @@ class FlagTestCases(RegistrationCartTestCase): condition=condition, enabling_category=cls.CAT_2, ) - flag.save() flag.products.add(cls.PROD_1) - flag.save() def test_product_flag_enables_product(self): self.add_product_flag() @@ -265,8 +259,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_1, 1) cart_2.set_quantity(self.PROD_1, 0) - cart.cart.released = True - cart.next_cart() + cart.cart.status = commerce.Cart.STATUS_RELEASED + cart.cart.save() with self.assertRaises(ValidationError): cart_2.set_quantity(self.PROD_1, 1) @@ -283,8 +277,8 @@ class FlagTestCases(RegistrationCartTestCase): cart_2.add_to_cart(self.PROD_1, 1) cart_2.set_quantity(self.PROD_1, 0) - cart.cart.released = True - cart.next_cart() + cart.cart.status = commerce.Cart.STATUS_RELEASED + cart.cart.save() with self.assertRaises(ValidationError): cart_2.set_quantity(self.PROD_1, 1) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index a7db2849..1b687c0c 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -97,7 +97,10 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertTrue(invoice.invoice.is_paid) # Cart should not be active - self.assertFalse(invoice.invoice.cart.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) @@ -482,7 +485,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index fbed1f32..dde1fa30 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -5,6 +5,8 @@ from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase +from registrasion.models import commerce + UTC = pytz.timezone('UTC') @@ -21,10 +23,16 @@ class RefundTestCase(RegistrationCartTestCase): self.assertFalse(invoice.invoice.is_void) self.assertTrue(invoice.invoice.is_paid) self.assertFalse(invoice.invoice.is_refunded) - self.assertFalse(invoice.invoice.cart.released) + 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.assertTrue(invoice.invoice.cart.released) + self.assertEqual( + commerce.Cart.STATUS_RELEASED, + invoice.invoice.cart.status, + ) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 46b2270a..f837a480 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -4,6 +4,7 @@ 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 @@ -64,9 +65,7 @@ class VoucherTestCases(RegistrationCartTestCase): voucher=voucher, condition=conditions.FlagBase.ENABLE_IF_TRUE, ) - flag.save() flag.products.add(self.PROD_1) - flag.save() # Adding the product without a voucher will not work current_cart = TestingCartController.for_user(self.USER_1) @@ -84,13 +83,12 @@ class VoucherTestCases(RegistrationCartTestCase): description="VOUCHER RECIPIENT", voucher=voucher, ) - discount.save() conditions.DiscountForProduct.objects.create( discount=discount, product=self.PROD_1, percentage=Decimal(100), quantity=1 - ).save() + ) # Having PROD_1 in place should add a discount current_cart = TestingCartController.for_user(self.USER_1) @@ -98,6 +96,7 @@ class VoucherTestCases(RegistrationCartTestCase): 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): From 397ba207bbda70d6bc0b6ac859d86d85e53763e4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 15:32:59 +1000 Subject: [PATCH 163/418] =?UTF-8?q?Adds=20utility=20to=20defeat=20segfault?= =?UTF-8?q?s=20in=20tests.=20Hopefully=20you=20won=E2=80=99t=20need=20it.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/tests/test_cart.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index c60b7551..bf781bb1 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -5,6 +5,7 @@ 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 @@ -24,6 +25,16 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): def setUp(self): super(RegistrationCartTestCase, self).setUp() + def tearDown(self): + if False: + # 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): From bf053242deb8ba1ba58983995aeb2335c55f2aa0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 16:00:33 +1000 Subject: [PATCH 164/418] =?UTF-8?q?Closes=20#25=20=E2=80=94=20changes=20wh?= =?UTF-8?q?at=20invoice=5Faccess=20will=20redirect=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/views.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index c71cdc9d..0d9dba23 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -542,8 +542,14 @@ def _checkout_errors(request, errors): def invoice_access(request, access_code): - ''' Redirects to the first unpaid invoice for the attendee that matches - the given access code, if any. + ''' Redirects to an invoice for the attendee that matches the given access + code, if any. + + If the attendee has multiple invoices, we use the following tie-break: + + - If there's an unpaid invoice, show that, otherwise + - If there's a paid invoice, show the most recent one, otherwise + - Show the most recent invoid of all Arguments: @@ -552,21 +558,29 @@ def invoice_access(request, access_code): Returns: redirect: - Redirect to the first unpaid invoice for that user. + Redirect to the selected invoice for that user. Raises: - Http404: If there is no such invoice. + Http404: If the user has no invoices. ''' invoices = commerce.Invoice.objects.filter( user__attendee__access_code=access_code, - status=commerce.Invoice.STATUS_UNPAID, - ).order_by("issue_time") + ).order_by("-issue_time") + if not invoices: raise Http404() - invoice = invoices[0] + unpaid = invoices.filter(status=commerce.Invoice.STATUS_UNPAID) + paid = invoices.filter(status=commerce.Invoice.STATUS_PAID) + + if unpaid: + invoice = unpaid[0] # (should only be 1 unpaid invoice?) + elif paid: + invoice = paid[0] # Most recent paid invoice + else: + invoice = invoices[0] # Most recent of any invoices return redirect("invoice", invoice.id, access_code) From 42912519f1e37ae8910368ab61d3b4649499bc62 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 16:06:29 +1000 Subject: [PATCH 165/418] Adds entered_by to manual payments. Closes #22. --- .../0026_manualpayment_entered_by.py | 24 +++++++++++++++++++ registrasion/models/commerce.py | 2 ++ registrasion/views.py | 1 + 3 files changed, 27 insertions(+) create mode 100644 registrasion/migrations/0026_manualpayment_entered_by.py diff --git a/registrasion/migrations/0026_manualpayment_entered_by.py b/registrasion/migrations/0026_manualpayment_entered_by.py new file mode 100644 index 00000000..0e068b17 --- /dev/null +++ b/registrasion/migrations/0026_manualpayment_entered_by.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-25 06:05 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('registrasion', '0025_auto_20160425_0411'), + ] + + operations = [ + migrations.AddField( + model_name='manualpayment', + name='entered_by', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index cea8cc11..7e86acf2 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -220,6 +220,8 @@ class ManualPayment(PaymentBase): 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 diff --git a/registrasion/views.py b/registrasion/views.py index 0d9dba23..1d357d17 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -669,6 +669,7 @@ def manual_payment(request, invoice_id): if request.POST and form.is_valid(): form.instance.invoice = inv + form.instance.entered_by = request.user form.save() current_invoice.update_status() form = forms.ManualPaymentForm(prefix=FORM_PREFIX) From 00f87e30b7b36043eef82fe8a5c7837c5f706bb1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 16:16:22 +1000 Subject: [PATCH 166/418] =?UTF-8?q?Adds=20an=20upper=20limit=20on=20quanti?= =?UTF-8?q?ty=20boxes=20(it=E2=80=99s=20set=20to=20500=20for=20the=20momen?= =?UTF-8?q?t=20though).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #19. --- registrasion/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 1037d229..14fa3ec0 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -100,6 +100,7 @@ class _QuantityBoxProductsForm(_ProductsForm): 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 From 2afa6a8d79eba69ce6fd5cc4e0efae5f2e6e8002 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 16:16:41 +1000 Subject: [PATCH 167/418] =?UTF-8?q?Adds=20=E2=80=9CNO=20SELECTION=E2=80=9D?= =?UTF-8?q?=20to=20radio=20buttons=20form.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #32. --- registrasion/forms.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 14fa3ec0..68302f56 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -133,6 +133,9 @@ class _RadioButtonProductsForm(_ProductsForm): 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, @@ -156,6 +159,8 @@ class _RadioButtonProductsForm(_ProductsForm): 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, From e2687cfa6fb1d24d286693e838d429a335cf5f14 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 16:45:42 +1000 Subject: [PATCH 168/418] Stops testing using ManualPayment, and just uses PaymentBase instead --- registrasion/tests/controller_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 1022ffe8..1684ea75 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -45,7 +45,7 @@ class TestingInvoiceController(InvoiceController): self.validate_allowed_to_pay() ''' Adds a payment ''' - commerce.ManualPayment.objects.create( + commerce.PaymentBase.objects.create( invoice=self.invoice, reference=reference, amount=amount, From a69d3f051e6b767a72b6752b7d52a969f2bb44d4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 17:13:11 +1000 Subject: [PATCH 169/418] Makes cart amendment methods fail if the cart is no longer active. Closes #16 --- registrasion/controllers/cart.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 89e660d6..97e17594 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,6 +1,7 @@ import collections import datetime import discount +import functools import itertools from django.core.exceptions import ObjectDoesNotExist @@ -19,6 +20,18 @@ from conditions import ConditionController from product import ProductController +def _modifies_cart(func): + ''' Decorator that makes the wrapped function raise ValidationError + if we're doing something that could modify the cart. ''' + + @functools.wraps(func) + def inner(self, *a, **k): + self._fail_if_cart_is_not_active() + return func(self, *a, **k) + + return inner + + class CartController(object): def __init__(self, cart): @@ -42,6 +55,12 @@ class CartController(object): ) 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.") + + @_modifies_cart def extend_reservation(self): ''' Updates the cart's time last updated value, which is used to determine whether the cart has reserved the items and discounts it @@ -64,6 +83,7 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) + @_modifies_cart def end_batch(self): ''' Performs operations that occur occur at the end of a batch of product changes/voucher applications etc. @@ -76,6 +96,7 @@ class CartController(object): self.cart.revision += 1 self.cart.save() + @_modifies_cart @transaction.atomic def set_quantities(self, product_quantities): ''' Sets the quantities on each of the products on each of the @@ -176,6 +197,7 @@ class CartController(object): if errors: raise CartValidationError(errors) + @_modifies_cart def apply_voucher(self, voucher_code): ''' Applies the voucher with the given code to this cart. ''' @@ -272,6 +294,7 @@ class CartController(object): if errors: raise ValidationError(errors) + @_modifies_cart @transaction.atomic def fix_simple_errors(self): ''' This attempts to fix the easy errors raised by ValidationError. @@ -304,6 +327,7 @@ class CartController(object): self.set_quantities(zeros) + @_modifies_cart @transaction.atomic def recalculate_discounts(self): ''' Calculates all of the discounts available for this product. From b709da97f1bf4125d90b6c0c0ac06ae60f216464 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 17:13:47 +1000 Subject: [PATCH 170/418] Checks that required category constraints are met before letting you check out your cart. Closes #35 --- registrasion/controllers/cart.py | 39 ++++++++++++++++++++++++++++-- registrasion/tests/test_invoice.py | 34 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 97e17594..f8cbd0f5 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -251,6 +251,37 @@ class CartController(object): if errors: raise(ValidationError(ve)) + 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: + print item + 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: + print error.message + 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 ''' @@ -270,8 +301,12 @@ class CartController(object): try: self._test_limits(product_quantities) except ValidationError as ve: - for error in ve.error_list: - errors.append(error.message[1]) + self._append_errors(errors, ve) + + try: + self._test_required_categories() + except ValidationError as ve: + self._append_errors(errors, ve) # Validate the discounts discount_items = commerce.DiscountItem.objects.filter(cart=cart) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 1b687c0c..9e5b55cf 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -499,3 +499,37 @@ class InvoiceTestCase(RegistrationCartTestCase): 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_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) From a2fa1d6548add72409ba577f86663d1bbc873dcf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 17:33:16 +1000 Subject: [PATCH 171/418] Fixes a bunch of variable errors, and adds user_passes_test --- registrasion/views.py | 38 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 1d357d17..19417b55 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,7 @@ from collections import namedtuple from django.conf import settings from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import user_passes_test from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError @@ -630,7 +631,11 @@ def invoice(request, invoice_id, access_code=None): return render(request, "registrasion/invoice.html", data) -@login_required +def _staff_only(user): + ''' Returns true if the user is staff. ''' + return user.is_staff + +@user_passes_test(_staff_only) def manual_payment(request, invoice_id): ''' Allows staff to make manual payments or refunds on an invoice. @@ -650,16 +655,10 @@ def manual_payment(request, invoice_id): # object. } - Raises: - Http404: if the logged in user is not staff. - ''' FORM_PREFIX = "manual_payment" - if not request.user.is_staff: - raise Http404() - current_invoice = InvoiceController.for_id_or_404(invoice_id) form = forms.ManualPaymentForm( @@ -668,21 +667,21 @@ def manual_payment(request, invoice_id): ) if request.POST and form.is_valid(): - form.instance.invoice = inv + form.instance.invoice = current_invoice.invoice form.instance.entered_by = request.user form.save() current_invoice.update_status() form = forms.ManualPaymentForm(prefix=FORM_PREFIX) data = { - "invoice": inv, + "invoice": current_invoice.invoice, "form": form, } return render(request, "registrasion/manual_payment.html", data) -@login_required +@user_passes_test(_staff_only) def refund(request, invoice_id): ''' Marks an invoice as refunded and requests a credit note for the full amount paid against the invoice. @@ -696,14 +695,8 @@ def refund(request, invoice_id): redirect: Redirects to ``invoice``. - Raises: - Http404: if the logged in user is not staff. - ''' - if not request.user.is_staff: - raise Http404() - current_invoice = InvoiceController.for_id_or_404(invoice_id) try: @@ -715,7 +708,7 @@ def refund(request, invoice_id): return redirect("invoice", invoice_id) -@login_required +@user_passes_test(_staff_only) def credit_note(request, note_id, access_code=None): ''' Displays a credit note. @@ -741,19 +734,12 @@ def credit_note(request, note_id, access_code=None): # refund of the credit note. } - Raises: - Http404: If the logged in user is not staff. - - ''' - if not request.user.is_staff: - raise Http404() - current_note = CreditNoteController.for_id_or_404(note_id) apply_form = forms.ApplyCreditNoteForm( - note.invoice.user, + current_note.credit_note.invoice.user, request.POST or None, prefix="apply_note" ) @@ -775,7 +761,7 @@ def credit_note(request, note_id, access_code=None): elif request.POST and refund_form.is_valid(): refund_form.instance.entered_by = request.user - refund_form.instance.parent = note + refund_form.instance.parent = current_note.credit_note refund_form.save() messages.success( request, From 4cdbdb71ceeebaed35d311025a3b7658fea9a35c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 17:37:33 +1000 Subject: [PATCH 172/418] flake8 fixes --- registrasion/controllers/discount.py | 2 +- registrasion/templatetags/registrasion_tags.py | 1 + registrasion/tests/test_cart.py | 18 +++++++++++------- registrasion/tests/test_invoice.py | 2 +- registrasion/views.py | 11 +++++------ setup.cfg | 2 +- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 6b9b7582..e73928d0 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -71,7 +71,7 @@ def available_discounts(user, categories, products): # is not available any more. past_uses = commerce.DiscountItem.objects.filter( cart__user=user, - cart__status=commerce.Cart.STATUS_PAID, # Only past carts count + cart__status=commerce.Cart.STATUS_PAID, # Only past carts count discount=real_discount, ) agg = past_uses.aggregate(Sum("quantity")) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index bd31d1f4..fabc7754 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -10,6 +10,7 @@ register = template.Library() _ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) + class ProductAndQuantity(_ProductAndQuantity): ''' Class that holds a product and a quantity. diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index bf781bb1..790c1df9 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -26,12 +26,16 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): super(RegistrationCartTestCase, self).setUp() def tearDown(self): - if False: + 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) + call_command( + 'flush', + verbosity=0, + interactive=False, + reset_sequences=False, + allow_cascade=False, + inhibit_post_migrate=False + ) super(RegistrationCartTestCase, self).tearDown() @@ -51,11 +55,11 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): password='top_secret') attendee1 = people.Attendee.get_instance(cls.USER_1) - profile1 = people.AttendeeProfileBase.objects.create( + people.AttendeeProfileBase.objects.create( attendee=attendee1, ) attendee2 = people.Attendee.get_instance(cls.USER_2) - profile2 = people.AttendeeProfileBase.objects.create( + people.AttendeeProfileBase.objects.create( attendee=attendee2, ) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 9e5b55cf..f1ed6ed0 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -484,7 +484,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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) + notes = sorted(notes, key=lambda note: note.value) self.assertEqual(cnval, notes[0].value) self.assertEqual(val, notes[1].value) diff --git a/registrasion/views.py b/registrasion/views.py index 19417b55..f10de90f 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -21,7 +21,6 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.http import Http404 -from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render @@ -42,17 +41,17 @@ class GuidedRegistrationSection(_GuidedRegistrationSection): ''' Represents a section of a guided registration page. Attributes: - title (str): The title of the section. + title (str): The title of the section. - discounts ([registrasion.contollers.discount.DiscountAndQuantity, ...]): + discounts ([registrasion.contollers.discount.DiscountAndQuantity, ...]): A list of discount objects that are available in the section. You can display ``.clause`` to show what the discount applies to, and ``.quantity`` to display the number of times that discount can be applied. - description (str): A description of the section. + description (str): A description of the section. - form (forms.Form): A form to display. + form (forms.Form): A form to display. ''' pass @@ -569,7 +568,6 @@ def invoice_access(request, access_code): user__attendee__access_code=access_code, ).order_by("-issue_time") - if not invoices: raise Http404() @@ -635,6 +633,7 @@ def _staff_only(user): ''' Returns true if the user is staff. ''' return user.is_staff + @user_passes_test(_staff_only) def manual_payment(request, invoice_id): ''' Allows staff to make manual payments or refunds on an invoice. diff --git a/setup.cfg b/setup.cfg index 257d3cd6..290fdb45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [flake8] -exclude = registrasion/migrations/*, build/* +exclude = registrasion/migrations/*, build/*, docs/* From f376bba7fdb1755fef06c4ee8c365797317453fb Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 18:30:31 +1000 Subject: [PATCH 173/418] Removes all migrations --- .../0001_squashed_0002_auto_20160304_1723.py | 287 ------------------ .../migrations/0002_auto_20160323_2029.py | 54 ---- .../migrations/0003_auto_20160323_2044.py | 24 -- .../migrations/0004_auto_20160323_2137.py | 55 ---- .../migrations/0005_auto_20160323_2141.py | 19 -- .../migrations/0006_category_required.py | 20 -- .../migrations/0007_auto_20160326_2105.py | 24 -- registrasion/migrations/0008_cart_released.py | 19 -- .../migrations/0009_auto_20160330_2336.py | 113 ------- .../migrations/0010_auto_20160330_2342.py | 29 -- .../migrations/0011_auto_20160401_0943.py | 30 -- .../migrations/0012_auto_20160406_1212.py | 49 --- ...6_2228_squashed_0015_auto_20160406_1942.py | 88 ------ .../migrations/0014_attendee_access_code.py | 21 -- .../migrations/0015_auto_20160408_0220.py | 20 -- .../migrations/0016_auto_20160408_0234.py | 20 -- .../migrations/0017_auto_20160408_0731.py | 24 -- ...refund_squashed_0019_auto_20160410_0753.py | 43 --- ...refund_squashed_0020_auto_20160411_0256.py | 27 -- .../migrations/0020_auto_20160411_0258.py | 21 -- ...1_0748_squashed_0024_auto_20160411_2230.py | 77 ----- .../migrations/0021_auto_20160411_0820.py | 51 ---- registrasion/migrations/0022_merge.py | 16 - .../migrations/0023_auto_20160425_0409.py | 19 -- .../migrations/0024_auto_20160425_0410.py | 33 -- .../migrations/0025_auto_20160425_0411.py | 20 -- .../0026_manualpayment_entered_by.py | 24 -- registrasion/migrations/__init__.py | 0 28 files changed, 1227 deletions(-) delete mode 100644 registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py delete mode 100644 registrasion/migrations/0002_auto_20160323_2029.py delete mode 100644 registrasion/migrations/0003_auto_20160323_2044.py delete mode 100644 registrasion/migrations/0004_auto_20160323_2137.py delete mode 100644 registrasion/migrations/0005_auto_20160323_2141.py delete mode 100644 registrasion/migrations/0006_category_required.py delete mode 100644 registrasion/migrations/0007_auto_20160326_2105.py delete mode 100644 registrasion/migrations/0008_cart_released.py delete mode 100644 registrasion/migrations/0009_auto_20160330_2336.py delete mode 100644 registrasion/migrations/0010_auto_20160330_2342.py delete mode 100644 registrasion/migrations/0011_auto_20160401_0943.py delete mode 100644 registrasion/migrations/0012_auto_20160406_1212.py delete mode 100644 registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py delete mode 100644 registrasion/migrations/0014_attendee_access_code.py delete mode 100644 registrasion/migrations/0015_auto_20160408_0220.py delete mode 100644 registrasion/migrations/0016_auto_20160408_0234.py delete mode 100644 registrasion/migrations/0017_auto_20160408_0731.py delete mode 100644 registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py delete mode 100644 registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py delete mode 100644 registrasion/migrations/0020_auto_20160411_0258.py delete mode 100644 registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py delete mode 100644 registrasion/migrations/0021_auto_20160411_0820.py delete mode 100644 registrasion/migrations/0022_merge.py delete mode 100644 registrasion/migrations/0023_auto_20160425_0409.py delete mode 100644 registrasion/migrations/0024_auto_20160425_0410.py delete mode 100644 registrasion/migrations/0025_auto_20160425_0411.py delete mode 100644 registrasion/migrations/0026_manualpayment_entered_by.py delete mode 100644 registrasion/migrations/__init__.py diff --git a/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py deleted file mode 100644 index cc1e3f0d..00000000 --- a/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py +++ /dev/null @@ -1,287 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import datetime -import django.utils.timezone -from django.conf import settings - - -class Migration(migrations.Migration): - - replaces = [('registrasion', '0001_initial'), ('registrasion', '0002_auto_20160304_1723')] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Badge', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=256)), - ('company', models.CharField(max_length=256)), - ], - ), - migrations.CreateModel( - name='Cart', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('time_last_updated', models.DateTimeField()), - ('reservation_duration', models.DurationField()), - ('revision', models.PositiveIntegerField(default=1)), - ('active', models.BooleanField(default=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='Category', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=65, verbose_name='Name')), - ('description', models.CharField(max_length=255, verbose_name='Description')), - ('order', models.PositiveIntegerField(verbose_name='Display order')), - ('render_type', models.IntegerField(verbose_name='Render type', choices=[(1, 'Radio button'), (2, 'Quantity boxes')])), - ], - ), - migrations.CreateModel( - name='DiscountBase', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('description', models.CharField(max_length=255, verbose_name='Description')), - ], - ), - migrations.CreateModel( - name='DiscountForCategory', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('percentage', models.DecimalField(max_digits=4, decimal_places=1, blank=True)), - ('quantity', models.PositiveIntegerField()), - ('category', models.ForeignKey(to='registrasion.Category')), - ], - ), - migrations.CreateModel( - name='DiscountForProduct', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('percentage', models.DecimalField(null=True, max_digits=4, decimal_places=1)), - ('price', models.DecimalField(null=True, max_digits=8, decimal_places=2)), - ('quantity', models.PositiveIntegerField()), - ], - ), - migrations.CreateModel( - name='DiscountItem', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('quantity', models.PositiveIntegerField()), - ('cart', models.ForeignKey(to='registrasion.Cart')), - ], - ), - migrations.CreateModel( - name='EnablingConditionBase', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('description', models.CharField(max_length=255)), - ('mandatory', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='Invoice', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('cart_revision', models.IntegerField(null=True)), - ('void', models.BooleanField(default=False)), - ('paid', models.BooleanField(default=False)), - ('value', models.DecimalField(max_digits=8, decimal_places=2)), - ('cart', models.ForeignKey(to='registrasion.Cart', null=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='LineItem', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('description', models.CharField(max_length=255)), - ('quantity', models.PositiveIntegerField()), - ('price', models.DecimalField(max_digits=8, decimal_places=2)), - ('invoice', models.ForeignKey(to='registrasion.Invoice')), - ], - ), - migrations.CreateModel( - name='Payment', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('time', models.DateTimeField(default=django.utils.timezone.now)), - ('reference', models.CharField(max_length=64)), - ('amount', models.DecimalField(max_digits=8, decimal_places=2)), - ('invoice', models.ForeignKey(to='registrasion.Invoice')), - ], - ), - migrations.CreateModel( - name='Product', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=65, verbose_name='Name')), - ('description', models.CharField(max_length=255, verbose_name='Description')), - ('price', models.DecimalField(verbose_name='Price', max_digits=8, decimal_places=2)), - ('limit_per_user', models.PositiveIntegerField(verbose_name='Limit per user', blank=True)), - ('reservation_duration', models.DurationField(default=datetime.timedelta(0, 3600), verbose_name='Reservation duration')), - ('order', models.PositiveIntegerField(verbose_name='Display order')), - ('category', models.ForeignKey(verbose_name='Product category', to='registrasion.Category')), - ], - ), - migrations.CreateModel( - name='ProductItem', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('quantity', models.PositiveIntegerField()), - ('cart', models.ForeignKey(to='registrasion.Cart')), - ('product', models.ForeignKey(to='registrasion.Product')), - ], - ), - migrations.CreateModel( - name='Profile', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('completed_registration', models.BooleanField(default=False)), - ('highest_complete_category', models.IntegerField(default=0)), - ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='Voucher', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('recipient', models.CharField(max_length=64, verbose_name='Recipient')), - ('code', models.CharField(unique=True, max_length=16, verbose_name='Voucher code')), - ('limit', models.PositiveIntegerField(verbose_name='Voucher use limit')), - ], - ), - migrations.CreateModel( - name='CategoryEnablingCondition', - fields=[ - ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), - ('enabling_category', models.ForeignKey(to='registrasion.Category')), - ], - bases=('registrasion.enablingconditionbase',), - ), - migrations.CreateModel( - name='IncludedProductDiscount', - fields=[ - ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), - ('enabling_products', models.ManyToManyField(to=b'registrasion.Product', verbose_name='Including product')), - ], - options={ - 'verbose_name': 'Product inclusion', - }, - bases=('registrasion.discountbase',), - ), - migrations.CreateModel( - name='ProductEnablingCondition', - fields=[ - ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), - ('enabling_products', models.ManyToManyField(to=b'registrasion.Product')), - ], - bases=('registrasion.enablingconditionbase',), - ), - migrations.CreateModel( - name='TimeOrStockLimitDiscount', - fields=[ - ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), - ('start_time', models.DateTimeField(null=True, verbose_name='Start time', blank=True)), - ('end_time', models.DateTimeField(null=True, verbose_name='End time', blank=True)), - ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit', blank=True)), - ], - options={ - 'verbose_name': 'Promotional discount', - }, - bases=('registrasion.discountbase',), - ), - migrations.CreateModel( - name='TimeOrStockLimitEnablingCondition', - fields=[ - ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), - ('start_time', models.DateTimeField(null=True, verbose_name='Start time')), - ('end_time', models.DateTimeField(null=True, verbose_name='End time')), - ('limit', models.PositiveIntegerField(null=True, verbose_name='Limit')), - ], - bases=('registrasion.enablingconditionbase',), - ), - migrations.CreateModel( - name='VoucherDiscount', - fields=[ - ('discountbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.DiscountBase')), - ('voucher', models.OneToOneField(verbose_name='Voucher', to='registrasion.Voucher')), - ], - bases=('registrasion.discountbase',), - ), - migrations.CreateModel( - name='VoucherEnablingCondition', - fields=[ - ('enablingconditionbase_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='registrasion.EnablingConditionBase')), - ('voucher', models.OneToOneField(to='registrasion.Voucher')), - ], - bases=('registrasion.enablingconditionbase',), - ), - migrations.AddField( - model_name='enablingconditionbase', - name='categories', - field=models.ManyToManyField(to=b'registrasion.Category', blank=True), - ), - migrations.AddField( - model_name='enablingconditionbase', - name='products', - field=models.ManyToManyField(to=b'registrasion.Product', blank=True), - ), - migrations.AddField( - model_name='discountitem', - name='discount', - field=models.ForeignKey(to='registrasion.DiscountBase'), - ), - migrations.AddField( - model_name='discountitem', - name='product', - field=models.ForeignKey(to='registrasion.Product'), - ), - migrations.AddField( - model_name='discountforproduct', - name='discount', - field=models.ForeignKey(to='registrasion.DiscountBase'), - ), - migrations.AddField( - model_name='discountforproduct', - name='product', - field=models.ForeignKey(to='registrasion.Product'), - ), - migrations.AddField( - model_name='discountforcategory', - name='discount', - field=models.ForeignKey(to='registrasion.DiscountBase'), - ), - migrations.AddField( - model_name='cart', - name='vouchers', - field=models.ManyToManyField(to=b'registrasion.Voucher', blank=True), - ), - migrations.AddField( - model_name='badge', - name='profile', - field=models.OneToOneField(to='registrasion.Profile'), - ), - migrations.AlterField( - model_name='discountforcategory', - name='percentage', - field=models.DecimalField(max_digits=4, decimal_places=1), - ), - migrations.AlterField( - model_name='discountforproduct', - name='percentage', - field=models.DecimalField(null=True, max_digits=4, decimal_places=1, blank=True), - ), - migrations.AlterField( - model_name='discountforproduct', - name='price', - field=models.DecimalField(null=True, max_digits=8, decimal_places=2, blank=True), - ), - ] diff --git a/registrasion/migrations/0002_auto_20160323_2029.py b/registrasion/migrations/0002_auto_20160323_2029.py deleted file mode 100644 index 491c6865..00000000 --- a/registrasion/migrations/0002_auto_20160323_2029.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0001_squashed_0002_auto_20160304_1723'), - ] - - operations = [ - migrations.AddField( - model_name='badge', - name='accessibility_requirements', - field=models.CharField(max_length=256, blank=True), - ), - migrations.AddField( - model_name='badge', - name='dietary_requirements', - field=models.CharField(max_length=256, blank=True), - ), - migrations.AddField( - model_name='badge', - name='free_text_1', - field=models.CharField(help_text="A line of free text that will appear on your badge. Use this for your Twitter handle, IRC nick, your preferred pronouns or anything else you'd like people to see on your badge.", max_length=64, verbose_name='Free text line 1', blank=True), - ), - migrations.AddField( - model_name='badge', - name='free_text_2', - field=models.CharField(max_length=64, verbose_name='Free text line 2', blank=True), - ), - migrations.AddField( - model_name='badge', - name='gender', - field=models.CharField(max_length=64, blank=True), - ), - migrations.AddField( - model_name='badge', - name='of_legal_age', - field=models.BooleanField(default=False, verbose_name='18+?'), - ), - migrations.AlterField( - model_name='badge', - name='company', - field=models.CharField(help_text="The name of your company, as you'd like it on your badge", max_length=64, blank=True), - ), - migrations.AlterField( - model_name='badge', - name='name', - field=models.CharField(help_text="Your name, as you'd like it on your badge", max_length=64), - ), - ] diff --git a/registrasion/migrations/0003_auto_20160323_2044.py b/registrasion/migrations/0003_auto_20160323_2044.py deleted file mode 100644 index 931dc77f..00000000 --- a/registrasion/migrations/0003_auto_20160323_2044.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0002_auto_20160323_2029'), - ] - - operations = [ - migrations.AddField( - model_name='badge', - name='name_per_invoice', - field=models.CharField(help_text="If your legal name is different to the name on your badge, fill this in, and we'll put it on your invoice. Otherwise, leave it blank.", max_length=64, verbose_name='Your legal name (for invoicing purposes)', blank=True), - ), - migrations.AlterField( - model_name='badge', - name='name', - field=models.CharField(help_text="Your name, as you'd like it to appear on your badge. ", max_length=64, verbose_name='Your name (for your conference nametag)'), - ), - ] diff --git a/registrasion/migrations/0004_auto_20160323_2137.py b/registrasion/migrations/0004_auto_20160323_2137.py deleted file mode 100644 index e6514b59..00000000 --- a/registrasion/migrations/0004_auto_20160323_2137.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('registrasion', '0003_auto_20160323_2044'), - ] - - operations = [ - migrations.CreateModel( - name='Attendee', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('completed_registration', models.BooleanField(default=False)), - ('highest_complete_category', models.IntegerField(default=0)), - ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='BadgeAndProfile', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(help_text="Your name, as you'd like it to appear on your badge. ", max_length=64, verbose_name='Your name (for your conference nametag)')), - ('company', models.CharField(help_text="The name of your company, as you'd like it on your badge", max_length=64, blank=True)), - ('free_text_1', models.CharField(help_text="A line of free text that will appear on your badge. Use this for your Twitter handle, IRC nick, your preferred pronouns or anything else you'd like people to see on your badge.", max_length=64, verbose_name='Free text line 1', blank=True)), - ('free_text_2', models.CharField(max_length=64, verbose_name='Free text line 2', blank=True)), - ('name_per_invoice', models.CharField(help_text="If your legal name is different to the name on your badge, fill this in, and we'll put it on your invoice. Otherwise, leave it blank.", max_length=64, verbose_name='Your legal name (for invoicing purposes)', blank=True)), - ('of_legal_age', models.BooleanField(default=False, verbose_name='18+?')), - ('dietary_requirements', models.CharField(max_length=256, blank=True)), - ('accessibility_requirements', models.CharField(max_length=256, blank=True)), - ('gender', models.CharField(max_length=64, blank=True)), - ('profile', models.OneToOneField(to='registrasion.Attendee')), - ], - ), - migrations.RemoveField( - model_name='badge', - name='profile', - ), - migrations.RemoveField( - model_name='profile', - name='user', - ), - migrations.DeleteModel( - name='Badge', - ), - migrations.DeleteModel( - name='Profile', - ), - ] diff --git a/registrasion/migrations/0005_auto_20160323_2141.py b/registrasion/migrations/0005_auto_20160323_2141.py deleted file mode 100644 index 26124f7d..00000000 --- a/registrasion/migrations/0005_auto_20160323_2141.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0004_auto_20160323_2137'), - ] - - operations = [ - migrations.RenameField( - model_name='badgeandprofile', - old_name='profile', - new_name='attendee', - ), - ] diff --git a/registrasion/migrations/0006_category_required.py b/registrasion/migrations/0006_category_required.py deleted file mode 100644 index 46cc6a51..00000000 --- a/registrasion/migrations/0006_category_required.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0005_auto_20160323_2141'), - ] - - operations = [ - migrations.AddField( - model_name='category', - name='required', - field=models.BooleanField(default=False), - preserve_default=False, - ), - ] diff --git a/registrasion/migrations/0007_auto_20160326_2105.py b/registrasion/migrations/0007_auto_20160326_2105.py deleted file mode 100644 index dbcf2ac7..00000000 --- a/registrasion/migrations/0007_auto_20160326_2105.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0006_category_required'), - ] - - operations = [ - migrations.AddField( - model_name='category', - name='limit_per_user', - field=models.PositiveIntegerField(null=True, verbose_name='Limit per user', blank=True), - ), - migrations.AlterField( - model_name='product', - name='limit_per_user', - field=models.PositiveIntegerField(null=True, verbose_name='Limit per user', blank=True), - ), - ] diff --git a/registrasion/migrations/0008_cart_released.py b/registrasion/migrations/0008_cart_released.py deleted file mode 100644 index 1d805c94..00000000 --- a/registrasion/migrations/0008_cart_released.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0007_auto_20160326_2105'), - ] - - operations = [ - migrations.AddField( - model_name='cart', - name='released', - field=models.BooleanField(default=False), - ), - ] diff --git a/registrasion/migrations/0009_auto_20160330_2336.py b/registrasion/migrations/0009_auto_20160330_2336.py deleted file mode 100644 index 9d81a3b6..00000000 --- a/registrasion/migrations/0009_auto_20160330_2336.py +++ /dev/null @@ -1,113 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import datetime - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0008_cart_released'), - ] - - operations = [ - migrations.AlterModelOptions( - name='category', - options={'verbose_name_plural': 'categories'}, - ), - migrations.AlterModelOptions( - name='timeorstocklimitenablingcondition', - options={'verbose_name': 'ceiling'}, - ), - migrations.AlterField( - model_name='category', - name='limit_per_user', - field=models.PositiveIntegerField(help_text='The total number of items from this category one attendee may purchase.', null=True, verbose_name='Limit per user', blank=True), - ), - migrations.AlterField( - model_name='category', - name='render_type', - field=models.IntegerField(help_text='The registration form will render this category in this style.', verbose_name='Render type', choices=[(1, 'Radio button'), (2, 'Quantity boxes')]), - ), - migrations.AlterField( - model_name='category', - name='required', - field=models.BooleanField(help_text='If enabled, a user must select an item from this category.'), - ), - migrations.AlterField( - model_name='categoryenablingcondition', - name='enabling_category', - field=models.ForeignKey(help_text='If a product from this category is purchased, this condition is met.', to='registrasion.Category'), - ), - migrations.AlterField( - model_name='discountbase', - name='description', - field=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.AlterField( - model_name='enablingconditionbase', - name='categories', - field=models.ManyToManyField(help_text='Categories whose products are enabled if this condition is met.', to='registrasion.Category', blank=True), - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='mandatory', - field=models.BooleanField(default=False, help_text='If there is at least one mandatory condition defined on a product or category, all such conditions must be met. Otherwise, at least one non-mandatory condition must be met.'), - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='products', - field=models.ManyToManyField(help_text='Products that are enabled if this condition is met.', to='registrasion.Product', blank=True), - ), - migrations.AlterField( - model_name='includedproductdiscount', - name='enabling_products', - field=models.ManyToManyField(help_text='If one of these products are purchased, the discounts below will be enabled.', to='registrasion.Product', verbose_name='Including product'), - ), - migrations.AlterField( - model_name='product', - name='description', - field=models.CharField(max_length=255, null=True, verbose_name='Description', blank=True), - ), - migrations.AlterField( - model_name='product', - name='reservation_duration', - field=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'), - ), - migrations.AlterField( - model_name='productenablingcondition', - name='enabling_products', - field=models.ManyToManyField(help_text='If one of these products are purchased, this condition is met.', to='registrasion.Product'), - ), - migrations.AlterField( - model_name='timeorstocklimitdiscount', - name='end_time', - field=models.DateTimeField(help_text='This discount will only be available before this time.', null=True, verbose_name='End time', blank=True), - ), - migrations.AlterField( - model_name='timeorstocklimitdiscount', - name='limit', - field=models.PositiveIntegerField(help_text='This discount may only be applied this many times.', null=True, verbose_name='Limit', blank=True), - ), - migrations.AlterField( - model_name='timeorstocklimitdiscount', - name='start_time', - field=models.DateTimeField(help_text='This discount will only be available after this time.', null=True, verbose_name='Start time', blank=True), - ), - migrations.AlterField( - model_name='timeorstocklimitenablingcondition', - name='end_time', - field=models.DateTimeField(help_text='Products included in this condition will only be available before this time.', null=True), - ), - migrations.AlterField( - model_name='timeorstocklimitenablingcondition', - name='limit', - field=models.PositiveIntegerField(help_text='The number of items under this grouping that can be purchased.', null=True), - ), - migrations.AlterField( - model_name='timeorstocklimitenablingcondition', - name='start_time', - field=models.DateTimeField(help_text='Products included in this condition will only be available after this time.', null=True), - ), - ] diff --git a/registrasion/migrations/0010_auto_20160330_2342.py b/registrasion/migrations/0010_auto_20160330_2342.py deleted file mode 100644 index f689504b..00000000 --- a/registrasion/migrations/0010_auto_20160330_2342.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0009_auto_20160330_2336'), - ] - - operations = [ - migrations.AlterField( - model_name='timeorstocklimitenablingcondition', - name='end_time', - field=models.DateTimeField(help_text='Products included in this condition will only be available before this time.', null=True, blank=True), - ), - migrations.AlterField( - model_name='timeorstocklimitenablingcondition', - name='limit', - field=models.PositiveIntegerField(help_text='The number of items under this grouping that can be purchased.', null=True, blank=True), - ), - migrations.AlterField( - model_name='timeorstocklimitenablingcondition', - name='start_time', - field=models.DateTimeField(help_text='Products included in this condition will only be available after this time.', null=True, blank=True), - ), - ] diff --git a/registrasion/migrations/0011_auto_20160401_0943.py b/registrasion/migrations/0011_auto_20160401_0943.py deleted file mode 100644 index a8b2deac..00000000 --- a/registrasion/migrations/0011_auto_20160401_0943.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-01 09:43 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0010_auto_20160330_2342'), - ] - - operations = [ - 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.RemoveField( - model_name='badgeandprofile', - name='attendee', - ), - migrations.DeleteModel( - name='BadgeAndProfile', - ), - ] diff --git a/registrasion/migrations/0012_auto_20160406_1212.py b/registrasion/migrations/0012_auto_20160406_1212.py deleted file mode 100644 index 61a27ed1..00000000 --- a/registrasion/migrations/0012_auto_20160406_1212.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-06 12:12 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0011_auto_20160401_0943'), - ] - - operations = [ - migrations.AlterField( - model_name='cart', - name='active', - field=models.BooleanField(db_index=True, default=True), - ), - migrations.AlterField( - model_name='cart', - name='released', - field=models.BooleanField(db_index=True, default=False), - ), - migrations.AlterField( - model_name='cart', - name='time_last_updated', - field=models.DateTimeField(db_index=True), - ), - migrations.AlterField( - model_name='category', - name='order', - field=models.PositiveIntegerField(db_index=True, verbose_name='Display order'), - ), - migrations.AlterField( - model_name='product', - name='order', - field=models.PositiveIntegerField(db_index=True, verbose_name='Display order'), - ), - migrations.AlterField( - model_name='productitem', - name='quantity', - field=models.PositiveIntegerField(db_index=True), - ), - migrations.AlterIndexTogether( - name='cart', - index_together=set([('active', 'released'), ('released', 'user'), ('active', 'user'), ('active', 'time_last_updated')]), - ), - ] diff --git a/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py deleted file mode 100644 index e4d88313..00000000 --- a/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py +++ /dev/null @@ -1,88 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-07 03:13 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - replaces = [('registrasion', '0013_auto_20160406_2228'), ('registrasion', '0014_auto_20160406_1847'), ('registrasion', '0015_auto_20160406_1942')] - - dependencies = [ - ('registrasion', '0012_auto_20160406_1212'), - ] - - operations = [ - 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)), - ], - ), - migrations.RemoveField( - model_name='payment', - name='invoice', - ), - migrations.RemoveField( - model_name='invoice', - name='paid', - ), - migrations.RemoveField( - model_name='invoice', - name='void', - ), - migrations.AddField( - model_name='invoice', - name='due_time', - field=models.DateTimeField(default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='invoice', - name='issue_time', - field=models.DateTimeField(default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='invoice', - name='recipient', - field=models.CharField(default='Lol', max_length=1024), - preserve_default=False, - ), - migrations.AddField( - model_name='invoice', - name='status', - field=models.IntegerField(choices=[(1, 'Unpaid'), (2, 'Paid'), (3, 'Refunded'), (4, 'VOID')], db_index=True), - ), - 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.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')), - ], - bases=('registrasion.paymentbase',), - ), - migrations.DeleteModel( - name='Payment', - ), - migrations.AddField( - model_name='paymentbase', - name='invoice', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Invoice'), - ), - migrations.AlterField( - model_name='invoice', - name='cart_revision', - field=models.IntegerField(db_index=True, null=True), - ), - ] diff --git a/registrasion/migrations/0014_attendee_access_code.py b/registrasion/migrations/0014_attendee_access_code.py deleted file mode 100644 index a579f47a..00000000 --- a/registrasion/migrations/0014_attendee_access_code.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-08 02:20 -from __future__ import unicode_literals - -from django.db import migrations, models -import registrasion.util - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0013_auto_20160406_2228_squashed_0015_auto_20160406_1942'), - ] - - operations = [ - migrations.AddField( - model_name='attendee', - name='access_code', - field=models.CharField(default=registrasion.util.generate_access_code, max_length=6, unique=True), - ), - ] diff --git a/registrasion/migrations/0015_auto_20160408_0220.py b/registrasion/migrations/0015_auto_20160408_0220.py deleted file mode 100644 index acdde45f..00000000 --- a/registrasion/migrations/0015_auto_20160408_0220.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-08 02:20 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0014_attendee_access_code'), - ] - - operations = [ - migrations.AlterField( - model_name='attendee', - name='access_code', - field=models.CharField(max_length=6, unique=True), - ), - ] diff --git a/registrasion/migrations/0016_auto_20160408_0234.py b/registrasion/migrations/0016_auto_20160408_0234.py deleted file mode 100644 index 94beb184..00000000 --- a/registrasion/migrations/0016_auto_20160408_0234.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-08 02:34 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0015_auto_20160408_0220'), - ] - - operations = [ - migrations.AlterField( - model_name='attendee', - name='access_code', - field=models.CharField(db_index=True, max_length=6, unique=True), - ), - ] diff --git a/registrasion/migrations/0017_auto_20160408_0731.py b/registrasion/migrations/0017_auto_20160408_0731.py deleted file mode 100644 index 5bb8957c..00000000 --- a/registrasion/migrations/0017_auto_20160408_0731.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-08 07:31 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0016_auto_20160408_0234'), - ] - - operations = [ - migrations.RemoveField( - model_name='attendee', - name='highest_complete_category', - ), - migrations.AddField( - model_name='attendee', - name='guided_categories_complete', - field=models.ManyToManyField(to='registrasion.Category'), - ), - ] diff --git a/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py b/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py deleted file mode 100644 index abb3b6e9..00000000 --- a/registrasion/migrations/0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-10 07:54 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - replaces = [('registrasion', '0018_creditnote_creditnoteapplication_creditnoterefund'), ('registrasion', '0019_auto_20160410_0753')] - - dependencies = [ - ('registrasion', '0017_auto_20160408_0731'), - ] - - operations = [ - 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.paymentbase',), - ), - 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)), - ('parent', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='registrasion.CreditNote')), - ], - ), - ] diff --git a/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py b/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py deleted file mode 100644 index b070e3e6..00000000 --- a/registrasion/migrations/0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 02:57 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - replaces = [('registrasion', '0019_manualcreditnoterefund'), ('registrasion', '0020_auto_20160411_0256')] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('registrasion', '0018_creditnote_creditnoteapplication_creditnoterefund_squashed_0019_auto_20160410_0753'), - ] - - operations = [ - migrations.CreateModel( - name='ManualCreditNoteRefund', - fields=[ - ('entered_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('creditnoterefund_ptr', models.OneToOneField(auto_created=True, default=0, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund')), - ], - ), - ] diff --git a/registrasion/migrations/0020_auto_20160411_0258.py b/registrasion/migrations/0020_auto_20160411_0258.py deleted file mode 100644 index 0148f90d..00000000 --- a/registrasion/migrations/0020_auto_20160411_0258.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 02:58 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0019_manualcreditnoterefund_squashed_0020_auto_20160411_0256'), - ] - - operations = [ - migrations.AlterField( - model_name='manualcreditnoterefund', - name='creditnoterefund_ptr', - field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund'), - ), - ] diff --git a/registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py b/registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py deleted file mode 100644 index 53b82d10..00000000 --- a/registrasion/migrations/0021_auto_20160411_0748_squashed_0024_auto_20160411_2230.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 22:46 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0020_auto_20160411_0258'), - ] - - operations = [ - migrations.RenameModel( - old_name='CategoryEnablingCondition', - new_name='CategoryFlag', - ), - migrations.RenameModel( - old_name='ProductEnablingCondition', - new_name='ProductFlag', - ), - migrations.RenameModel( - old_name='TimeOrStockLimitEnablingCondition', - new_name='TimeOrStockLimitFlag', - ), - migrations.RenameModel( - old_name='VoucherEnablingCondition', - new_name='VoucherFlag', - ), - migrations.AlterModelOptions( - name='categoryflag', - options={'verbose_name': 'flag (dependency on product from category)', 'verbose_name_plural': 'flags (dependency on product from category)'}, - ), - migrations.AlterModelOptions( - name='productflag', - options={'verbose_name': 'flag (dependency on product)', 'verbose_name_plural': 'flags (dependency on product)'}, - ), - migrations.AlterModelOptions( - name='timeorstocklimitflag', - options={'verbose_name': 'flag (time/stock limit)', 'verbose_name_plural': 'flags (time/stock limit)'}, - ), - migrations.AlterModelOptions( - name='voucherflag', - options={'verbose_name': 'flag (dependency on voucher)', 'verbose_name_plural': 'flags (dependency on voucher)'}, - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='categories', - field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", to=b'registrasion.Category'), - ), - migrations.RenameField( - model_name='enablingconditionbase', - old_name='mandatory', - new_name='condition', - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='condition', - field=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.AlterField( - model_name='enablingconditionbase', - name='products', - field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", to=b'registrasion.Product'), - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='categories', - field=models.ManyToManyField(blank=True, help_text="Categories whose products are affected by this flag's condition.", related_name='flagbase_set', to=b'registrasion.Category'), - ), - migrations.AlterField( - model_name='enablingconditionbase', - name='products', - field=models.ManyToManyField(blank=True, help_text="Products affected by this flag's condition.", related_name='flagbase_set', to=b'registrasion.Product'), - ), - ] diff --git a/registrasion/migrations/0021_auto_20160411_0820.py b/registrasion/migrations/0021_auto_20160411_0820.py deleted file mode 100644 index b6fc4367..00000000 --- a/registrasion/migrations/0021_auto_20160411_0820.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-11 08:20 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0020_auto_20160411_0258'), - ] - - operations = [ - migrations.AlterModelOptions( - name='category', - options={'ordering': ('order',), 'verbose_name': 'inventory - category', 'verbose_name_plural': 'inventory - categories'}, - ), - migrations.AlterModelOptions( - name='discountitem', - options={'ordering': ('product',)}, - ), - migrations.AlterModelOptions( - name='includedproductdiscount', - options={'verbose_name': 'discount (product inclusions)', 'verbose_name_plural': 'discounts (product inclusions)'}, - ), - migrations.AlterModelOptions( - name='lineitem', - options={'ordering': ('id',)}, - ), - migrations.AlterModelOptions( - name='paymentbase', - options={'ordering': ('time',)}, - ), - migrations.AlterModelOptions( - name='product', - options={'ordering': ('category__order', 'order'), 'verbose_name': 'inventory - product'}, - ), - migrations.AlterModelOptions( - name='productitem', - options={'ordering': ('product',)}, - ), - migrations.AlterModelOptions( - name='timeorstocklimitdiscount', - options={'verbose_name': 'discount (time/stock limit)', 'verbose_name_plural': 'discounts (time/stock limit)'}, - ), - migrations.AlterModelOptions( - name='voucherdiscount', - options={'verbose_name': 'discount (enabled by voucher)', 'verbose_name_plural': 'discounts (enabled by voucher)'}, - ), - ] diff --git a/registrasion/migrations/0022_merge.py b/registrasion/migrations/0022_merge.py deleted file mode 100644 index e3c30039..00000000 --- a/registrasion/migrations/0022_merge.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-12 01:40 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0021_auto_20160411_0748_squashed_0024_auto_20160411_2230'), - ('registrasion', '0021_auto_20160411_0820'), - ] - - operations = [ - ] diff --git a/registrasion/migrations/0023_auto_20160425_0409.py b/registrasion/migrations/0023_auto_20160425_0409.py deleted file mode 100644 index 234e9cda..00000000 --- a/registrasion/migrations/0023_auto_20160425_0409.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-25 04:09 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0022_merge'), - ] - - operations = [ - migrations.AlterIndexTogether( - name='cart', - index_together=set([]), - ), - ] diff --git a/registrasion/migrations/0024_auto_20160425_0410.py b/registrasion/migrations/0024_auto_20160425_0410.py deleted file mode 100644 index 5cf7cdbc..00000000 --- a/registrasion/migrations/0024_auto_20160425_0410.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-25 04:10 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0023_auto_20160425_0409'), - ] - - operations = [ - migrations.AddField( - model_name='cart', - name='status', - field=models.IntegerField(choices=[(1, 'Active'), (2, 'Paid'), (3, 'Released')], db_index=True, default=1), - preserve_default=False, - ), - migrations.RemoveField( - model_name='cart', - name='active', - ), - migrations.RemoveField( - model_name='cart', - name='released', - ), - migrations.AlterIndexTogether( - name='cart', - index_together=set([('status', 'user'), ('status', 'time_last_updated')]), - ), - ] diff --git a/registrasion/migrations/0025_auto_20160425_0411.py b/registrasion/migrations/0025_auto_20160425_0411.py deleted file mode 100644 index aa084246..00000000 --- a/registrasion/migrations/0025_auto_20160425_0411.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-25 04:11 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0024_auto_20160425_0410'), - ] - - operations = [ - migrations.AlterField( - model_name='cart', - name='status', - field=models.IntegerField(choices=[(1, 'Active'), (2, 'Paid'), (3, 'Released')], db_index=True, default=1), - ), - ] diff --git a/registrasion/migrations/0026_manualpayment_entered_by.py b/registrasion/migrations/0026_manualpayment_entered_by.py deleted file mode 100644 index 0e068b17..00000000 --- a/registrasion/migrations/0026_manualpayment_entered_by.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-04-25 06:05 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('registrasion', '0025_auto_20160425_0411'), - ] - - operations = [ - migrations.AddField( - model_name='manualpayment', - name='entered_by', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - preserve_default=False, - ), - ] diff --git a/registrasion/migrations/__init__.py b/registrasion/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 From f755b130910c18b6842eaf603f869ead80711aeb Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 18:32:11 +1000 Subject: [PATCH 174/418] Removes EnablingConditionBase, replaces it with FlagBase; adds first tranche of migrations --- registrasion/migrations/0001_initial.py | 383 ++++++++++++++++++++++++ registrasion/migrations/__init__.py | 0 registrasion/models/conditions.py | 25 +- 3 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 registrasion/migrations/0001_initial.py create mode 100644 registrasion/migrations/__init__.py diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_initial.py new file mode 100644 index 00000000..aa9cebd3 --- /dev/null +++ b/registrasion/migrations/0001_initial.py @@ -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')]), + ), + ] diff --git a/registrasion/migrations/__init__.py b/registrasion/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index 2186ef1d..41e1a320 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -279,10 +279,7 @@ class FlagBase(models.Model): The Categories whose Products are affected by this flag. ''' - class Meta: - # TODO: make concrete once https://code.djangoproject.com/ticket/26488 - # is solved. - abstract = True + objects = InheritanceManager() DISABLE_IF_FALSE = 1 ENABLE_IF_TRUE = 2 @@ -333,19 +330,7 @@ class FlagBase(models.Model): ) -class EnablingConditionBase(FlagBase): - ''' Reifies the abstract FlagBase. This is necessary because django - prevents renaming base classes in migrations. ''' - # TODO: remove this, and make subclasses subclass FlagBase once - # https://code.djangoproject.com/ticket/26488 is solved. - - class Meta: - app_label = "registrasion" - - objects = InheritanceManager() - - -class TimeOrStockLimitFlag(EnablingConditionBase): +class TimeOrStockLimitFlag(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. @@ -388,7 +373,7 @@ class TimeOrStockLimitFlag(EnablingConditionBase): @python_2_unicode_compatible -class ProductFlag(EnablingConditionBase): +class ProductFlag(FlagBase): ''' The condition is met because a specific product is purchased. Attributes: @@ -412,7 +397,7 @@ class ProductFlag(EnablingConditionBase): @python_2_unicode_compatible -class CategoryFlag(EnablingConditionBase): +class CategoryFlag(FlagBase): ''' The condition is met because a product in a particular product is purchased. @@ -437,7 +422,7 @@ class CategoryFlag(EnablingConditionBase): @python_2_unicode_compatible -class VoucherFlag(EnablingConditionBase): +class VoucherFlag(FlagBase): ''' The condition is met because a Voucher is present. This is for e.g. enabling sponsor tickets. ''' From fd751b4ea12f6ac6f5f47f3ab9970b0bc2cabf94 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 18:32:36 +1000 Subject: [PATCH 175/418] Removes print statement --- registrasion/controllers/cart.py | 1 - 1 file changed, 1 deletion(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index f8cbd0f5..15c65d66 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -266,7 +266,6 @@ class CartController(object): ) for item in items: - print item required.remove(item.product.category) errors = [] From cbecbf9a410208f23d1a26be5c57b58ad2759927 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 18:34:28 +1000 Subject: [PATCH 176/418] Tidies up some docs --- docs/integration.rst | 2 +- docs/inventory.rst | 5 +++++ registrasion/controllers/cart.py | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index bdf7845b..bc83eb49 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -1,7 +1,7 @@ Integrating Registrasion ======================== -Registrasion is a Django app. It does not provide any templates -- you'll need to develop these yourself. You can use the ``registrasion-demo`` project as a starting point. +Registrasion is a Django app. It does not provide any templates -- you'll need to develop these yourself. You can use the `registrasion-demo `_ project as a starting point. To use Registrasion for your own conference, you'll need to do a small amount of development work, usually in your own Django App. diff --git a/docs/inventory.rst b/docs/inventory.rst index 65ac9c46..c9d6dec1 100644 --- a/docs/inventory.rst +++ b/docs/inventory.rst @@ -4,6 +4,11 @@ Inventory Management Registrasion uses an inventory model to keep track of tickets, and the other various products that attendees of your conference might want to have, such as t-shirts and dinner tickets. +All of the classes described herein are available through the Django Admin interface. + +Overview +-------- + The inventory model is split up into Categories and Products. Categories are used to group Products. Registrasion uses conditionals to build up complex tickets, or enable/disable specific items to specific users: diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 15c65d66..20bd6b5c 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -278,7 +278,6 @@ class CartController(object): def _append_errors(self, errors, ve): for error in ve.error_list: - print error.message errors.append(error.message[1]) def validate_cart(self): From 7ccfaed304cebefbb104c033ea9c51dd8fdec9f7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 18:49:54 +1000 Subject: [PATCH 177/418] Removes line that forces segfault avoidance --- registrasion/tests/test_cart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 790c1df9..507d5cf7 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -26,7 +26,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): super(RegistrationCartTestCase, self).setUp() def tearDown(self): - if True: + if False: # If you're seeing segfaults in tests, enable this. call_command( 'flush', From 98365dcf28d5e5937af970e9956be68614ec4b81 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 25 Apr 2016 19:36:52 +1000 Subject: [PATCH 178/418] Adds more to the integration docs --- docs/integration.rst | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index bc83eb49..36d33795 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -3,9 +3,19 @@ Integrating Registrasion Registrasion is a Django app. It does not provide any templates -- you'll need to develop these yourself. You can use the `registrasion-demo `_ project as a starting point. -To use Registrasion for your own conference, you'll need to do a small amount of development work, usually in your own Django App. +To use Registrasion for your own conference, you'll need to do a small amount of configuration and development work, in your own Django App. + +The configuration that you'll need to do is minimal. The first piece of development work is to define a model and form for your attendee profile, and the second is to implement a payment app. + + +Configuring your Django App +--------------------------- + +In your Django ``settings.py`` file, you'll need to add the following to your ``INSTALLED_APPS``:: + + "registrasion", + "nested_admin", -The first is to define a model and form for your attendee profile, and the second is to implement a payment app. Attendee profile From 63d15a6be3e99656c4db38d054e15215b1a094f8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 10:52:56 +1000 Subject: [PATCH 179/418] More view documentation --- docs/views.rst | 29 +++++++++++++-- registrasion/controllers/discount.py | 20 +++++++++++ registrasion/models/commerce.py | 53 ++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/docs/views.rst b/docs/views.rst index ea30f748..96429a7d 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -1,11 +1,22 @@ -Public-facing views -=================== +User-facing views +================= + + +View functions +-------------- Here's all of the views that Registrasion exposes to the public. .. automodule:: registrasion.views :members: +Data types +~~~~~~~~~~ + +.. automodule:: registrasion.controllers.discount + +.. autoclass:: DiscountAndQuantity + Template tags ------------- @@ -14,3 +25,17 @@ Registrasion makes template tags available: .. automodule:: registrasion.templatetags.registrasion_tags :members: + + +Rendering invoices +------------------ + +You'll need to render the following Django models in order to view invoices. + +.. automodule:: registrasion.models.commerce + +.. autoclass:: Invoice + +.. autoclass:: LineItem + +.. autoclass:: PaymentBase diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index e73928d0..0544e980 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -8,6 +8,26 @@ from django.db.models import Sum 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 diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 7e86acf2..a9fc7341 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -102,7 +102,39 @@ class DiscountItem(models.Model): @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. ''' + 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" @@ -165,7 +197,24 @@ class Invoice(models.Model): 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. ''' + 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" From cd194ab133caed7bb0407c06b58d33ec6bcfaf8e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 13:54:28 +1000 Subject: [PATCH 180/418] Fixes the documentation for installation. --- docs/integration.rst | 17 +++++++++++++++-- docs/views.rst | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index 36d33795..827d99a7 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -1,5 +1,5 @@ -Integrating Registrasion -======================== +Installing and integrating Registrasion +======================================= Registrasion is a Django app. It does not provide any templates -- you'll need to develop these yourself. You can use the `registrasion-demo `_ project as a starting point. @@ -8,6 +8,19 @@ To use Registrasion for your own conference, you'll need to do a small amount of The configuration that you'll need to do is minimal. The first piece of development work is to define a model and form for your attendee profile, and the second is to implement a payment app. +Installing Registrasion +----------------------- + +Registrasion depends on an in-development version of Symposion. You'll need to add the following two lines to your ``requirements.txt`` files:: + + git+https://github.com/pinax/symposion.git@ad81810 + git+https://github.com/chrisjrn/registrasion.git@releases/0.1 + +Symposion currently specifies Django version 1.9.2. + +Running ``pip install -r requirements.txt`` will pull down the git version of Symposion as well as the current 0.1 release of Registrasion. + + Configuring your Django App --------------------------- diff --git a/docs/views.rst b/docs/views.rst index 96429a7d..313337ec 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -38,4 +38,4 @@ You'll need to render the following Django models in order to view invoices. .. autoclass:: LineItem -.. autoclass:: PaymentBase +See also: :class:`PaymentBase` From ddadf7081f688bdaa1bbab2e5edc6531efc90884 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 13:55:48 +1000 Subject: [PATCH 181/418] One Last Doc. --- docs/integration.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integration.rst b/docs/integration.rst index 827d99a7..aebfa2f8 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -29,6 +29,7 @@ In your Django ``settings.py`` file, you'll need to add the following to your `` "registrasion", "nested_admin", +You will also need to configure ``symposion`` appropriately. Attendee profile From 8afb31a118a54aea5fa982491c84492ada846223 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 13:56:10 +1000 Subject: [PATCH 182/418] Flake8 fix --- registrasion/controllers/discount.py | 1 - 1 file changed, 1 deletion(-) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 0544e980..a8f9282e 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -27,7 +27,6 @@ class DiscountAndQuantity(object): ''' - def __init__(self, discount, clause, quantity): self.discount = discount self.clause = clause From 0efd9e146a6453469ba49b77b8bd10da5a5027b7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 14:29:57 +1000 Subject: [PATCH 183/418] =?UTF-8?q?Makes=20Registrasion=E2=80=99s=20depend?= =?UTF-8?q?encies=20fully=20installable=20through=20-=E2=80=94process-depe?= =?UTF-8?q?ndency-links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements/base.txt | 1 + requirements/dependencies.txt | 1 + requirements/extern.txt | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 requirements/dependencies.txt diff --git a/requirements/base.txt b/requirements/base.txt index 25c5df9b..c98ec0dd 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1 +1,2 @@ django-nested-admin==2.2.6 +SymposionMaster==1.0.0b3-dev diff --git a/requirements/dependencies.txt b/requirements/dependencies.txt new file mode 100644 index 00000000..2d418ca1 --- /dev/null +++ b/requirements/dependencies.txt @@ -0,0 +1 @@ +https://github.com/pinax/symposion/tarball/ad81810#egg=SymposionMaster-1.0.0b3-dev diff --git a/requirements/extern.txt b/requirements/extern.txt index 3dc3c610..f9f15786 100644 --- a/requirements/extern.txt +++ b/requirements/extern.txt @@ -1,4 +1,4 @@ # Requirements that currently live in git land, so are necessary to make the # project build, but can't live in setup.py --e git+https://github.com/pinax/symposion.git#egg=SymposionMaster # Symposion lives on git at the moment +-e git+https://github.com/pinax/symposion.git@ad81810#egg=SymposionMaster-1.0.0b3-dev # Symposion lives on git at the moment diff --git a/setup.py b/setup.py index 6686b6fb..531c35bb 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,6 @@ def read_file(filename): except IOError: return '' - setup( name="registrasion", author="Christopher Neugebauer", @@ -34,4 +33,5 @@ setup( "License :: OSI Approved :: Apache Software License", ), install_requires=read_file("requirements/base.txt").splitlines(), + dependency_links=read_file("requirements/dependencies.txt").splitlines(), ) From a7d4e042360dc4fdd1aeb2d90f01697177cf5113 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 14:41:34 +1000 Subject: [PATCH 184/418] Installation documentation is now accurate --- docs/integration.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index aebfa2f8..ffbb5c25 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -11,14 +11,15 @@ The configuration that you'll need to do is minimal. The first piece of developm Installing Registrasion ----------------------- -Registrasion depends on an in-development version of Symposion. You'll need to add the following two lines to your ``requirements.txt`` files:: +Registrasion depends on an in-development version of Symposion. You'll need to add the following line to your ``requirements.txt`` files:: - git+https://github.com/pinax/symposion.git@ad81810 git+https://github.com/chrisjrn/registrasion.git@releases/0.1 -Symposion currently specifies Django version 1.9.2. +And also to enable dependency links in pip:: -Running ``pip install -r requirements.txt`` will pull down the git version of Symposion as well as the current 0.1 release of Registrasion. + pip install --process-dependency-links -r requirements.txt + +Symposion currently specifies Django version 1.9.2. Note that ``pip`` version 1.6 does not support ``--process-dependency-links``, so you'll need to use an earlier, or later version of ``pip``. Configuring your Django App From b32c7780c6d786e1324c9e725fb414e6c5b75758 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 14:51:12 +1000 Subject: [PATCH 185/418] Marks 0.1.0 release --- registrasion/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/__init__.py b/registrasion/__init__.py index 97a6f232..09e905c0 100644 --- a/registrasion/__init__.py +++ b/registrasion/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.1a1" +__version__ = "0.1.0" default_app_config = "registrasion.apps.RegistrasionConfig" diff --git a/setup.py b/setup.py index 531c35bb..f3bda01f 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( packages=find_packages(), include_package_data=True, classifiers=( - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 3 - Alpha", "Programming Language :: Python", "Framework :: Django", "Intended Audience :: Developers", From d119bb01805a7cd05fed6715c2ce3bde9501ebbf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 15:00:01 +1000 Subject: [PATCH 186/418] Fixes dependencies.txt --- requirements/base.txt | 2 +- requirements/dependencies.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index c98ec0dd..5343b3dc 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ django-nested-admin==2.2.6 -SymposionMaster==1.0.0b3-dev +symposion==1.0b2.dev3 diff --git a/requirements/dependencies.txt b/requirements/dependencies.txt index 2d418ca1..f994cf33 100644 --- a/requirements/dependencies.txt +++ b/requirements/dependencies.txt @@ -1 +1 @@ -https://github.com/pinax/symposion/tarball/ad81810#egg=SymposionMaster-1.0.0b3-dev +https://github.com/pinax/symposion/tarball/ad81810#egg=symposion-1.0b2.dev3 From 6d67439f16ce63329798629f7ea7766fd3c5e703 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 26 Apr 2016 16:25:30 +1000 Subject: [PATCH 187/418] Adds correct documentation for setting up a project. --- docs/integration.rst | 3 ++- requirements/dependencies.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index ffbb5c25..f8c58a64 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -13,7 +13,8 @@ Installing Registrasion Registrasion depends on an in-development version of Symposion. You'll need to add the following line to your ``requirements.txt`` files:: - git+https://github.com/chrisjrn/registrasion.git@releases/0.1 + registrasion==0.1.0 + https://github.com/pinax/symposion/tarball/ad81810#egg=symposion And also to enable dependency links in pip:: diff --git a/requirements/dependencies.txt b/requirements/dependencies.txt index f994cf33..42ef60c8 100644 --- a/requirements/dependencies.txt +++ b/requirements/dependencies.txt @@ -1 +1 @@ -https://github.com/pinax/symposion/tarball/ad81810#egg=symposion-1.0b2.dev3 +https://github.com/pinax/symposion/tarball/ad81810#egg=symposion From 05269c93cda77d76ea2b5dde4ab7a0efe6f36189 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 27 Apr 2016 11:36:31 +1000 Subject: [PATCH 188/418] Marks 0.2.0-dev --- registrasion/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/__init__.py b/registrasion/__init__.py index 09e905c0..1a6053a5 100644 --- a/registrasion/__init__.py +++ b/registrasion/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.1.0" +__version__ = "0.2.0-dev" default_app_config = "registrasion.apps.RegistrasionConfig" From 3f1be0e14e06b5a24ee5669648d823d84c47f55b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 27 Apr 2016 11:46:44 +1000 Subject: [PATCH 189/418] Rearchitected condition processing such that multiple conditions are processed by the database, in bulk. Closes #42. --- registrasion/controllers/cart.py | 2 + registrasion/controllers/conditions.py | 422 +++++++++++++++++++------ registrasion/controllers/discount.py | 147 ++++++--- registrasion/tests/test_ceilings.py | 35 ++ setup.cfg | 2 +- 5 files changed, 471 insertions(+), 137 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 20bd6b5c..e7282e9a 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -307,6 +307,8 @@ class CartController(object): self._append_errors(errors, ve) # Validate the discounts + # TODO: refactor in terms of available_discounts + # why aren't we doing that here?! discount_items = commerce.DiscountItem.objects.filter(cart=cart) seen_discounts = set() diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index db40d0c2..291a573a 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -4,7 +4,12 @@ import operator from collections import defaultdict from collections import namedtuple +from django.db.models import Case +from django.db.models import Count +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 @@ -12,6 +17,7 @@ from registrasion.models import conditions from registrasion.models import inventory + ConditionAndRemainder = namedtuple( "ConditionAndRemainder", ( @@ -21,16 +27,77 @@ ConditionAndRemainder = namedtuple( ) +_FlagCounter = namedtuple( + "_FlagCounter", + ( + "products", + "categories", + ), +) + + +_ConditionsCount = namedtuple( + "ConditionsCount", + ( + "dif", + "eit", + ), +) + + +class FlagCounter(_FlagCounter): + + @classmethod + def count(cls): + # 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) + + class ConditionController(object): ''' Base class for testing conditions that activate Flag or Discount objects. ''' - def __init__(self): - pass + def __init__(self, condition): + self.condition = condition @staticmethod - def for_condition(condition): - CONTROLLERS = { + def _controllers(): + return { conditions.CategoryFlag: CategoryConditionController, conditions.IncludedProductDiscount: ProductConditionController, conditions.ProductFlag: ProductConditionController, @@ -42,8 +109,14 @@ class ConditionController(object): conditions.VoucherFlag: VoucherConditionController, } + @staticmethod + def for_type(cls): + return ConditionController._controllers()[cls] + + @staticmethod + def for_condition(condition): try: - return CONTROLLERS[type(condition)](condition) + return ConditionController.for_type(type(condition))(condition) except KeyError: return ConditionController() @@ -91,20 +164,9 @@ class ConditionController(object): products = set(products) quantities = {} - # Get the conditions covered by the products themselves - prods = ( - product.flagbase_set.select_subclasses() - for product in products - ) - # Get the conditions covered by their categories - cats = ( - category.flagbase_set.select_subclasses() - for category in set(product.category for product in products) - ) - if products: # Simplify the query. - all_conditions = reduce(operator.or_, itertools.chain(prods, cats)) + all_conditions = cls._filtered_flags(user, products) else: all_conditions = [] @@ -114,11 +176,15 @@ class ConditionController(object): 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 = cls.for_condition(condition) - remainder = cond.user_quantity_remaining(user) + 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 @@ -149,14 +215,41 @@ class ConditionController(object): 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() + 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] = "Some disable-if-false " \ + "conditions were not met" + if f.eit > 0 and product not in do_enable: + do_enable[product] = False + if product not in messages: + messages[product] = "Some enable-if-true " \ + "conditions were not met" + 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) @@ -172,7 +265,71 @@ class ConditionController(object): return error_fields - def user_quantity_remaining(self, user): + @classmethod + def _filtered_flags(cls, user, products): + ''' + + 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)] + + # Get all flags for the products and categories. + prods = ( + product.flagbase_set.all() + for product in products + ) + cats = ( + category.flagbase_set.all() + for category in set(product.category for product in products) + ) + all_flags = reduce(operator.or_, itertools.chain(prods, cats)) + + all_subsets = [] + + for flagtype in flagtypes: + flags = flagtype.objects.filter(id__in=all_flags) + ctrl = ConditionController.for_type(flagtype) + flags = ctrl.pre_filter(flags, user) + all_subsets.append(flags) + + return itertools.chain(*all_subsets) + + @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. @@ -180,26 +337,37 @@ class ConditionController(object): Either this method, or is_met() must be overridden in subclasses. ''' - return 99999999 if self.is_met(user) else 0 + return _BIG_QUANTITY if self.is_met(user, filtered) else 0 - def is_met(self, user): + 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) > 0 + return self.user_quantity_remaining(user, filtered) > 0 -class CategoryConditionController(ConditionController): +class IsMetByFilter(object): - def __init__(self, condition): - self.condition = condition + 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. ''' - def is_met(self, user): - ''' returns True if the user has a product from a category that invokes - this condition in one of their carts ''' + if filtered: + return True # Why query again? + + return self.passes_filter(user) carts = commerce.Cart.objects.filter(user=user) carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) @@ -212,112 +380,176 @@ class CategoryConditionController(ConditionController): ).count() return products_count > 0 +class RemainderSetByFilter(object): -class ProductConditionController(ConditionController): + 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. ''' + + items = commerce.ProductItem.objects.filter(cart__user=user) + items = items.exclude(cart__status=commerce.Cart.STATUS_RELEASED) + items = items.select_related("product", "product__category") + categories = [item.product.category for item in items] + + return queryset.filter(enabling_category__in=categories) + + +class ProductConditionController(IsMetByFilter, ConditionController): ''' Condition tests for ProductFlag and IncludedProductDiscount. ''' - def __init__(self, condition): - self.condition = condition + @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. ''' - def is_met(self, user): - ''' returns True if the user has a product that invokes this - condition in one of their carts ''' + items = commerce.ProductItem.objects.filter(cart__user=user) + items = items.exclude(cart__status=commerce.Cart.STATUS_RELEASED) + items = items.select_related("product", "product__category") + products = [item.product for item in items] - carts = commerce.Cart.objects.filter(user=user) - carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) - products_count = commerce.ProductItem.objects.filter( - cart__in=carts, - product__in=self.condition.enabling_products.all(), - ).count() - return products_count > 0 + return queryset.filter(enabling_products__in=products) -class TimeOrStockLimitConditionController(ConditionController): +class TimeOrStockLimitConditionController( + RemainderSetByFilter, + ConditionController, + ): ''' Common condition tests for TimeOrStockLimit Flag and Discount.''' - def __init__(self, ceiling): - self.ceiling = ceiling + @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.''' - def user_quantity_remaining(self, user): - ''' returns 0 if the date range is violated, otherwise, it will return - the quantity remaining under the stock limit. ''' - - # Test date range - if not self._test_date_range(): - return 0 - - return self._get_remaining_stock(user) - - def _test_date_range(self): now = timezone.now() - if self.ceiling.start_time is not None: - if now < self.ceiling.start_time: - return False + # 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)) - if self.ceiling.end_time is not None: - if now > self.ceiling.end_time: - return False + # Filter out items that have been reserved beyond the limits + quantity_or_zero = self._calculate_quantities(user) - return True + remainder = Case( + When(limit=None, then=Value(_BIG_QUANTITY)), + default=F("limit") - Sum(quantity_or_zero), + ) - def _get_remaining_stock(self, user): - ''' Returns the stock that remains under this ceiling, excluding the - user's current cart. ''' + queryset = queryset.annotate(remainder=remainder) + queryset = queryset.filter(remainder__gt=0) - if self.ceiling.limit is None: - return 99999999 + return queryset - # We care about all reserved carts, but not the user's current cart + @classmethod + def _relevant_carts(cls, user): reserved_carts = commerce.Cart.reserved_carts() reserved_carts = reserved_carts.exclude( user=user, status=commerce.Cart.STATUS_ACTIVE, ) - - items = self._items() - items = items.filter(cart__in=reserved_carts) - count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - - return self.ceiling.limit - count + return reserved_carts class TimeOrStockLimitFlagController( TimeOrStockLimitConditionController): - def _items(self): - category_products = inventory.Product.objects.filter( - category__in=self.ceiling.categories.all(), - ) - products = self.ceiling.products.all() | category_products + @classmethod + def _calculate_quantities(cls, user): + reserved_carts = cls._relevant_carts(user) - product_items = commerce.ProductItem.objects.filter( - product__in=products.all(), + # Calculate category lines + cat_items = F('categories__product__productitem__product__category') + reserved_category_products = ( + Q(categories=cat_items) & + Q(categories__product__productitem__cart__in=reserved_carts) ) - return product_items + + # 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): - def _items(self): - discount_items = commerce.DiscountItem.objects.filter( - discount=self.ceiling, + @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" ) - return discount_items + + quantity_or_zero = Case( + quantity_in_reserved_carts, + default=Value(0) + ) + + return quantity_or_zero -class VoucherConditionController(ConditionController): +class VoucherConditionController(IsMetByFilter, ConditionController): ''' Condition test for VoucherFlag and VoucherDiscount.''' - def __init__(self, condition): - self.condition = condition + @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. ''' - def is_met(self, user): - ''' returns True if the user has the given voucher attached. ''' - carts_count = commerce.Cart.objects.filter( + carts = commerce.Cart.objects.filter( user=user, - vouchers=self.condition.voucher, - ).count() - return carts_count > 0 + ) + vouchers = [cart.vouchers.all() for cart in carts] + + return queryset.filter(voucher__in=itertools.chain(*vouchers)) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index a8f9282e..21d8a51b 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -4,7 +4,11 @@ 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 Q from django.db.models import Sum +from django.db.models import Value +from django.db.models import When class DiscountAndQuantity(object): @@ -43,6 +47,62 @@ def available_discounts(user, categories, products): and products. The discounts also list the available quantity for this user, not including products that are pending purchase. ''' + + + filtered_clauses = _filtered_discounts(user, categories, products) + + 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 = discount.past_use_count + + # TODO: add test case -- + # discount covers 2x prod_1 and 1x prod_2 + # add 1x prod_2 + # add 1x prod_1 + # checkout + # discount should be available for prod_1 + + 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 + + +def _filtered_discounts(user, categories, products): + ''' + + Returns: + Sequence[discountbase]: All discounts that passed the filter function. + + ''' + + types = list(ConditionController._controllers()) + discounttypes = [ + i for i in types if issubclass(i, conditions.DiscountBase) + ] + # discounts that match provided categories category_discounts = conditions.DiscountForCategory.objects.filter( category__in=categories @@ -67,51 +127,56 @@ def available_discounts(user, categories, products): "category", ) + valid_discounts = conditions.DiscountBase.objects.filter( + Q(discountforproduct__in=product_discounts) | + Q(discountforcategory__in=all_category_discounts) + ) + + all_subsets = [] + + for discounttype in discounttypes: + discounts = discounttype.objects.filter(id__in=valid_discounts) + ctrl = ConditionController.for_type(discounttype) + discounts = ctrl.pre_filter(discounts, user) + discounts = _annotate_with_past_uses(discounts, user) + all_subsets.append(discounts) + + filtered_discounts = list(itertools.chain(*all_subsets)) + + # Map from discount key to itself (contains annotations added by filter) + from_filter = dict((i.id, i) for i in filtered_discounts) + # The set of all potential discounts - potential_discounts = set(itertools.chain( - product_discounts, - all_category_discounts, + discount_clauses = set(itertools.chain( + product_discounts.filter(discount__in=filtered_discounts), + all_category_discounts.filter(discount__in=filtered_discounts), )) - discounts = [] + # 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] - # Markers so that we don't need to evaluate given conditions more than once - accepted_discounts = set() - failed_discounts = set() + return discount_clauses - for discount in potential_discounts: - real_discount = conditions.DiscountBase.objects.get_subclass( - pk=discount.discount.pk, - ) - cond = ConditionController.for_condition(real_discount) - # Count the past uses of the given discount item. - # If this user has exceeded the limit for the clause, this clause - # is not available any more. - past_uses = commerce.DiscountItem.objects.filter( - cart__user=user, - cart__status=commerce.Cart.STATUS_PAID, # Only past carts count - discount=real_discount, - ) - agg = past_uses.aggregate(Sum("quantity")) - past_use_count = agg["quantity__sum"] - if past_use_count is None: - past_use_count = 0 +def _annotate_with_past_uses(queryset, user): + ''' Annotates the queryset with a usage count for that discount by the + given user. ''' - if past_use_count >= discount.quantity: - # This clause has exceeded its use count - pass - elif real_discount not in failed_discounts: - # This clause is still available - if real_discount in accepted_discounts or cond.is_met(user): - # This clause is valid for this user - discounts.append(DiscountAndQuantity( - discount=real_discount, - clause=discount, - quantity=discount.quantity - past_use_count, - )) - accepted_discounts.add(real_discount) - else: - # This clause is not valid for this user - failed_discounts.add(real_discount) - return discounts + past_use_quantity = When( + ( + Q(discountitem__cart__user=user) & + Q(discountitem__cart__status=commerce.Cart.STATUS_PAID) + ), + then="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 diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 877556d0..1273a071 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -6,6 +6,8 @@ from django.core.exceptions import ValidationError from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase +from registrasion.controllers.discount import available_discounts +from registrasion.controllers.product import ProductController from registrasion.models import commerce from registrasion.models import conditions @@ -135,6 +137,39 @@ class CeilingsTestCases(RegistrationCartTestCase): 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 xrange(2): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + cart.next_cart() + + discounts = 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 xrange(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) diff --git a/setup.cfg b/setup.cfg index 290fdb45..9b05640c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [flake8] -exclude = registrasion/migrations/*, build/*, docs/* +exclude = registrasion/migrations/*, build/*, docs/*, dist/* From 145fd057aca06e846f55d33c23c79dd0249a6569 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 12:13:42 +1000 Subject: [PATCH 190/418] Breaks out flag-handling code into flag.py and FlagController --- registrasion/controllers/cart.py | 9 +- registrasion/controllers/conditions.py | 259 +------------------------ registrasion/controllers/flag.py | 257 ++++++++++++++++++++++++ registrasion/controllers/product.py | 7 +- 4 files changed, 268 insertions(+), 264 deletions(-) create mode 100644 registrasion/controllers/flag.py diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index e7282e9a..3eb4ce37 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -15,9 +15,10 @@ from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import inventory -from category import CategoryController -from conditions import ConditionController -from product import ProductController +from .category import CategoryController +from .conditions import ConditionController +from .flag import FlagController +from .product import ProductController def _modifies_cart(func): @@ -185,7 +186,7 @@ class CartController(object): )) # Test the flag conditions - errs = ConditionController.test_flags( + errs = FlagController.test_flags( self.cart.user, product_quantities=product_quantities, ) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 291a573a..c0847b10 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -18,74 +18,7 @@ from registrasion.models import inventory -ConditionAndRemainder = namedtuple( - "ConditionAndRemainder", - ( - "condition", - "remainder", - ), -) - - -_FlagCounter = namedtuple( - "_FlagCounter", - ( - "products", - "categories", - ), -) - - -_ConditionsCount = namedtuple( - "ConditionsCount", - ( - "dif", - "eit", - ), -) - - -class FlagCounter(_FlagCounter): - - @classmethod - def count(cls): - # 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) +_BIG_QUANTITY = 99999999 # A big quantity class ConditionController(object): @@ -120,184 +53,6 @@ class ConditionController(object): except KeyError: return ConditionController() - 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 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, products) - 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 = cls.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 - cond_products = condition.products.all() - from_category = inventory.Product.objects.filter( - category__in=condition.categories.all(), - ).all() - all_products = cond_products | from_category - all_products = all_products.select_related("category") - # Remove the products that we aren't asking about - all_products = [ - product - for product in all_products - if product in products - ] - - if quantities: - consumed = sum(quantities[i] for i in all_products) - else: - consumed = 1 - met = consumed <= remainder - - if not met: - items = ", ".join(str(product) for product in all_products) - base = cls.MESSAGE[remainder == 0][len(all_products) == 1] - message = base % {"items": items, "remainder": 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() - - 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] = "Some disable-if-false " \ - "conditions were not met" - if f.eit > 0 and product not in do_enable: - do_enable[product] = False - if product not in messages: - messages[product] = "Some enable-if-true " \ - "conditions were not met" - - 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 - - @classmethod - def _filtered_flags(cls, user, products): - ''' - - 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)] - - # Get all flags for the products and categories. - prods = ( - product.flagbase_set.all() - for product in products - ) - cats = ( - category.flagbase_set.all() - for category in set(product.category for product in products) - ) - all_flags = reduce(operator.or_, itertools.chain(prods, cats)) - - all_subsets = [] - - for flagtype in flagtypes: - flags = flagtype.objects.filter(id__in=all_flags) - ctrl = ConditionController.for_type(flagtype) - flags = ctrl.pre_filter(flags, user) - all_subsets.append(flags) - - return itertools.chain(*all_subsets) - @classmethod def pre_filter(cls, queryset, user): ''' Returns only the flag conditions that might be available for this @@ -369,16 +124,6 @@ class IsMetByFilter(object): return self.passes_filter(user) - carts = commerce.Cart.objects.filter(user=user) - carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) - enabling_products = inventory.Product.objects.filter( - category=self.condition.enabling_category, - ) - products_count = commerce.ProductItem.objects.filter( - cart__in=carts, - product__in=enabling_products, - ).count() - return products_count > 0 class RemainderSetByFilter(object): @@ -491,7 +236,7 @@ class TimeOrStockLimitFlagController( # Calculate category lines cat_items = F('categories__product__productitem__product__category') reserved_category_products = ( - Q(categories=cat_items) & + Q(categories=F('categories__product__productitem__product__category')) & Q(categories__product__productitem__cart__in=reserved_carts) ) diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py new file mode 100644 index 00000000..40901931 --- /dev/null +++ b/registrasion/controllers/flag.py @@ -0,0 +1,257 @@ +import itertools +import operator + +from collections import defaultdict +from collections import namedtuple +from django.db.models import Count + +from .conditions import ConditionController + +from registrasion.models import conditions +from registrasion.models import inventory + + +class FlagController(object): + + 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 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*. ''' + + print "GREPME: test_flags()" + + 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, products) + 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 + cond_products = condition.products.all() + from_category = inventory.Product.objects.filter( + category__in=condition.categories.all(), + ).all() + all_products = cond_products | from_category + all_products = all_products.select_related("category") + # Remove the products that we aren't asking about + all_products = [ + product + for product in all_products + if product in products + ] + + if quantities: + consumed = sum(quantities[i] for i in all_products) + else: + consumed = 1 + met = consumed <= remainder + + if not met: + items = ", ".join(str(product) for product in all_products) + base = cls.MESSAGE[remainder == 0][len(all_products) == 1] + message = base % {"items": items, "remainder": 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() + + 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] = "Some disable-if-false " \ + "conditions were not met" + if f.eit > 0 and product not in do_enable: + do_enable[product] = False + if product not in messages: + messages[product] = "Some enable-if-true " \ + "conditions were not met" + + 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 + + @classmethod + def _filtered_flags(cls, user, products): + ''' + + 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)] + + # Get all flags for the products and categories. + prods = ( + product.flagbase_set.all() + for product in products + ) + cats = ( + category.flagbase_set.all() + for category in set(product.category for product in products) + ) + all_flags = reduce(operator.or_, itertools.chain(prods, cats)) + + all_subsets = [] + + for flagtype in flagtypes: + flags = flagtype.objects.filter(id__in=all_flags) + ctrl = ConditionController.for_type(flagtype) + flags = ctrl.pre_filter(flags, user) + all_subsets.append(flags) + + return itertools.chain(*all_subsets) + + +ConditionAndRemainder = namedtuple( + "ConditionAndRemainder", + ( + "condition", + "remainder", + ), +) + + +_FlagCounter = namedtuple( + "_FlagCounter", + ( + "products", + "categories", + ), +) + + +_ConditionsCount = namedtuple( + "ConditionsCount", + ( + "dif", + "eit", + ), +) + + +class FlagCounter(_FlagCounter): + + @classmethod + def count(cls): + # 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) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 99618ded..09f66bb3 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -4,8 +4,9 @@ from django.db.models import Sum from registrasion.models import commerce from registrasion.models import inventory -from category import CategoryController -from conditions import ConditionController +from .category import CategoryController +from .conditions import ConditionController +from .flag import FlagController class ProductController(object): @@ -46,7 +47,7 @@ class ProductController(object): if cls(product).user_quantity_remaining(user) > 0 ) - failed_and_messages = ConditionController.test_flags( + failed_and_messages = FlagController.test_flags( user, products=passed_limits ) failed_conditions = set(i[0] for i in failed_and_messages) From 71de0df5dc5116d3154aedf507323e7e73c6a797 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 12:20:36 +1000 Subject: [PATCH 191/418] Makes DiscountController a class and puts available_discounts inside it --- registrasion/controllers/cart.py | 3 +- registrasion/controllers/discount.py | 215 +++++++++++++-------------- registrasion/tests/test_ceilings.py | 4 +- registrasion/tests/test_discount.py | 36 ++--- registrasion/views.py | 4 +- 5 files changed, 128 insertions(+), 134 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 3eb4ce37..597dd47b 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -17,6 +17,7 @@ from registrasion.models import inventory from .category import CategoryController from .conditions import ConditionController +from .discount import DiscountController from .flag import FlagController from .product import ProductController @@ -377,7 +378,7 @@ class CartController(object): ) products = [i.product for i in product_items] - discounts = discount.available_discounts(self.cart.user, [], products) + discounts = DiscountController.available_discounts(self.cart.user, [], products) # The highest-value discounts will apply to the highest-value # products first. diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 21d8a51b..c04ed42d 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -42,141 +42,134 @@ class DiscountAndQuantity(object): ) -def available_discounts(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. ''' +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_discounts(user, categories, products) - filtered_clauses = _filtered_discounts(user, categories, products) + discounts = [] - discounts = [] + # Markers so that we don't need to evaluate given conditions more than once + accepted_discounts = set() + failed_discounts = set() - # 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) - for clause in filtered_clauses: - discount = clause.discount - cond = ConditionController.for_condition(discount) - - past_use_count = discount.past_use_count - - # TODO: add test case -- - # discount covers 2x prod_1 and 1x prod_2 - # add 1x prod_2 - # add 1x prod_1 - # checkout - # discount should be available for prod_1 - - 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 + past_use_count = discount.past_use_count -def _filtered_discounts(user, categories, products): - ''' + 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 + if discount in accepted_discounts 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 - Returns: - Sequence[discountbase]: All discounts that passed the filter function. + @classmethod + def _filtered_discounts(cls, user, categories, products): + ''' - ''' + Returns: + Sequence[discountbase]: All discounts that passed the filter function. - types = list(ConditionController._controllers()) - discounttypes = [ - i for i in types if issubclass(i, conditions.DiscountBase) - ] + ''' - # discounts that match provided categories - category_discounts = conditions.DiscountForCategory.objects.filter( - category__in=categories - ) - # discounts that match provided products - product_discounts = conditions.DiscountForProduct.objects.filter( - product__in=products - ) - # discounts that match categories for provided products - product_category_discounts = conditions.DiscountForCategory.objects.filter( - category__in=(product.category for product in products) - ) - # (Not relevant: discounts that match products in provided categories) + types = list(ConditionController._controllers()) + discounttypes = [i for i in types if issubclass(i, conditions.DiscountBase)] - product_discounts = product_discounts.select_related( - "product", - "product__category", - ) + # discounts that match provided categories + category_discounts = conditions.DiscountForCategory.objects.filter( + category__in=categories + ) + # discounts that match provided products + product_discounts = conditions.DiscountForProduct.objects.filter( + product__in=products + ) + # discounts that match categories for provided products + product_category_discounts = conditions.DiscountForCategory.objects.filter( + category__in=(product.category for product in products) + ) + # (Not relevant: discounts that match products in provided categories) - all_category_discounts = category_discounts | product_category_discounts - all_category_discounts = all_category_discounts.select_related( - "category", - ) + product_discounts = product_discounts.select_related( + "product", + "product__category", + ) - valid_discounts = conditions.DiscountBase.objects.filter( - Q(discountforproduct__in=product_discounts) | - Q(discountforcategory__in=all_category_discounts) - ) + all_category_discounts = category_discounts | product_category_discounts + all_category_discounts = all_category_discounts.select_related( + "category", + ) - all_subsets = [] + valid_discounts = conditions.DiscountBase.objects.filter( + Q(discountforproduct__in=product_discounts) | + Q(discountforcategory__in=all_category_discounts) + ) - for discounttype in discounttypes: - discounts = discounttype.objects.filter(id__in=valid_discounts) - ctrl = ConditionController.for_type(discounttype) - discounts = ctrl.pre_filter(discounts, user) - discounts = _annotate_with_past_uses(discounts, user) - all_subsets.append(discounts) + all_subsets = [] - filtered_discounts = list(itertools.chain(*all_subsets)) + for discounttype in discounttypes: + discounts = discounttype.objects.filter(id__in=valid_discounts) + ctrl = ConditionController.for_type(discounttype) + discounts = ctrl.pre_filter(discounts, user) + discounts = cls._annotate_with_past_uses(discounts, user) + all_subsets.append(discounts) - # Map from discount key to itself (contains annotations added by filter) - from_filter = dict((i.id, i) for i in filtered_discounts) + filtered_discounts = list(itertools.chain(*all_subsets)) - # The set of all potential discounts - discount_clauses = set(itertools.chain( - product_discounts.filter(discount__in=filtered_discounts), - all_category_discounts.filter(discount__in=filtered_discounts), - )) + # Map from discount key to itself (contains annotations added by filter) + from_filter = dict((i.id, i) for i in filtered_discounts) - # 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] + # The set of all potential discounts + discount_clauses = set(itertools.chain( + product_discounts.filter(discount__in=filtered_discounts), + all_category_discounts.filter(discount__in=filtered_discounts), + )) - return discount_clauses + # 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 -def _annotate_with_past_uses(queryset, user): - ''' Annotates the queryset with a usage count for that discount by the - given user. ''' + @classmethod + def _annotate_with_past_uses(cls, queryset, user): + ''' Annotates the queryset with a usage count for that discount by the + given user. ''' - past_use_quantity = When( - ( - Q(discountitem__cart__user=user) & - Q(discountitem__cart__status=commerce.Cart.STATUS_PAID) - ), - then="discountitem__quantity", - ) + past_use_quantity = When( + ( + Q(discountitem__cart__user=user) & + Q(discountitem__cart__status=commerce.Cart.STATUS_PAID) + ), + then="discountitem__quantity", + ) - past_use_quantity_or_zero = Case( - past_use_quantity, - default=Value(0), - ) + 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 + queryset = queryset.annotate(past_use_count=Sum(past_use_quantity_or_zero)) + return queryset diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 1273a071..bd28b598 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase -from registrasion.controllers.discount import available_discounts +from registrasion.controllers.discount import DiscountController from registrasion.controllers.product import ProductController from registrasion.models import commerce from registrasion.models import conditions @@ -149,7 +149,7 @@ class CeilingsTestCases(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) cart.next_cart() - discounts = available_discounts(self.USER_1, [], [self.PROD_1]) + discounts = DiscountController.available_discounts(self.USER_1, [], [self.PROD_1]) self.assertEqual(0, len(discounts)) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 71d09d97..fd8302de 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -4,7 +4,7 @@ from decimal import Decimal from registrasion.models import commerce from registrasion.models import conditions -from registrasion.controllers import discount +from registrasion.controllers.discount import DiscountController from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase @@ -243,22 +243,22 @@ class DiscountTestCase(RegistrationCartTestCase): # The discount is applied. self.assertEqual(1, len(discount_items)) - # Tests for the discount.available_discounts enumerator + # Tests for the DiscountController.available_discounts enumerator def test_enumerate_no_discounts_for_no_input(self): - discounts = discount.available_discounts(self.USER_1, [], []) + 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 = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_3], ) self.assertEqual(0, len(discounts)) - discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + 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): @@ -267,7 +267,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [self.CAT_2], [self.PROD_3], @@ -280,7 +280,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + discounts = DiscountController.available_discounts(self.USER_1, [self.CAT_2], []) self.assertEqual(1, len(discounts)) def test_category_discount_appears_with_product(self): @@ -289,7 +289,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_3], @@ -302,7 +302,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_3, self.PROD_4] @@ -315,7 +315,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_2], @@ -328,7 +328,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) # Enable the discount - discounts = discount.available_discounts(self.USER_1, [self.CAT_1], []) + 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): @@ -338,7 +338,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) # Enable the discount cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity - discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + discounts = DiscountController.available_discounts(self.USER_1, [self.CAT_2], []) self.assertEqual(2, discounts[0].quantity) cart.next_cart() @@ -349,21 +349,21 @@ class DiscountTestCase(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity - discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + 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 = discount.available_discounts(self.USER_1, [self.CAT_2], []) + 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 = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_3, self.PROD_4], @@ -374,7 +374,7 @@ class DiscountTestCase(RegistrationCartTestCase): 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 = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_2], @@ -388,7 +388,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart.next_cart() - discounts = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_2], @@ -398,7 +398,7 @@ class DiscountTestCase(RegistrationCartTestCase): cart.cart.status = commerce.Cart.STATUS_RELEASED cart.cart.save() - discounts = discount.available_discounts( + discounts = DiscountController.available_discounts( self.USER_1, [], [self.PROD_2], diff --git a/registrasion/views.py b/registrasion/views.py index f10de90f..bdfcf551 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -5,7 +5,7 @@ from registrasion import util from registrasion.models import commerce from registrasion.models import inventory from registrasion.models import people -from registrasion.controllers import discount +from registrasion.controllers.discount import DiscountController from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController @@ -427,7 +427,7 @@ def _handle_products(request, category, products, prefix): ) handled = False if products_form.errors else True - discounts = discount.available_discounts(request.user, [], products) + discounts = DiscountController.available_discounts(request.user, [], products) return products_form, discounts, handled From 162db248178173c53d5ed3dec185eac884cb4204 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 12:39:20 +1000 Subject: [PATCH 192/418] Flake8 fixes --- registrasion/controllers/cart.py | 7 +++-- registrasion/controllers/conditions.py | 9 ++---- registrasion/controllers/discount.py | 33 +++++++++++++------- registrasion/controllers/flag.py | 15 ++++++--- registrasion/controllers/product.py | 1 - registrasion/tests/test_cart.py | 2 +- registrasion/tests/test_ceilings.py | 6 +++- registrasion/tests/test_discount.py | 42 +++++++++++++++++++++----- registrasion/views.py | 6 +++- 9 files changed, 86 insertions(+), 35 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 597dd47b..47340c15 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,6 +1,5 @@ import collections import datetime -import discount import functools import itertools @@ -378,7 +377,11 @@ class CartController(object): ) products = [i.product for i in product_items] - discounts = DiscountController.available_discounts(self.cart.user, [], products) + discounts = DiscountController.available_discounts( + self.cart.user, + [], + products, + ) # The highest-value discounts will apply to the highest-value # products first. diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index c0847b10..0a2b3c4a 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,11 +1,6 @@ import itertools -import operator - -from collections import defaultdict -from collections import namedtuple from django.db.models import Case -from django.db.models import Count from django.db.models import F, Q from django.db.models import Sum from django.db.models import Value @@ -234,9 +229,9 @@ class TimeOrStockLimitFlagController( reserved_carts = cls._relevant_carts(user) # Calculate category lines - cat_items = F('categories__product__productitem__product__category') + item_cats = F('categories__product__productitem__product__category') reserved_category_products = ( - Q(categories=F('categories__product__productitem__product__category')) & + Q(categories=item_cats) & Q(categories__product__productitem__cart__in=reserved_carts) ) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index c04ed42d..1c7fa59f 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -46,16 +46,17 @@ 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. ''' + ''' 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_discounts(user, categories, products) discounts = [] - # Markers so that we don't need to evaluate given conditions more than once + # Markers so that we don't need to evaluate given conditions + # more than once accepted_discounts = set() failed_discounts = set() @@ -71,7 +72,8 @@ class DiscountController(object): pass elif discount not in failed_discounts: # This clause is still available - if discount in accepted_discounts or cond.is_met(user, filtered=True): + 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, @@ -89,12 +91,15 @@ class DiscountController(object): ''' Returns: - Sequence[discountbase]: All discounts that passed the filter function. + Sequence[discountbase]: All discounts that passed the filter + function. ''' types = list(ConditionController._controllers()) - discounttypes = [i for i in types if issubclass(i, conditions.DiscountBase)] + discounttypes = [ + i for i in types if issubclass(i, conditions.DiscountBase) + ] # discounts that match provided categories category_discounts = conditions.DiscountForCategory.objects.filter( @@ -105,7 +110,8 @@ class DiscountController(object): product__in=products ) # discounts that match categories for provided products - product_category_discounts = conditions.DiscountForCategory.objects.filter( + product_category_discounts = conditions.DiscountForCategory.objects + product_category_discounts = product_category_discounts.filter( category__in=(product.category for product in products) ) # (Not relevant: discounts that match products in provided categories) @@ -115,7 +121,9 @@ class DiscountController(object): "product__category", ) - all_category_discounts = category_discounts | product_category_discounts + all_category_discounts = ( + category_discounts | product_category_discounts + ) all_category_discounts = all_category_discounts.select_related( "category", ) @@ -136,7 +144,8 @@ class DiscountController(object): filtered_discounts = list(itertools.chain(*all_subsets)) - # Map from discount key to itself (contains annotations added by filter) + # Map from discount key to itself + # (contains annotations needed in the future) from_filter = dict((i.id, i) for i in filtered_discounts) # The set of all potential discounts @@ -171,5 +180,7 @@ class DiscountController(object): default=Value(0), ) - queryset = queryset.annotate(past_use_count=Sum(past_use_quantity_or_zero)) + queryset = queryset.annotate( + past_use_count=Sum(past_use_quantity_or_zero) + ) return queryset diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index 40901931..a67a0b94 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -228,12 +228,19 @@ class FlagCounter(_FlagCounter): # 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) + 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')) + flagbases.filter( + condition=condition_type + ).values( + 'products', 'categories' + ).annotate( + count=Count('id') + ) for condition_type in types ] diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 09f66bb3..e1ad9ef7 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -5,7 +5,6 @@ from registrasion.models import commerce from registrasion.models import inventory from .category import CategoryController -from .conditions import ConditionController from .flag import FlagController diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 507d5cf7..790c1df9 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -26,7 +26,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): super(RegistrationCartTestCase, self).setUp() def tearDown(self): - if False: + if True: # If you're seeing segfaults in tests, enable this. call_command( 'flush', diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index bd28b598..87b54946 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -149,7 +149,11 @@ class CeilingsTestCases(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) cart.next_cart() - discounts = DiscountController.available_discounts(self.USER_1, [], [self.PROD_1]) + discounts = DiscountController.available_discounts( + self.USER_1, + [], + [self.PROD_1], + ) self.assertEqual(0, len(discounts)) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index fd8302de..4b92c81b 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -245,7 +245,11 @@ class DiscountTestCase(RegistrationCartTestCase): # Tests for the DiscountController.available_discounts enumerator def test_enumerate_no_discounts_for_no_input(self): - discounts = DiscountController.available_discounts(self.USER_1, [], []) + discounts = DiscountController.available_discounts( + self.USER_1, + [], + [], + ) self.assertEqual(0, len(discounts)) def test_enumerate_no_discounts_if_condition_not_met(self): @@ -258,7 +262,11 @@ class DiscountTestCase(RegistrationCartTestCase): ) self.assertEqual(0, len(discounts)) - discounts = DiscountController.available_discounts(self.USER_1, [self.CAT_2], []) + 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): @@ -280,7 +288,11 @@ class DiscountTestCase(RegistrationCartTestCase): 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], []) + discounts = DiscountController.available_discounts( + self.USER_1, + [self.CAT_2], + [], + ) self.assertEqual(1, len(discounts)) def test_category_discount_appears_with_product(self): @@ -328,7 +340,11 @@ class DiscountTestCase(RegistrationCartTestCase): 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], []) + 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): @@ -338,7 +354,11 @@ class DiscountTestCase(RegistrationCartTestCase): 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], []) + discounts = DiscountController.available_discounts( + self.USER_1, + [self.CAT_2], + [], + ) self.assertEqual(2, discounts[0].quantity) cart.next_cart() @@ -349,14 +369,22 @@ class DiscountTestCase(RegistrationCartTestCase): 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], []) + 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], []) + discounts = DiscountController.available_discounts( + self.USER_1, + [self.CAT_2], + [], + ) self.assertEqual(0, len(discounts)) def test_product_discount_enabled_twice_appears_twice(self): diff --git a/registrasion/views.py b/registrasion/views.py index bdfcf551..416afe0a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -427,7 +427,11 @@ def _handle_products(request, category, products, prefix): ) handled = False if products_form.errors else True - discounts = DiscountController.available_discounts(request.user, [], products) + discounts = DiscountController.available_discounts( + request.user, + [], + products, + ) return products_form, discounts, handled From 587e6e20b284e6f2522bf89900c16ea6682e3a67 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 14:01:36 +1000 Subject: [PATCH 193/418] Adds an operations_batch context manager that allows batches of modifying operations to be nested. Closes #44. --- registrasion/controllers/cart.py | 87 +++++++++++++++++++++++------ registrasion/controllers/invoice.py | 6 ++ 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 47340c15..48cec0dd 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,4 +1,5 @@ import collections +import contextlib import datetime import functools import itertools @@ -23,12 +24,19 @@ from .product import ProductController def _modifies_cart(func): ''' Decorator that makes the wrapped function raise ValidationError - if we're doing something that could modify the cart. ''' + if we're doing something that could modify the cart. + + It also wraps the execution of this function in a database transaction, + and marks the boundaries of a cart operations batch. + ''' @functools.wraps(func) def inner(self, *a, **k): self._fail_if_cart_is_not_active() - return func(self, *a, **k) + with transaction.atomic(): + with CartController.operations_batch(self.cart.user) as mark: + mark.mark = True # Marker that we've modified the cart + return func(self, *a, **k) return inner @@ -56,13 +64,57 @@ class CartController(object): ) return cls(existing) + + # Marks the carts that are currently in batches + _BATCH_COUNT = collections.defaultdict(int) + _MODIFIED_CARTS = set() + + class _ModificationMarker(object): + pass + + @classmethod + @contextlib.contextmanager + def operations_batch(cls, user): + ''' Marks the boundary for a batch of operations on a user's cart. + + These markers can be nested. Only on exiting the outermost marker will + a batch be ended. + + When a batch is ended, discounts are recalculated, and the cart's + revision is increased. + ''' + + ctrl = cls.for_user(user) + _id = ctrl.cart.id + + cls._BATCH_COUNT[_id] += 1 + try: + success = False + + marker = cls._ModificationMarker() + yield marker + + if hasattr(marker, "mark"): + cls._MODIFIED_CARTS.add(_id) + + success = True + finally: + + cls._BATCH_COUNT[_id] -= 1 + + # Only end on the outermost batch marker, and only if + # it excited cleanly, and a modification occurred + modified = _id in cls._MODIFIED_CARTS + if modified and cls._BATCH_COUNT[_id] == 0 and success: + ctrl._end_batch() + cls._MODIFIED_CARTS.remove(_id) + def _fail_if_cart_is_not_active(self): self.cart.refresh_from_db() if self.cart.status != commerce.Cart.STATUS_ACTIVE: raise ValidationError("You can only amend active carts.") - @_modifies_cart - def extend_reservation(self): + def _autoextend_reservation(self): ''' Updates the cart's time last updated value, which is used to determine whether the cart has reserved the items and discounts it holds. ''' @@ -84,21 +136,26 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) - @_modifies_cart - def end_batch(self): + def _end_batch(self): ''' Performs operations that occur occur at the end of a batch of product changes/voucher applications etc. - THIS SHOULD BE PRIVATE + + You need to call this after you've finished modifying the user's cart. + This is normally done by wrapping a block of code using + ``operations_batch``. + ''' - self.recalculate_discounts() - self.extend_reservation() + self.cart.refresh_from_db() + + self._recalculate_discounts() + + self._autoextend_reservation() self.cart.revision += 1 self.cart.save() @_modifies_cart - @transaction.atomic def set_quantities(self, product_quantities): ''' Sets the quantities on each of the products on each of the products specified. Raises an exception (ValidationError) if a limit @@ -140,8 +197,6 @@ class CartController(object): items_in_cart.filter(quantity=0).delete() - self.end_batch() - def _test_limits(self, product_quantities): ''' Tests that the quantity changes we intend to make do not violate the limits and flag conditions imposed on the products. ''' @@ -213,7 +268,6 @@ class CartController(object): # If successful... self.cart.vouchers.add(voucher) - self.end_batch() def _test_voucher(self, voucher): ''' Tests whether this voucher is allowed to be applied to this cart. @@ -331,7 +385,6 @@ class CartController(object): raise ValidationError(errors) @_modifies_cart - @transaction.atomic def fix_simple_errors(self): ''' This attempts to fix the easy errors raised by ValidationError. This includes removing items from the cart that are no longer @@ -363,11 +416,9 @@ class CartController(object): self.set_quantities(zeros) - @_modifies_cart @transaction.atomic - def recalculate_discounts(self): - ''' Calculates all of the discounts available for this product. - ''' + def _recalculate_discounts(self): + ''' Calculates all of the discounts available for this product.''' # Delete the existing entries. commerce.DiscountItem.objects.filter(cart=self.cart).delete() diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 62bab0b6..9ef155ef 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -29,6 +29,7 @@ class InvoiceController(ForId, object): 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, @@ -74,6 +75,8 @@ class InvoiceController(ForId, object): def _generate(cls, cart): ''' Generates an invoice for the given cart. ''' + cart.refresh_from_db() + issued = timezone.now() reservation_limit = cart.reservation_duration + cart.time_last_updated # Never generate a due time that is before the issue time @@ -251,6 +254,9 @@ class InvoiceController(ForId, object): 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 From 76e6206d09876645e12588e1569f95954c0631d6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 14:46:17 +1000 Subject: [PATCH 194/418] Wraps the guided registration handler in views.py in a batch marker --- registrasion/views.py | 48 ++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 416afe0a..e23b57a1 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -181,33 +181,35 @@ def guided_registration(request): attendee.save() return next_step - for category in cats: - products = [ - i for i in available_products - if i.category == category - ] + with CartController.operations_batch(request.user): + for category in cats: + products = [ + i for i in available_products + if i.category == category + ] - prefix = "category_" + str(category.id) - p = _handle_products(request, category, products, prefix) - products_form, discounts, products_handled = p + prefix = "category_" + str(category.id) + p = _handle_products(request, category, products, prefix) + products_form, discounts, products_handled = p - section = GuidedRegistrationSection( - title=category.name, - description=category.description, - discounts=discounts, - form=products_form, - ) + section = GuidedRegistrationSection( + title=category.name, + description=category.description, + discounts=discounts, + form=products_form, + ) - if products: - # This product category has items to show. - sections.append(section) - # Add this to the list of things to show if the form errors. - request.session[SESSION_KEY].append(category.id) + if products: + # This product category has items to show. + sections.append(section) + # Add this to the list of things to show if the form + # errors. + request.session[SESSION_KEY].append(category.id) - if request.method == "POST" and not products_form.errors: - # This is only saved if we pass each form with no errors, - # and if the form actually has products. - attendee.guided_categories_complete.add(category) + if request.method == "POST" and not products_form.errors: + # This is only saved if we pass each form with no + # errors, and if the form actually has products. + attendee.guided_categories_complete.add(category) if sections and request.method == "POST": for section in sections: From 3b5b958b78b63f2494d073aa1cc670a176cc55fd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 17:19:27 +1000 Subject: [PATCH 195/418] =?UTF-8?q?Makes=20the=20discounts=20section=20fro?= =?UTF-8?q?m=20=5Fhandle=5Fproducts=20evaluate=20lazily,=20just=20in=20cas?= =?UTF-8?q?e=20it=E2=80=99s=20never=20displayed=20in=20a=20template=20(tho?= =?UTF-8?q?se=20are=20some=20very=20very=20expensive=20queries=20there).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/util.py | 30 ++++++++++++++++++++++++++++++ registrasion/views.py | 6 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/registrasion/util.py b/registrasion/util.py index 7179ceb5..54f56a1e 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -25,3 +25,33 @@ def all_arguments_optional(ntcls): ) 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 diff --git a/registrasion/views.py b/registrasion/views.py index e23b57a1..41c4a0d6 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -429,7 +429,11 @@ def _handle_products(request, category, products, prefix): ) handled = False if products_form.errors else True - discounts = DiscountController.available_discounts( + # Making this a function to lazily evaluate when it's displayed + # in templates. + + discounts = util.lazy( + DiscountController.available_discounts, request.user, [], products, From a79ad3520e60af3415f234c32a0f3120b9ea31a5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 18:57:55 +1000 Subject: [PATCH 196/418] Puts attach_remainders on ProductController and CategoryController, eliminating the need to query each product and category separately. --- registrasion/controllers/cart.py | 17 ++++-- registrasion/controllers/category.py | 65 ++++++++++++++------- registrasion/controllers/product.py | 84 ++++++++++++++++++---------- 3 files changed, 113 insertions(+), 53 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 48cec0dd..30b36178 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -203,13 +203,17 @@ class CartController(object): errors = [] + # Pre-annotate products + products = [p for (p, q) in product_quantities] + r = ProductController.attach_user_remainders(self.cart.user, products) + with_remainders = dict((p, p) for p in r) + # Test each product limit here for product, quantity in product_quantities: if quantity < 0: errors.append((product, "Value must be zero or greater.")) - prod = ProductController(product) - limit = prod.user_quantity_remaining(self.cart.user) + limit = with_remainders[product].remainder if quantity > limit: errors.append(( @@ -224,10 +228,15 @@ class CartController(object): for product, quantity in product_quantities: by_cat[product.category].append((product, quantity)) + # Pre-annotate categories + r = CategoryController.attach_user_remainders(self.cart.user, by_cat) + with_remainders = dict((cat, cat) for cat in r) + # Test each category limit here for category in by_cat: - ctrl = CategoryController(category) - limit = ctrl.user_quantity_remaining(self.cart.user) + #ctrl = CategoryController(category) + #limit = ctrl.user_quantity_remaining(self.cart.user) + limit = with_remainders[category].remainder # Get the amount so far in the cart to_add = sum(i[1] for i in by_cat[category]) diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index a986eea7..c3f38ed9 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -1,7 +1,11 @@ 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 class AllProducts(object): @@ -34,25 +38,48 @@ class CategoryController(object): return set(i.category for i in available) + + @classmethod + def attach_user_remainders(cls, user, categories): + ''' + + Return: + queryset(inventory.Product): A queryset containing items from + ``categories``, with an extra attribute -- remainder = the amount + of items from this category that is remaining. + ''' + + ids = [category.id for category in categories] + categories = inventory.Category.objects.filter(id__in=ids) + + 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 categories + def user_quantity_remaining(self, user): - ''' Returns the number of items from this category that the user may - add in the current cart. ''' + ''' Returns the quantity of this product that the user add in the + current cart. ''' - cat_limit = self.category.limit_per_user + with_remainders = self.attach_user_remainders(user, [self.category]) - if cat_limit is None: - # We don't need to waste the following queries - return 99999999 - - carts = commerce.Cart.objects.filter( - user=user, - status=commerce.Cart.STATUS_PAID, - ) - - items = commerce.ProductItem.objects.filter( - cart__in=carts, - product__category=self.category, - ) - - cat_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - return cat_limit - cat_count + return with_remainders[0].remainder diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index e1ad9ef7..74783002 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,6 +1,11 @@ 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 @@ -16,9 +21,7 @@ class ProductController(object): @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. - TODO: refactor so that all conditions are tested here and - can_add_with_flags calls this method. ''' + flag conditions from the given categories. ''' if category is None and products is None: raise ValueError("You must provide products or a category") @@ -31,19 +34,18 @@ class ProductController(object): if products is not None: all_products = set(itertools.chain(all_products, products)) - cat_quants = dict( - ( - category, - CategoryController(category).user_quantity_remaining(user), - ) - for category in set(product.category for product in all_products) - ) + categories = set(product.category for product in all_products) + r = CategoryController.attach_user_remainders(user, categories) + cat_quants = dict((c,c) for c in r) + + r = ProductController.attach_user_remainders(user, all_products) + prod_quants = dict((p,p) for p in r) passed_limits = set( product for product in all_products - if cat_quants[product.category] > 0 - if cls(product).user_quantity_remaining(user) > 0 + if cat_quants[product.category].remainder > 0 + if prod_quants[product].remainder > 0 ) failed_and_messages = FlagController.test_flags( @@ -56,26 +58,48 @@ class ProductController(object): return out + + @classmethod + def attach_user_remainders(cls, user, products): + ''' + + Return: + queryset(inventory.Product): A queryset containing items from + ``product``, with an extra attribute -- remainder = the amount of + this item that is remaining. + ''' + + ids = [product.id for product in products] + products = inventory.Product.objects.filter(id__in=ids) + + 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 products + def user_quantity_remaining(self, user): ''' Returns the quantity of this product that the user add in the current cart. ''' - prod_limit = self.product.limit_per_user + with_remainders = self.attach_user_remainders(user, [self.product]) - if prod_limit is None: - # Don't need to run the remaining queries - return 999999 # We can do better - - carts = commerce.Cart.objects.filter( - user=user, - status=commerce.Cart.STATUS_PAID, - ) - - items = commerce.ProductItem.objects.filter( - cart__in=carts, - product=self.product, - ) - - prod_count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - - return prod_limit - prod_count + return with_remainders[0].remainder From fd5cf50fabd84b3dcfa9b926e66dee6fa09fef7c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 19:52:39 +1000 Subject: [PATCH 197/418] Makes items_purchased do more database work --- .../templatetags/registrasion_tags.py | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index fabc7754..6100d589 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -4,7 +4,11 @@ from registrasion.controllers.category import CategoryController from collections import namedtuple from django import template +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 register = template.Library() @@ -99,20 +103,33 @@ def items_purchased(context, category=None): ''' - all_items = commerce.ProductItem.objects.filter( - cart__user=context.request.user, - cart__status=commerce.Cart.STATUS_PAID, - ).select_related("product", "product__category") + in_cart=( + Q(productitem__cart__user=context.request.user) & + Q(productitem__cart__status=commerce.Cart.STATUS_PAID) + ) + + 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: - all_items = all_items.filter(product__category=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) - pq = all_items.values("product").annotate(quantity=Sum("quantity")).all() - products = inventory.Product.objects.all() out = [] - for item in pq: - prod = products.get(pk=item["product"]) - out.append(ProductAndQuantity(prod, item["quantity"])) + for prod in products: + out.append(ProductAndQuantity(prod, prod.quantity)) return out From 4fb569d9353dffaeb067cf314ef55b3561e97388 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 19:58:09 +1000 Subject: [PATCH 198/418] Does more select_related and bulk_create calls --- registrasion/controllers/cart.py | 1 + registrasion/controllers/invoice.py | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 30b36178..7e1e9072 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -358,6 +358,7 @@ class CartController(object): 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: diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 9ef155ef..da737a73 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -99,6 +99,10 @@ class InvoiceController(ForId, object): ) product_items = commerce.ProductItem.objects.filter(cart=cart) + product_items = product_items.select_related( + "product", + "product__category", + ) if len(product_items) == 0: raise ValidationError("Your cart is empty.") @@ -106,29 +110,41 @@ class InvoiceController(ForId, object): product_items = product_items.order_by( "product__category__order", "product__order" ) + discount_items = commerce.DiscountItem.objects.filter(cart=cart) + discount_items = discount_items.select_related( + "discount", + "product", + "product__category", + ) + + line_items = [] + invoice_value = Decimal() for item in product_items: product = item.product - line_item = commerce.LineItem.objects.create( + line_item = commerce.LineItem( invoice=invoice, description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, price=product.price, product=product, ) + line_items.append(line_item) invoice_value += line_item.quantity * line_item.price - for item in discount_items: - line_item = commerce.LineItem.objects.create( + line_item = commerce.LineItem( invoice=invoice, description=item.discount.description, quantity=item.quantity, price=cls.resolve_discount_value(item) * -1, product=item.product, ) + line_items.append(line_item) invoice_value += line_item.quantity * line_item.price + commerce.LineItem.objects.bulk_create(line_items) + invoice.value = invoice_value invoice.save() From 6d52a4c18ff85785b6729073158eedd128158c61 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 28 Apr 2016 20:15:21 +1000 Subject: [PATCH 199/418] More low-hanging query optimisations --- registrasion/controllers/cart.py | 37 ++++++++++++++++---------- registrasion/controllers/conditions.py | 35 +++++++++++++----------- registrasion/controllers/flag.py | 22 +++++++-------- registrasion/views.py | 7 +++-- 4 files changed, 58 insertions(+), 43 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 7e1e9072..83e08ef8 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -8,6 +8,7 @@ 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 @@ -84,6 +85,8 @@ class CartController(object): revision is increased. ''' + # TODO cache carts mid-batch? + ctrl = cls.for_user(user) _id = ctrl.cart.id @@ -180,22 +183,28 @@ class CartController(object): # Validate that the limits we're adding are OK self._test_limits(all_product_quantities) + new_items = [] + products = [] for product, quantity in product_quantities: - try: - product_item = commerce.ProductItem.objects.get( - cart=self.cart, - product=product, - ) - product_item.quantity = quantity - product_item.save() - except ObjectDoesNotExist: - commerce.ProductItem.objects.create( - cart=self.cart, - product=product, - quantity=quantity, - ) + products.append(product) - items_in_cart.filter(quantity=0).delete() + 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 diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 0a2b3c4a..2480a4fc 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -154,12 +154,17 @@ class CategoryConditionController(IsMetByFilter, ConditionController): product from a category invoking that item's condition in one of their carts. ''' - items = commerce.ProductItem.objects.filter(cart__user=user) - items = items.exclude(cart__status=commerce.Cart.STATUS_RELEASED) - items = items.select_related("product", "product__category") - categories = [item.product.category for item in items] + 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.filter(enabling_category__in=categories) + return queryset class ProductConditionController(IsMetByFilter, ConditionController): @@ -171,12 +176,15 @@ class ProductConditionController(IsMetByFilter, ConditionController): ''' Returns all of the items from queryset where the user has a product invoking that item's condition in one of their carts. ''' - items = commerce.ProductItem.objects.filter(cart__user=user) - items = items.exclude(cart__status=commerce.Cart.STATUS_RELEASED) - items = items.select_related("product", "product__category") - products = [item.product for item in items] + in_user_carts = Q(enabling_products__productitem__cart__user=user) + released = commerce.Cart.STATUS_RELEASED + in_released_carts = Q( + enabling_products__productitem__cart__status=released + ) + queryset = queryset.filter(in_user_carts) + queryset = queryset.exclude(in_released_carts) - return queryset.filter(enabling_products__in=products) + return queryset class TimeOrStockLimitConditionController( @@ -287,9 +295,4 @@ class VoucherConditionController(IsMetByFilter, ConditionController): ''' 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. ''' - carts = commerce.Cart.objects.filter( - user=user, - ) - vouchers = [cart.vouchers.all() for cart in carts] - - return queryset.filter(voucher__in=itertools.chain(*vouchers)) + return queryset.filter(voucher__cart__user=user) diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index a67a0b94..aa11d53e 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -4,6 +4,7 @@ import operator from collections import defaultdict from collections import namedtuple from django.db.models import Count +from django.db.models import Q from .conditions import ConditionController @@ -83,18 +84,16 @@ class FlagController(object): # Get all products covered by this condition, and the products # from the categories covered by this condition - cond_products = condition.products.all() - from_category = inventory.Product.objects.filter( - category__in=condition.categories.all(), - ).all() - all_products = cond_products | from_category + + ids = [product.id for product in products] + 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") - # Remove the products that we aren't asking about - all_products = [ - product - for product in all_products - if product in products - ] if quantities: consumed = sum(quantities[i] for i in all_products) @@ -221,6 +220,7 @@ _ConditionsCount = namedtuple( ) +# TODO: this should be cacheable. class FlagCounter(_FlagCounter): @classmethod diff --git a/registrasion/views.py b/registrasion/views.py index 41c4a0d6..a7917406 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -445,14 +445,17 @@ def _handle_products(request, category, products, prefix): def _set_quantities_from_products_form(products_form, current_cart): quantities = list(products_form.product_quantities()) - + id_to_quantity = dict(i[:2] for i in quantities) pks = [i[0] for i in quantities] products = inventory.Product.objects.filter( id__in=pks, ).select_related("category") + + + # TODO: This is fundamentally dumb product_quantities = [ - (products.get(pk=i[0]), i[1]) for i in quantities + (product, id_to_quantity[product.id]) for product in products ] field_names = dict( (i[0][0], i[1][2]) for i in zip(product_quantities, quantities) From 02fe88a4e4db19943791a7a07a20890685d1bf5e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 29 Apr 2016 10:08:21 +1000 Subject: [PATCH 200/418] Tests and fixes for a bug where discount quantities did not respect per-line item quantities. --- registrasion/controllers/discount.py | 44 ++++++++++++++++++---------- registrasion/tests/test_discount.py | 23 +++++++++++++++ 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 1c7fa59f..164d95cc 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -5,7 +5,7 @@ from registrasion.models import commerce from registrasion.models import conditions from django.db.models import Case -from django.db.models import Q +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 @@ -64,9 +64,7 @@ class DiscountController(object): discount = clause.discount cond = ConditionController.for_condition(discount) - past_use_count = discount.past_use_count - - + past_use_count = clause.past_use_count if past_use_count >= clause.quantity: # This clause has exceeded its use count pass @@ -139,7 +137,6 @@ class DiscountController(object): discounts = discounttype.objects.filter(id__in=valid_discounts) ctrl = ConditionController.for_type(discounttype) discounts = ctrl.pre_filter(discounts, user) - discounts = cls._annotate_with_past_uses(discounts, user) all_subsets.append(discounts) filtered_discounts = list(itertools.chain(*all_subsets)) @@ -148,11 +145,17 @@ class DiscountController(object): # (contains annotations needed in the future) from_filter = dict((i.id, i) for i in filtered_discounts) - # The set of all potential discounts - discount_clauses = set(itertools.chain( + clause_sets = ( product_discounts.filter(discount__in=filtered_discounts), all_category_discounts.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 @@ -164,15 +167,26 @@ class DiscountController(object): @classmethod def _annotate_with_past_uses(cls, queryset, user): - ''' Annotates the queryset with a usage count for that discount by the - given 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( - ( - Q(discountitem__cart__user=user) & - Q(discountitem__cart__status=commerce.Cart.STATUS_PAID) - ), - then="discountitem__quantity", + in_carts & matches, + then="discount__discountitem__quantity", ) past_use_quantity_or_zero = Case( diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 4b92c81b..d7920a10 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -398,6 +398,29 @@ class DiscountTestCase(RegistrationCartTestCase): ) 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) From 4eff8194f96f59d311f40280c2e9bdcd38731d22 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 29 Apr 2016 10:48:51 +1000 Subject: [PATCH 201/418] Reduces CartController re-loading when batching operations --- registrasion/controllers/cart.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 83e08ef8..b445a802 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -67,6 +67,7 @@ class CartController(object): # Marks the carts that are currently in batches + _FOR_USER = {} _BATCH_COUNT = collections.defaultdict(int) _MODIFIED_CARTS = set() @@ -85,10 +86,11 @@ class CartController(object): revision is increased. ''' - # TODO cache carts mid-batch? + if user not in cls._FOR_USER: + _ctrl = cls.for_user(user) + cls._FOR_USER[user] = (_ctrl, _ctrl.cart.id) - ctrl = cls.for_user(user) - _id = ctrl.cart.id + ctrl, _id = cls._FOR_USER[user] cls._BATCH_COUNT[_id] += 1 try: @@ -108,10 +110,15 @@ class CartController(object): # Only end on the outermost batch marker, and only if # it excited cleanly, and a modification occurred modified = _id in cls._MODIFIED_CARTS - if modified and cls._BATCH_COUNT[_id] == 0 and success: + outermost = cls._BATCH_COUNT[_id] == 0 + if modified and outermost and success: ctrl._end_batch() cls._MODIFIED_CARTS.remove(_id) + # Clear out the cache on the outermost operation + if outermost: + del cls._FOR_USER[user] + def _fail_if_cart_is_not_active(self): self.cart.refresh_from_db() if self.cart.status != commerce.Cart.STATUS_ACTIVE: From 135f2fb47b13dcbd31b640449dabe0cdcee80df9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 29 Apr 2016 10:57:33 +1000 Subject: [PATCH 202/418] Refactors discounts validation in terms of available_discounts --- registrasion/controllers/cart.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index b445a802..5c5cf4f8 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -390,19 +390,22 @@ class CartController(object): # Validate the discounts # TODO: refactor in terms of available_discounts # why aren't we doing that here?! - discount_items = commerce.DiscountItem.objects.filter(cart=cart) - seen_discounts = set() + # 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 in seen_discounts: - continue - seen_discounts.add(discount) - real_discount = conditions.DiscountBase.objects.get_subclass( - pk=discount.pk) - cond = ConditionController.for_condition(real_discount) - if not cond.is_met(user): + if discount.id not in discounts: errors.append( ValidationError("Discounts are no longer available") ) From b40505117f4c9e2abe4464ffa6505d23a1440ac2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 29 Apr 2016 11:22:56 +1000 Subject: [PATCH 203/418] Fixes flake8 errors arising from rebase --- registrasion/controllers/cart.py | 5 ----- registrasion/controllers/category.py | 1 - registrasion/controllers/conditions.py | 12 +++--------- registrasion/controllers/discount.py | 1 - registrasion/controllers/product.py | 5 ++--- registrasion/templatetags/registrasion_tags.py | 2 +- registrasion/views.py | 3 --- 7 files changed, 6 insertions(+), 23 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 5c5cf4f8..dbf7e8a0 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -17,7 +17,6 @@ from registrasion.models import conditions from registrasion.models import inventory from .category import CategoryController -from .conditions import ConditionController from .discount import DiscountController from .flag import FlagController from .product import ProductController @@ -65,7 +64,6 @@ class CartController(object): ) return cls(existing) - # Marks the carts that are currently in batches _FOR_USER = {} _BATCH_COUNT = collections.defaultdict(int) @@ -156,7 +154,6 @@ class CartController(object): ''' - self.cart.refresh_from_db() self._recalculate_discounts() @@ -250,8 +247,6 @@ class CartController(object): # Test each category limit here for category in by_cat: - #ctrl = CategoryController(category) - #limit = ctrl.user_quantity_remaining(self.cart.user) limit = with_remainders[category].remainder # Get the amount so far in the cart diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index c3f38ed9..9db8ca9e 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -38,7 +38,6 @@ class CategoryController(object): return set(i.category for i in available) - @classmethod def attach_user_remainders(cls, user, categories): ''' diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 2480a4fc..51078016 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,5 +1,3 @@ -import itertools - from django.db.models import Case from django.db.models import F, Q from django.db.models import Sum @@ -9,8 +7,6 @@ from django.utils import timezone from registrasion.models import commerce from registrasion.models import conditions -from registrasion.models import inventory - _BIG_QUANTITY = 99999999 # A big quantity @@ -134,8 +130,6 @@ class RemainderSetByFilter(object): 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) @@ -188,9 +182,9 @@ class ProductConditionController(IsMetByFilter, ConditionController): class TimeOrStockLimitConditionController( - RemainderSetByFilter, - ConditionController, - ): + RemainderSetByFilter, + ConditionController, + ): ''' Common condition tests for TimeOrStockLimit Flag and Discount.''' diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 164d95cc..f4f88ed2 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -50,7 +50,6 @@ class DiscountController(object): categories and products. The discounts also list the available quantity for this user, not including products that are pending purchase. ''' - filtered_clauses = cls._filtered_discounts(user, categories, products) discounts = [] diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 74783002..610c7f0d 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -36,10 +36,10 @@ class ProductController(object): categories = set(product.category for product in all_products) r = CategoryController.attach_user_remainders(user, categories) - cat_quants = dict((c,c) for c in r) + cat_quants = dict((c, c) for c in r) r = ProductController.attach_user_remainders(user, all_products) - prod_quants = dict((p,p) for p in r) + prod_quants = dict((p, p) for p in r) passed_limits = set( product @@ -58,7 +58,6 @@ class ProductController(object): return out - @classmethod def attach_user_remainders(cls, user, products): ''' diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 6100d589..9074781c 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -103,7 +103,7 @@ def items_purchased(context, category=None): ''' - in_cart=( + in_cart = ( Q(productitem__cart__user=context.request.user) & Q(productitem__cart__status=commerce.Cart.STATUS_PAID) ) diff --git a/registrasion/views.py b/registrasion/views.py index a7917406..a4dcceac 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -451,9 +451,6 @@ def _set_quantities_from_products_form(products_form, current_cart): id__in=pks, ).select_related("category") - - - # TODO: This is fundamentally dumb product_quantities = [ (product, id_to_quantity[product.id]) for product in products ] From 941caa30d9f4216675d37f7cdc9ae7523b22db14 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 30 Apr 2016 20:30:21 +1000 Subject: [PATCH 204/418] Replaces ProductController.attach_user_remainders with ProductController.user_remainders --- registrasion/controllers/cart.py | 6 ++---- registrasion/controllers/product.py | 25 +++++++------------------ 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index dbf7e8a0..2ff9f171 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -217,16 +217,14 @@ class CartController(object): errors = [] # Pre-annotate products - products = [p for (p, q) in product_quantities] - r = ProductController.attach_user_remainders(self.cart.user, products) - with_remainders = dict((p, p) for p in r) + 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 = with_remainders[product].remainder + limit = remainders[product.id] if quantity > limit: errors.append(( diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 610c7f0d..0e2e984f 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -38,14 +38,13 @@ class ProductController(object): r = CategoryController.attach_user_remainders(user, categories) cat_quants = dict((c, c) for c in r) - r = ProductController.attach_user_remainders(user, all_products) - prod_quants = dict((p, p) for p in r) + product_remainders = ProductController.user_remainders(user) passed_limits = set( product for product in all_products if cat_quants[product.category].remainder > 0 - if prod_quants[product].remainder > 0 + if product_remainders[product.id] > 0 ) failed_and_messages = FlagController.test_flags( @@ -59,17 +58,15 @@ class ProductController(object): return out @classmethod - def attach_user_remainders(cls, user, products): + def user_remainders(cls, user): ''' Return: - queryset(inventory.Product): A queryset containing items from - ``product``, with an extra attribute -- remainder = the amount of - this item that is remaining. + Mapping[int->int]: A dictionary that maps the product ID to the + user's remainder for that product. ''' - ids = [product.id for product in products] - products = inventory.Product.objects.filter(id__in=ids) + products = inventory.Product.objects.all() cart_filter = ( Q(productitem__cart__user=user) & @@ -93,12 +90,4 @@ class ProductController(object): products = products.annotate(remainder=remainder) - return products - - def user_quantity_remaining(self, user): - ''' Returns the quantity of this product that the user add in the - current cart. ''' - - with_remainders = self.attach_user_remainders(user, [self.product]) - - return with_remainders[0].remainder + return dict((product.id, product.remainder) for product in products) From c6fdfa496e844ad4dde24e06c9fd03ff30b36484 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 30 Apr 2016 20:30:44 +1000 Subject: [PATCH 205/418] Replaces CategoryController.attach_user_remainders with user_remainders --- registrasion/controllers/cart.py | 5 ++--- registrasion/controllers/category.py | 21 ++++++--------------- registrasion/controllers/product.py | 7 ++----- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 2ff9f171..283c3119 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -240,12 +240,11 @@ class CartController(object): by_cat[product.category].append((product, quantity)) # Pre-annotate categories - r = CategoryController.attach_user_remainders(self.cart.user, by_cat) - with_remainders = dict((cat, cat) for cat in r) + remainders = CategoryController.user_remainders(self.cart.user) # Test each category limit here for category in by_cat: - limit = with_remainders[category].remainder + limit = remainders[category.id] # Get the amount so far in the cart to_add = sum(i[1] for i in by_cat[category]) diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 9db8ca9e..4681f48b 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -39,17 +39,16 @@ class CategoryController(object): return set(i.category for i in available) @classmethod - def attach_user_remainders(cls, user, categories): + def user_remainders(cls, user): ''' Return: - queryset(inventory.Product): A queryset containing items from - ``categories``, with an extra attribute -- remainder = the amount - of items from this category that is remaining. + Mapping[int->int]: A dictionary that maps the category ID to the + user's remainder for that category. + ''' - ids = [category.id for category in categories] - categories = inventory.Category.objects.filter(id__in=ids) + categories = inventory.Category.objects.all() cart_filter = ( Q(product__productitem__cart__user=user) & @@ -73,12 +72,4 @@ class CategoryController(object): categories = categories.annotate(remainder=remainder) - return categories - - def user_quantity_remaining(self, user): - ''' Returns the quantity of this product that the user add in the - current cart. ''' - - with_remainders = self.attach_user_remainders(user, [self.category]) - - return with_remainders[0].remainder + return dict((cat.id, cat.remainder) for cat in categories) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 0e2e984f..0810902b 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -34,16 +34,13 @@ class ProductController(object): if products is not None: all_products = set(itertools.chain(all_products, products)) - categories = set(product.category for product in all_products) - r = CategoryController.attach_user_remainders(user, categories) - cat_quants = dict((c, c) for c in r) - + category_remainders = CategoryController.user_remainders(user) product_remainders = ProductController.user_remainders(user) passed_limits = set( product for product in all_products - if cat_quants[product.category].remainder > 0 + if category_remainders[product.category.id] > 0 if product_remainders[product.id] > 0 ) From b3491cab8e1fc59a1ef0ab94230ad84907e8a924 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 30 Apr 2016 20:42:41 +1000 Subject: [PATCH 206/418] _filtered_flags now no longer cares about products for filtering. It just does everything. --- registrasion/controllers/flag.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index aa11d53e..97456478 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -47,8 +47,6 @@ class FlagController(object): a list is returned containing all of the products that are *not enabled*. ''' - print "GREPME: test_flags()" - if products is not None and product_quantities is not None: raise ValueError("Please specify only products or " "product_quantities") @@ -62,7 +60,7 @@ class FlagController(object): if products: # Simplify the query. - all_conditions = cls._filtered_flags(user, products) + all_conditions = cls._filtered_flags(user) else: all_conditions = [] @@ -160,7 +158,7 @@ class FlagController(object): return error_fields @classmethod - def _filtered_flags(cls, user, products): + def _filtered_flags(cls, user): ''' Returns: @@ -171,16 +169,7 @@ class FlagController(object): types = list(ConditionController._controllers()) flagtypes = [i for i in types if issubclass(i, conditions.FlagBase)] - # Get all flags for the products and categories. - prods = ( - product.flagbase_set.all() - for product in products - ) - cats = ( - category.flagbase_set.all() - for category in set(product.category for product in products) - ) - all_flags = reduce(operator.or_, itertools.chain(prods, cats)) + all_flags = conditions.FlagBase.objects.all() all_subsets = [] From 162a1f23dd2e54ddd2404657a4774dd393a4a9db Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 08:47:53 +1000 Subject: [PATCH 207/418] _filtered_discounts is now called _filtered_clauses, and it no longer cares about specific products or categories --- registrasion/controllers/discount.py | 53 +++++++++++++--------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index f4f88ed2..f0df1b07 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -50,7 +50,22 @@ class DiscountController(object): categories and products. The discounts also list the available quantity for this user, not including products that are pending purchase. ''' - filtered_clauses = cls._filtered_discounts(user, categories, products) + filtered_clauses = cls._filtered_clauses(user, categories, products) + + # 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 = [] @@ -84,7 +99,7 @@ class DiscountController(object): return discounts @classmethod - def _filtered_discounts(cls, user, categories, products): + def _filtered_clauses(cls, user): ''' Returns: @@ -98,37 +113,17 @@ class DiscountController(object): i for i in types if issubclass(i, conditions.DiscountBase) ] - # discounts that match provided categories - category_discounts = conditions.DiscountForCategory.objects.filter( - category__in=categories - ) - # discounts that match provided products - product_discounts = conditions.DiscountForProduct.objects.filter( - product__in=products - ) - # discounts that match categories for provided products - product_category_discounts = conditions.DiscountForCategory.objects - product_category_discounts = product_category_discounts.filter( - category__in=(product.category for product in products) - ) - # (Not relevant: discounts that match products in provided categories) - - product_discounts = product_discounts.select_related( + product_clauses = conditions.DiscountForProduct.objects.all() + product_clauses = product_clauses.select_related( "product", "product__category", ) - - all_category_discounts = ( - category_discounts | product_category_discounts - ) - all_category_discounts = all_category_discounts.select_related( + category_clauses = conditions.DiscountForCategory.objects.all() + category_clauses = category_clauses.select_related( "category", ) - valid_discounts = conditions.DiscountBase.objects.filter( - Q(discountforproduct__in=product_discounts) | - Q(discountforcategory__in=all_category_discounts) - ) + valid_discounts = conditions.DiscountBase.objects.all() all_subsets = [] @@ -145,8 +140,8 @@ class DiscountController(object): from_filter = dict((i.id, i) for i in filtered_discounts) clause_sets = ( - product_discounts.filter(discount__in=filtered_discounts), - all_category_discounts.filter(discount__in=filtered_discounts), + product_clauses.filter(discount__in=filtered_discounts), + category_clauses.filter(discount__in=filtered_discounts), ) clause_sets = ( From 78a41970ea48428dde22d5af5b17564be063bd17 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 30 Apr 2016 21:42:02 +1000 Subject: [PATCH 208/418] Adds design for BatchController --- registrasion/controllers/batch.py | 85 ++++++++++++++++++++++++++++ registrasion/controllers/discount.py | 6 +- 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 registrasion/controllers/batch.py diff --git a/registrasion/controllers/batch.py b/registrasion/controllers/batch.py new file mode 100644 index 00000000..2e5e0241 --- /dev/null +++ b/registrasion/controllers/batch.py @@ -0,0 +1,85 @@ +import contextlib +import functools + + +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 = {} + + @classmethod + @contextlib.contextmanager + def batch(cls, user): + ''' Marks the entry point for a batch for the given user. ''' + pass + # TODO: store nesting count *inside* the cache object. You know it + # makes sense. + + @classmethod + def memoise(cls, func): + ''' Decorator that stores the result of the stored function in the + user's results cache until the batch completes. + + Arguments: + func (callable(user, *a, **k)): The function whose results we want + to store. ``user`` must be the first argument; this is used as + the cache key. + + Returns: + callable(user, *a, **k): The memosing version of ``func``. + + ''' + + @functools.wraps(func) + def f(user, *a, **k): + + cache = cls.get_cache(user) + if func not in cache: + cache[func] = func(user, *a, **k) + + return cache[func] + + return f + + @classmethod + def get_cache(cls, user): + if user not in cls._user_caches: + return {} # Return blank cache here, we'll just discard :) + + return cls._user_caches[user] + + +''' +TODO: memoise CartController.for_user +TODO: memoise user_remainders (Product, Category) +TODO: memoise _filtered_flags +TODO: memoise FlagCounter.count() (doesn't take user, but it'll do for now) +TODO: memoise _filtered_discounts + +Tests: +- Correct nesting behaviour + - do we get different cache objects every time we get a cache in non-batched + contexts? + - do we get the same cache object for nested caches? + - do we get different cache objects when we back out of a batch and enter a + new one +- are cache clears independent for different users? +- ``end_batch`` behaviour for CartController (use for_user *A LOT*) + - discounts not calculated until outermost batch point exits. + - Revision number shouldn't change until outermost batch point exits. +- Make sure memoisation ONLY happens when we're in a batch. + +''' diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index f0df1b07..29bc1ec6 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -50,7 +50,7 @@ class DiscountController(object): 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, categories, products) + filtered_clauses = cls._filtered_clauses(user) # clauses that match provided categories categories = set(categories) @@ -103,8 +103,8 @@ class DiscountController(object): ''' Returns: - Sequence[discountbase]: All discounts that passed the filter - function. + Sequence[DiscountForProduct | DiscountForCategory]: All clauses + that passed the filter function. ''' From eb29e7cd0977103c1d8b5ad6cb128cfa9cac8ac4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 10:46:40 +1000 Subject: [PATCH 209/418] Adds test cases for basic batch cacheing behaviour --- registrasion/tests/test_batch.py | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 registrasion/tests/test_batch.py diff --git a/registrasion/tests/test_batch.py b/registrasion/tests/test_batch.py new file mode 100644 index 00000000..95c3fbc0 --- /dev/null +++ b/registrasion/tests/test_batch.py @@ -0,0 +1,83 @@ +import datetime +import pytz + +from django.core.exceptions import ValidationError + +from controller_helpers import TestingCartController +from test_cart import RegistrationCartTestCase + +from registrasion.controllers.batch import BatchController +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 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) From ddedf54c42cfccaac5c438c638ae76fcfa34d6fc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 10:36:52 +1000 Subject: [PATCH 210/418] Adds batch context manager behaviour --- registrasion/controllers/batch.py | 45 +++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/registrasion/controllers/batch.py b/registrasion/controllers/batch.py index 2e5e0241..00001a86 100644 --- a/registrasion/controllers/batch.py +++ b/registrasion/controllers/batch.py @@ -19,14 +19,37 @@ class BatchController(object): ''' _user_caches = {} + _NESTING_KEY = "nesting_count" @classmethod @contextlib.contextmanager def batch(cls, user): ''' Marks the entry point for a batch for the given user. ''' - pass - # TODO: store nesting count *inside* the cache object. You know it - # makes sense. + + 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: + # TODO: Handle batch end cases + + del cls._user_caches[user] @classmethod def memoise(cls, func): @@ -57,10 +80,17 @@ class BatchController(object): @classmethod def get_cache(cls, user): if user not in cls._user_caches: - return {} # Return blank cache here, we'll just discard :) + # 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 ''' TODO: memoise CartController.for_user @@ -70,13 +100,6 @@ TODO: memoise FlagCounter.count() (doesn't take user, but it'll do for now) TODO: memoise _filtered_discounts Tests: -- Correct nesting behaviour - - do we get different cache objects every time we get a cache in non-batched - contexts? - - do we get the same cache object for nested caches? - - do we get different cache objects when we back out of a batch and enter a - new one -- are cache clears independent for different users? - ``end_batch`` behaviour for CartController (use for_user *A LOT*) - discounts not calculated until outermost batch point exits. - Revision number shouldn't change until outermost batch point exits. From 27ab44ec4415613584c51d5f4bb05ac769211a58 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 10:42:18 +1000 Subject: [PATCH 211/418] test cases for memoisation --- registrasion/tests/test_batch.py | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/registrasion/tests/test_batch.py b/registrasion/tests/test_batch.py index 95c3fbc0..51aa19dd 100644 --- a/registrasion/tests/test_batch.py +++ b/registrasion/tests/test_batch.py @@ -81,3 +81,42 @@ class BatchTestCase(RegistrationCartTestCase): 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() From a267b60eb9f39c723c910ca92cd90533d1749602 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 11:12:35 +1000 Subject: [PATCH 212/418] Makes memoise work properly --- registrasion/controllers/batch.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/registrasion/controllers/batch.py b/registrasion/controllers/batch.py index 00001a86..1a7f2d2e 100644 --- a/registrasion/controllers/batch.py +++ b/registrasion/controllers/batch.py @@ -1,6 +1,8 @@ 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 @@ -54,26 +56,36 @@ class BatchController(object): @classmethod def memoise(cls, func): ''' Decorator that stores the result of the stored function in the - user's results cache until the batch completes. + user's results cache until the batch completes. Keyword arguments are + not yet supported. Arguments: - func (callable(user, *a, **k)): The function whose results we want - to store. ``user`` must be the first argument; this is used as - the cache key. + func (callable(*a)): The function whose results we want + to store. The positional arguments, ``a``, are used as cache + keys. Returns: - callable(user, *a, **k): The memosing version of ``func``. + callable(*a): The memosing version of ``func``. ''' @functools.wraps(func) - def f(user, *a, **k): + 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 not in cache: - cache[func] = func(user, *a, **k) - return cache[func] + if func_key not in cache: + cache[func_key] = func(*a) + + return cache[func_key] return f From 3db1256895aa5086621335e42134a1ffd5b06965 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 11:21:55 +1000 Subject: [PATCH 213/418] Adds test for end_batch functionality --- registrasion/tests/test_batch.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/registrasion/tests/test_batch.py b/registrasion/tests/test_batch.py index 51aa19dd..590f7f04 100644 --- a/registrasion/tests/test_batch.py +++ b/registrasion/tests/test_batch.py @@ -120,3 +120,26 @@ class BatchTestCase(RegistrationCartTestCase): @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) From 5929c0af3cec5548f92a9cf026ec01e261cb57bb Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 11:25:48 +1000 Subject: [PATCH 214/418] Adds end_batch functionality --- registrasion/controllers/batch.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/batch.py b/registrasion/controllers/batch.py index 1a7f2d2e..3cf95c8a 100644 --- a/registrasion/controllers/batch.py +++ b/registrasion/controllers/batch.py @@ -49,7 +49,11 @@ class BatchController(object): cache[cls._NESTING_KEY] -= 1 if cache[cls._NESTING_KEY] == 0: - # TODO: Handle batch end cases + + for key in cache: + item = cache[key] + if hasattr(item, 'end_batch') and callable(item.end_batch): + item.end_batch() del cls._user_caches[user] @@ -115,6 +119,4 @@ Tests: - ``end_batch`` behaviour for CartController (use for_user *A LOT*) - discounts not calculated until outermost batch point exits. - Revision number shouldn't change until outermost batch point exits. -- Make sure memoisation ONLY happens when we're in a batch. - ''' From 94ceaa3bb10fccea5b77e0e90dced7a722d878ab Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 11:55:14 +1000 Subject: [PATCH 215/418] Adds test case for CartController batching --- registrasion/tests/test_batch.py | 1 - registrasion/tests/test_cart.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/registrasion/tests/test_batch.py b/registrasion/tests/test_batch.py index 590f7f04..70370799 100644 --- a/registrasion/tests/test_batch.py +++ b/registrasion/tests/test_batch.py @@ -122,7 +122,6 @@ class BatchTestCase(RegistrationCartTestCase): return object() def test_batch_end_functionality_is_called(self): - class Ender(object): end_count = 0 def end_batch(self): diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 790c1df9..a4f6a5eb 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -12,6 +12,7 @@ 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 controller_helpers import TestingCartController @@ -360,3 +361,58 @@ class BasicCartTests(RegistrationCartTestCase): def test_available_products_respects_product_limits(self): self.__available_products_test(self.PROD_4, 6) + + def test_cart_controller_batching(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) + + # - that the revision only increments on modifications + rev_0 = cart.cart.revision + with BatchController.batch(self.USER_1): + # Memoise the cart + same_cart = TestingCartController.for_user(self.USER_1) + # Do nothing on exit + rev_1 = self.reget(cart.cart).revision + self.assertEqual(rev_0, rev_1) + + # - that the revision only increments on the outside of a batch + 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 + cart.set_quantity(self.PROD_1, 0) + + self.assertEqual(rev_0, rev_1) + self.assertNotEqual(rev_0, rev_2) + + # - that discounts are only calculated on modifications o/s batch + def count_discounts(cart): + return cart.cart.discountitem_set.count() + + count_0 = count_discounts(cart.cart) + self.make_discount_ceiling("FLOOZLE") + 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.cart) + + count_2 = count_discounts(same_cart.cart) + + count_3 = count_discounts(cart.cart) + self.assertEqual(0, count_0) + self.assertEqual(0, count_1) + self.assertEqual(0, count_2) + self.assertEqual(1, count_1) From ad2de6e9d40ddfe0610769f8007ac13106f95a10 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 12:05:51 +1000 Subject: [PATCH 216/418] Breaks cart batching tests into multiple tests --- registrasion/tests/test_cart.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index a4f6a5eb..7b804f64 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -362,24 +362,29 @@ class BasicCartTests(RegistrationCartTestCase): def test_available_products_respects_product_limits(self): self.__available_products_test(self.PROD_4, 6) - def test_cart_controller_batching(self): + 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) - # - that the revision only increments on modifications + 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 same_cart = TestingCartController.for_user(self.USER_1) # Do nothing on exit + rev_1 = self.reget(cart.cart).revision self.assertEqual(rev_0, rev_1) - # - that the revision only increments on the outside of a batch + 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) @@ -387,17 +392,18 @@ class BasicCartTests(RegistrationCartTestCase): rev_1 = self.reget(same_cart.cart).revision rev_2 = self.reget(cart.cart).revision - cart.set_quantity(self.PROD_1, 0) self.assertEqual(rev_0, rev_1) self.assertNotEqual(rev_0, rev_2) - # - that discounts are only calculated on modifications o/s batch + def test_cart_discounts_only_calculated_at_end_of_batches(self): def count_discounts(cart): return cart.cart.discountitem_set.count() - count_0 = count_discounts(cart.cart) + cart = TestingCartController.for_user(self.USER_1) self.make_discount_ceiling("FLOOZLE") + count_0 = count_discounts(cart.cart) + with BatchController.batch(self.USER_1): # Memoise the cart same_cart = TestingCartController.for_user(self.USER_1) From 3717adb262908c8daac542830761912ed3814587 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 12:19:20 +1000 Subject: [PATCH 217/418] Squash this and last two --- registrasion/tests/test_cart.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 7b804f64..619b9074 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -402,7 +402,7 @@ class BasicCartTests(RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) self.make_discount_ceiling("FLOOZLE") - count_0 = count_discounts(cart.cart) + count_0 = count_discounts(cart) with BatchController.batch(self.USER_1): # Memoise the cart @@ -413,12 +413,13 @@ class BasicCartTests(RegistrationCartTestCase): 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.cart) + count_1 = count_discounts(same_cart_2) - count_2 = count_discounts(same_cart.cart) + count_2 = count_discounts(same_cart) + + count_3 = count_discounts(cart) - count_3 = count_discounts(cart.cart) self.assertEqual(0, count_0) self.assertEqual(0, count_1) self.assertEqual(0, count_2) - self.assertEqual(1, count_1) + self.assertEqual(1, count_3) From 3d635521ebf68c8a9fa7bd98e1bc5c85085f8ec4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 12:19:36 +1000 Subject: [PATCH 218/418] CartController now uses BatchController memoisation --- registrasion/controllers/cart.py | 69 ++++++-------------------------- 1 file changed, 13 insertions(+), 56 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 283c3119..d0a9f057 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -16,6 +16,7 @@ from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import inventory +from.batch import BatchController from .category import CategoryController from .discount import DiscountController from .flag import FlagController @@ -34,10 +35,11 @@ def _modifies_cart(func): def inner(self, *a, **k): self._fail_if_cart_is_not_active() with transaction.atomic(): - with CartController.operations_batch(self.cart.user) as mark: - mark.mark = True # Marker that we've modified the cart + 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 @@ -47,6 +49,7 @@ class CartController(object): 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. ''' @@ -64,59 +67,6 @@ class CartController(object): ) return cls(existing) - # Marks the carts that are currently in batches - _FOR_USER = {} - _BATCH_COUNT = collections.defaultdict(int) - _MODIFIED_CARTS = set() - - class _ModificationMarker(object): - pass - - @classmethod - @contextlib.contextmanager - def operations_batch(cls, user): - ''' Marks the boundary for a batch of operations on a user's cart. - - These markers can be nested. Only on exiting the outermost marker will - a batch be ended. - - When a batch is ended, discounts are recalculated, and the cart's - revision is increased. - ''' - - if user not in cls._FOR_USER: - _ctrl = cls.for_user(user) - cls._FOR_USER[user] = (_ctrl, _ctrl.cart.id) - - ctrl, _id = cls._FOR_USER[user] - - cls._BATCH_COUNT[_id] += 1 - try: - success = False - - marker = cls._ModificationMarker() - yield marker - - if hasattr(marker, "mark"): - cls._MODIFIED_CARTS.add(_id) - - success = True - finally: - - cls._BATCH_COUNT[_id] -= 1 - - # Only end on the outermost batch marker, and only if - # it excited cleanly, and a modification occurred - modified = _id in cls._MODIFIED_CARTS - outermost = cls._BATCH_COUNT[_id] == 0 - if modified and outermost and success: - ctrl._end_batch() - cls._MODIFIED_CARTS.remove(_id) - - # Clear out the cache on the outermost operation - if outermost: - del cls._FOR_USER[user] - def _fail_if_cart_is_not_active(self): self.cart.refresh_from_db() if self.cart.status != commerce.Cart.STATUS_ACTIVE: @@ -144,6 +94,13 @@ class CartController(object): self.cart.time_last_updated = timezone.now() 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. From efb73e7a682f937934c71d381ec2606182ff4800 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 12:42:06 +1000 Subject: [PATCH 219/418] Memoises everything else that needs to be memoised. --- registrasion/controllers/batch.py | 13 ------------- registrasion/controllers/category.py | 2 ++ registrasion/controllers/discount.py | 6 ++++-- registrasion/controllers/flag.py | 8 +++++--- registrasion/controllers/product.py | 2 ++ 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/registrasion/controllers/batch.py b/registrasion/controllers/batch.py index 3cf95c8a..defd741e 100644 --- a/registrasion/controllers/batch.py +++ b/registrasion/controllers/batch.py @@ -107,16 +107,3 @@ class BatchController(object): cache = {} cache[cls._NESTING_KEY] = 0 return cache - -''' -TODO: memoise CartController.for_user -TODO: memoise user_remainders (Product, Category) -TODO: memoise _filtered_flags -TODO: memoise FlagCounter.count() (doesn't take user, but it'll do for now) -TODO: memoise _filtered_discounts - -Tests: -- ``end_batch`` behaviour for CartController (use for_user *A LOT*) - - discounts not calculated until outermost batch point exits. - - Revision number shouldn't change until outermost batch point exits. -''' diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 4681f48b..4adf09b6 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -7,6 +7,7 @@ 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 @@ -39,6 +40,7 @@ class CategoryController(object): return set(i.category for i in available) @classmethod + @BatchController.memoise def user_remainders(cls, user): ''' diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 29bc1ec6..108ed29b 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -1,6 +1,8 @@ import itertools -from conditions import ConditionController +from .batch import BatchController +from .conditions import ConditionController + from registrasion.models import commerce from registrasion.models import conditions @@ -10,7 +12,6 @@ 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. @@ -99,6 +100,7 @@ class DiscountController(object): return discounts @classmethod + @BatchController.memoise def _filtered_clauses(cls, user): ''' diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index 97456478..879d85f0 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -6,6 +6,7 @@ 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 @@ -115,7 +116,7 @@ class FlagController(object): if not met and product not in messages: messages[product] = message - total_flags = FlagCounter.count() + total_flags = FlagCounter.count(user) valid = {} @@ -158,6 +159,7 @@ class FlagController(object): return error_fields @classmethod + @BatchController.memoise def _filtered_flags(cls, user): ''' @@ -209,11 +211,11 @@ _ConditionsCount = namedtuple( ) -# TODO: this should be cacheable. class FlagCounter(_FlagCounter): @classmethod - def count(cls): + @BatchController.memoise + def count(cls, user): # Get the count of how many conditions should exist per product flagbases = conditions.FlagBase.objects diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 0810902b..4210bd7c 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -9,6 +9,7 @@ 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 @@ -55,6 +56,7 @@ class ProductController(object): return out @classmethod + @BatchController.memoise def user_remainders(cls, user): ''' From 3ab5ac32cadc69dbb03a3c4056de534848749b9c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 12:44:07 +1000 Subject: [PATCH 220/418] Part of CartController->BatchController memoisation --- registrasion/views.py | 48 ++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index a4dcceac..3d2a3c04 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -5,9 +5,10 @@ from registrasion import util from registrasion.models import commerce from registrasion.models import inventory from registrasion.models import people -from registrasion.controllers.discount import DiscountController +from registrasion.controllers.batch import BatchController from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController +from registrasion.controllers.discount import DiscountController from registrasion.controllers.invoice import InvoiceController from registrasion.controllers.product import ProductController from registrasion.exceptions import CartValidationError @@ -170,18 +171,18 @@ def guided_registration(request): category__in=cats, ).select_related("category") - available_products = set(ProductController.available_products( - request.user, - products=all_products, - )) + with BatchController.batch(request.user): + available_products = set(ProductController.available_products( + request.user, + products=all_products, + )) - if len(available_products) == 0: - # We've filled in every category - attendee.completed_registration = True - attendee.save() - return next_step + if len(available_products) == 0: + # We've filled in every category + attendee.completed_registration = True + attendee.save() + return next_step - with CartController.operations_batch(request.user): for category in cats: products = [ i for i in available_products @@ -345,20 +346,21 @@ def product_category(request, category_id): category_id = int(category_id) # Routing is [0-9]+ category = inventory.Category.objects.get(pk=category_id) - products = ProductController.available_products( - request.user, - category=category, - ) - - if not products: - messages.warning( - request, - "There are no products available from category: " + category.name, + with BatchController.batch(request.user): + products = ProductController.available_products( + request.user, + category=category, ) - return redirect("dashboard") - p = _handle_products(request, category, products, PRODUCTS_FORM_PREFIX) - products_form, discounts, products_handled = p + if not products: + messages.warning( + request, + "There are no products available from category: " + category.name, + ) + return redirect("dashboard") + + p = _handle_products(request, category, products, PRODUCTS_FORM_PREFIX) + products_form, discounts, products_handled = p if request.POST and not voucher_handled and not products_form.errors: # Only return to the dashboard if we didn't add a voucher code From 9ca25e5986ba3f9709219a24e6db8e33f27ef49c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 13:15:19 +1000 Subject: [PATCH 221/418] Makes sure that the cache is not disturbed by calling end_batch --- registrasion/controllers/batch.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/batch.py b/registrasion/controllers/batch.py index defd741e..579d8970 100644 --- a/registrasion/controllers/batch.py +++ b/registrasion/controllers/batch.py @@ -49,13 +49,23 @@ class BatchController(object): cache[cls._NESTING_KEY] -= 1 if cache[cls._NESTING_KEY] == 0: + cls._call_end_batch_methods(user) + del cls._user_caches[user] - for key in cache: + @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() - - del cls._user_caches[user] + ended = ended | keys_to_end @classmethod def memoise(cls, func): From b9b50c68466b7b8d8914de2b476ca28d9e03ff0d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 14:07:29 +1000 Subject: [PATCH 222/418] Bug fixes and query optimisations in flag.py and discount.py --- registrasion/controllers/discount.py | 2 ++ registrasion/controllers/flag.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 108ed29b..79785a64 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -117,12 +117,14 @@ class DiscountController(object): 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", ) valid_discounts = conditions.DiscountBase.objects.all() diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index 879d85f0..29b5be6d 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -85,6 +85,8 @@ class FlagController(object): # 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) | @@ -181,7 +183,7 @@ class FlagController(object): flags = ctrl.pre_filter(flags, user) all_subsets.append(flags) - return itertools.chain(*all_subsets) + return list(itertools.chain(*all_subsets)) ConditionAndRemainder = namedtuple( From abe8c12b05e3f70146379852ec5ef134d47c720e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 1 May 2016 19:12:40 +1000 Subject: [PATCH 223/418] Simplifies flag and discount filter functions --- registrasion/controllers/discount.py | 4 +--- registrasion/controllers/flag.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 79785a64..984fe214 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -127,12 +127,10 @@ class DiscountController(object): "discount", ) - valid_discounts = conditions.DiscountBase.objects.all() - all_subsets = [] for discounttype in discounttypes: - discounts = discounttype.objects.filter(id__in=valid_discounts) + discounts = discounttype.objects.all() ctrl = ConditionController.for_type(discounttype) discounts = ctrl.pre_filter(discounts, user) all_subsets.append(discounts) diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index 29b5be6d..77d6476d 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -173,12 +173,10 @@ class FlagController(object): types = list(ConditionController._controllers()) flagtypes = [i for i in types if issubclass(i, conditions.FlagBase)] - all_flags = conditions.FlagBase.objects.all() - all_subsets = [] for flagtype in flagtypes: - flags = flagtype.objects.filter(id__in=all_flags) + flags = flagtype.objects.all() ctrl = ConditionController.for_type(flagtype) flags = ctrl.pre_filter(flags, user) all_subsets.append(flags) From de83015776178c0b9883c6a91bcbe5a85618a456 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 2 May 2016 10:55:29 +1000 Subject: [PATCH 224/418] Fixes ordering error in error display --- registrasion/controllers/flag.py | 56 +++++++++++++++++--------------- registrasion/views.py | 3 +- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index 77d6476d..c094da59 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -15,25 +15,6 @@ from registrasion.models import inventory class FlagController(object): - 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 test_flags( cls, user, products=None, product_quantities=None): @@ -103,9 +84,7 @@ class FlagController(object): met = consumed <= remainder if not met: - items = ", ".join(str(product) for product in all_products) - base = cls.MESSAGE[remainder == 0][len(all_products) == 1] - message = base % {"items": items, "remainder": remainder} + message = cls._error_message(all_products, remainder) for product in all_products: if condition.is_disable_if_false: @@ -135,13 +114,11 @@ class FlagController(object): if f.dif > 0 and f.dif != dif_count[product]: do_not_disable[product] = False if product not in messages: - messages[product] = "Some disable-if-false " \ - "conditions were not met" + 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] = "Some enable-if-true " \ - "conditions were not met" + messages[product] = cls._error_message([product], 0) for product in itertools.chain(do_not_disable, do_enable): f = total_flags.get(product) @@ -160,6 +137,33 @@ class FlagController(object): 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): diff --git a/registrasion/views.py b/registrasion/views.py index 3d2a3c04..7c3634d3 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -451,7 +451,8 @@ def _set_quantities_from_products_form(products_form, current_cart): pks = [i[0] for i in quantities] products = inventory.Product.objects.filter( id__in=pks, - ).select_related("category") + ).select_related("category").order_by("id") + quantities.sort(key = lambda i: i[0]) product_quantities = [ (product, id_to_quantity[product.id]) for product in products From 9056d5d303956b533f17a48859fd1953692ff71a Mon Sep 17 00:00:00 2001 From: Paris Buttfield-Addison Date: Thu, 12 May 2016 11:31:19 -0500 Subject: [PATCH 225/418] Fixed a typo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index bd695934..1a18a7c0 100644 --- a/README.rst +++ b/README.rst @@ -16,7 +16,7 @@ handles inventory management, as well as complex product inclusions, automatic calculation of discounts, and invoicing. Payment of invoices can be faciliated by manual filings of payments by staff, or through plugging in a payment app. -Initial development of ``registration`` was funded with the generous support of +Initial development of ``registrasion`` was funded with the generous support of the Python Software Foundation. Quickstart From 1faa608425f9509eadde495c49120d6b41d022c8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 21 Aug 2016 15:40:05 +1000 Subject: [PATCH 226/418] Adds email framework shamelessly stolen from Symposion --- registrasion/contrib/__init__.py | 0 registrasion/contrib/mail.py | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 registrasion/contrib/__init__.py create mode 100644 registrasion/contrib/mail.py diff --git a/registrasion/contrib/__init__.py b/registrasion/contrib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/contrib/mail.py b/registrasion/contrib/mail.py new file mode 100644 index 00000000..06ca8fd2 --- /dev/null +++ b/registrasion/contrib/mail.py @@ -0,0 +1,63 @@ +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() From 155f6d42d9f38c9aa95be240d077002bcb2e3a7c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 21 Aug 2016 16:39:57 +1000 Subject: [PATCH 227/418] Renames patch_datetime to patches, adds e-mail patching bits --- .../tests/{patch_datetime.py => patches.py} | 24 +++++++++++++++++++ registrasion/tests/test_cart.py | 6 ++--- 2 files changed, 27 insertions(+), 3 deletions(-) rename registrasion/tests/{patch_datetime.py => patches.py} (53%) diff --git a/registrasion/tests/patch_datetime.py b/registrasion/tests/patches.py similarity index 53% rename from registrasion/tests/patch_datetime.py rename to registrasion/tests/patches.py index 8b64b606..7d7cd66c 100644 --- a/registrasion/tests/patch_datetime.py +++ b/registrasion/tests/patches.py @@ -1,5 +1,6 @@ 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 @@ -23,3 +24,26 @@ class SetTimeMixin(object): 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 diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 619b9074..bee94322 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -16,12 +16,12 @@ from registrasion.controllers.batch import BatchController from registrasion.controllers.product import ProductController from controller_helpers import TestingCartController -from patch_datetime import SetTimeMixin +from patches import MixInPatches UTC = pytz.timezone('UTC') -class RegistrationCartTestCase(SetTimeMixin, TestCase): +class RegistrationCartTestCase(MixInPatches, TestCase): def setUp(self): super(RegistrationCartTestCase, self).setUp() @@ -377,7 +377,7 @@ class BasicCartTests(RegistrationCartTestCase): # Memoise the cart same_cart = TestingCartController.for_user(self.USER_1) # Do nothing on exit - + rev_1 = self.reget(cart.cart).revision self.assertEqual(rev_0, rev_1) From e946af0f0487f85e2e8036051fc43c9f7a0d732b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 21 Aug 2016 16:56:05 +1000 Subject: [PATCH 228/418] Adds functions for mailing invoices when certain events occur. --- registrasion/controllers/invoice.py | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index da737a73..03be199a 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -5,6 +5,8 @@ from django.db import transaction from django.db.models import Sum 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 @@ -149,6 +151,8 @@ class InvoiceController(ForId, object): invoice.save() + cls.email_on_invoice_creation(invoice) + return invoice def can_view(self, user=None, access_code=None): @@ -314,3 +318,33 @@ class InvoiceController(ForId, object): 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 + + ''' + + cls.email(invoice, "invoice_updated") From 924906d38cc0898121fac31cd2bd20532209687f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 21 Aug 2016 16:56:15 +1000 Subject: [PATCH 229/418] Adds test for e-mails being sent when invoices are generated. --- registrasion/tests/test_invoice.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index f1ed6ed0..32ec6c2e 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -533,3 +533,11 @@ class InvoiceTestCase(RegistrationCartTestCase): # 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_sends_email_on_invoice_creation(self): + invoice = self._invoice_containing_prod_1(1) + assert(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"]) From 7bf372f92a87274c924001215b98d582c72a8f2c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 21 Aug 2016 17:14:19 +1000 Subject: [PATCH 230/418] Invoices now send e-mails when created, paid, or refunded. --- registrasion/controllers/invoice.py | 17 ++++++++++++++++ registrasion/tests/test_invoice.py | 31 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 03be199a..1b70640e 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -245,6 +245,12 @@ class InvoiceController(ForId, object): 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. ''' @@ -347,4 +353,15 @@ class InvoiceController(ForId, object): ''' + # 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") diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 32ec6c2e..98d479b6 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -536,8 +536,37 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_sends_email_on_invoice_creation(self): invoice = self._invoice_containing_prod_1(1) - assert(1, len(self.emails)) + 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"]) From 4f16e4b9d0e9f1691e58d719c3f34d2a4f6d3176 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 21 Aug 2016 18:28:16 +1000 Subject: [PATCH 231/418] Oops. --- registrasion/controllers/invoice.py | 2 +- registrasion/tests/test_invoice.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 1b70640e..25578cdd 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -334,7 +334,7 @@ class InvoiceController(ForId, object): "invoice": invoice, } - send_email(invoice.user.email, kind, context=context) + send_email([invoice.user.email], kind, context=context) @classmethod def email_on_invoice_creation(cls, invoice): diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 98d479b6..bd8c4340 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -538,7 +538,7 @@ class InvoiceTestCase(RegistrationCartTestCase): 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([self.USER_1.email], email["to"]) self.assertEquals("invoice_created", email["kind"]) self.assertEquals(invoice.invoice, email["context"]["invoice"]) @@ -553,7 +553,7 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(2, len(self.emails)) email = self.emails[1] - self.assertEquals(self.USER_1.email, email["to"]) + self.assertEquals([self.USER_1.email], email["to"]) self.assertEquals("invoice_updated", email["kind"]) self.assertEquals(invoice.invoice, email["context"]["invoice"]) @@ -567,6 +567,6 @@ class InvoiceTestCase(RegistrationCartTestCase): self.assertEquals(3, len(self.emails)) email = self.emails[2] - self.assertEquals(self.USER_1.email, email["to"]) + self.assertEquals([self.USER_1.email], email["to"]) self.assertEquals("invoice_updated", email["kind"]) self.assertEquals(invoice.invoice, email["context"]["invoice"]) From 8c34c7498aeaad524e6602e6a7a1a3ab62406655 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:13:02 +1000 Subject: [PATCH 232/418] Factors _ProductsForm into _HasProductsFields --- registrasion/forms.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 68302f56..f7157c82 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -47,7 +47,7 @@ class ManualPaymentForm(forms.ModelForm): # Products forms -- none of these have any fields: they are to be subclassed # and the fields added as needs be. -class _ProductsForm(forms.Form): +class _HasProductsFields(object): PRODUCT_PREFIX = "product_" @@ -57,7 +57,7 @@ class _ProductsForm(forms.Form): initial = self.initial_data(k["product_quantities"]) k["initial"] = initial del k["product_quantities"] - super(_ProductsForm, self).__init__(*a, **k) + super(_ProductsFieldsHelpers, self).__init__(*a, **k) @classmethod def field_name(cls, product): @@ -81,6 +81,10 @@ class _ProductsForm(forms.Form): return iter([]) +class _ProductsForm(_HasProductsFields, forms.Form): + pass + + class _QuantityBoxProductsForm(_ProductsForm): ''' Products entry form that allows users to enter quantities of desired products. ''' From c4274817a86b1b0789f3763b06aa09ea863a2d49 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:15:01 +1000 Subject: [PATCH 233/418] Moves ProductsForm to the top of its file --- registrasion/forms.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index f7157c82..f21f800a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -45,7 +45,26 @@ class ManualPaymentForm(forms.ModelForm): # Products forms -- none of these have any fields: they are to be subclassed -# and the fields added as needs be. +# 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. + RENDER_TYPES = { + inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, + inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, + } + + # Produce a subclass of _ProductsForm which we can alter the base_fields on + class ProductsForm(RENDER_TYPES[category.render_type]): + pass + + ProductsForm.set_fields(category, products) + return ProductsForm + class _HasProductsFields(object): @@ -57,7 +76,7 @@ class _HasProductsFields(object): initial = self.initial_data(k["product_quantities"]) k["initial"] = initial del k["product_quantities"] - super(_ProductsFieldsHelpers, self).__init__(*a, **k) + super(_HasProductsFields, self).__init__(*a, **k) @classmethod def field_name(cls, product): @@ -172,24 +191,6 @@ class _RadioButtonProductsForm(_ProductsForm): ) -def ProductsForm(category, products): - ''' Produces an appropriate _ProductsForm subclass for the given render - type. ''' - - # Each Category.RENDER_TYPE value has a subclass here. - RENDER_TYPES = { - inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, - inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, - } - - # Produce a subclass of _ProductsForm which we can alter the base_fields on - class ProductsForm(RENDER_TYPES[category.render_type]): - pass - - ProductsForm.set_fields(category, products) - return ProductsForm - - class VoucherForm(forms.Form): voucher = forms.CharField( label="Voucher code", From d9f9af9827bfc6f8a693178ab61f85e05dff0777 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:35:30 +1000 Subject: [PATCH 234/418] Modifies the Category model to allow for ITEM_QUANTITY forms --- .../migrations/0002_auto_20160822_0034.py | 20 +++++++++++++++++++ registrasion/models/inventory.py | 8 ++++++++ 2 files changed, 28 insertions(+) create mode 100644 registrasion/migrations/0002_auto_20160822_0034.py diff --git a/registrasion/migrations/0002_auto_20160822_0034.py b/registrasion/migrations/0002_auto_20160822_0034.py new file mode 100644 index 00000000..ab346388 --- /dev/null +++ b/registrasion/migrations/0002_auto_20160822_0034.py @@ -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'), + ), + ] diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py index 84f386e1..a84f0fa8 100644 --- a/registrasion/models/inventory.py +++ b/registrasion/models/inventory.py @@ -36,6 +36,12 @@ class Category(models.Model): 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. @@ -56,10 +62,12 @@ class Category(models.Model): 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( From 02e415c104c1d72f903db5c29c1727e09e606fdc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:41:39 +1000 Subject: [PATCH 235/418] Adds an implementation for item-quantity forms. --- registrasion/forms.py | 59 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index f21f800a..dfd1a9d8 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -56,6 +56,7 @@ def ProductsForm(category, products): RENDER_TYPES = { inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, + inventory.Category.RENDER_TYPE_ITEM_QUANTITY: _ItemQuantityProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on @@ -152,7 +153,6 @@ class _RadioButtonProductsForm(_ProductsForm): 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)) @@ -190,6 +190,63 @@ class _RadioButtonProductsForm(_ProductsForm): self.FIELD, ) +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 specific + product type to be purchased.''' + + CHOICE_FIELD = "choice" + QUANTITY_FIELD = "quantity" + + @classmethod + def set_fields(cls, category, products): + choices = [] + for product in products: + choice_text = "%s -- $%d each" % (product.name, product.price) + choices.append((product.id, choice_text)) + + if not category.required: + choices.append((0, "No selection")) + + cls.base_fields[cls.CHOICE_FIELD] = forms.TypedChoiceField( + label=category.name, + widget=forms.Select, + choices=choices, + 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, + self.CHOICE_FIELD, + ) + class VoucherForm(forms.Form): voucher = forms.CharField( From d52fc6eb9d6366b2dcd9bca5669926f90dbc96e6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 13:17:24 +1000 Subject: [PATCH 236/418] Adds a formset for dealing with long-and-thin product categories. --- registrasion/forms.py | 104 +++++++++++++++++++++++++++++++++++++++--- registrasion/views.py | 18 +++----- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index dfd1a9d8..e9ba1282 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -2,6 +2,7 @@ from registrasion.models import commerce from registrasion.models import inventory from django import forms +from django.core.exceptions import ValidationError class ApplyCreditNoteForm(forms.Form): @@ -64,6 +65,13 @@ def ProductsForm(category, products): 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 @@ -100,6 +108,18 @@ class _HasProductsFields(object): 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): pass @@ -140,7 +160,7 @@ class _QuantityBoxProductsForm(_ProductsForm): 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, name) + yield (product_id, value) class _RadioButtonProductsForm(_ProductsForm): @@ -190,10 +210,14 @@ class _RadioButtonProductsForm(_ProductsForm): self.FIELD, ) + def add_product_error(self, product, error): + self.add_error(cls.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 specific - product type to be purchased.''' + 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" @@ -201,17 +225,19 @@ class _ItemQuantityProductsForm(_ProductsForm): @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)) - if not category.required: - choices.append((0, "No selection")) - cls.base_fields[cls.CHOICE_FIELD] = forms.TypedChoiceField( label=category.name, widget=forms.Select, choices=choices, + initial=0, empty_value=0, coerce=int, ) @@ -244,9 +270,73 @@ class _ItemQuantityProductsForm(_ProductsForm): yield ( choice_value, our_quantity if our_choice == choice_value else 0, - self.CHOICE_FIELD, ) + 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.QUANTITY_FIELD, error) + + +class _ItemQuantityProductsFormSet(_HasProductsFields, forms.BaseFormSet): + + @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) + class VoucherForm(forms.Form): voucher = forms.CharField( diff --git a/registrasion/views.py b/registrasion/views.py index 7c3634d3..13ebd927 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -446,33 +446,29 @@ def _handle_products(request, category, products, prefix): def _set_quantities_from_products_form(products_form, current_cart): + # Makes id_to_quantity, a dictionary from product ID to its quantity quantities = list(products_form.product_quantities()) - id_to_quantity = dict(i[:2] for i in quantities) + id_to_quantity = dict(quantities) + + # Get the actual product objects pks = [i[0] for i in quantities] products = inventory.Product.objects.filter( id__in=pks, ).select_related("category").order_by("id") + quantities.sort(key = lambda i: i[0]) + # Match the product objects to their quantities product_quantities = [ (product, id_to_quantity[product.id]) for product in products ] - field_names = dict( - (i[0][0], i[1][2]) for i in zip(product_quantities, quantities) - ) try: current_cart.set_quantities(product_quantities) except CartValidationError as ve: for ve_field in ve.error_list: product, message = ve_field.message - if product in field_names: - field = field_names[product] - elif isinstance(product, inventory.Product): - continue - else: - field = None - products_form.add_error(field, message) + products_form.add_product_error(product, message) def _handle_voucher(request, prefix): From 482fe22d891142e9b380df0a773de4c864cc7877 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 15:03:08 +1000 Subject: [PATCH 237/418] Better reporting of errors in long-and-thin categories --- registrasion/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index e9ba1282..552ea991 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -277,6 +277,7 @@ class _ItemQuantityProductsForm(_ProductsForm): return if product.id == self.cleaned_data[self.CHOICE_FIELD]: + self.add_error(self.CHOICE_FIELD, error) self.add_error(self.QUANTITY_FIELD, error) From 0b7396c40f09d7f7a2ee9de4221edfaddc74384c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 24 Aug 2016 11:46:15 +1000 Subject: [PATCH 238/418] Discount line items now describe the product that the discount applies to. --- registrasion/controllers/invoice.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 25578cdd..616f418d 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -122,12 +122,19 @@ class InvoiceController(ForId, object): line_items = [] + 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)) + invoice_value = Decimal() for item in product_items: product = item.product line_item = commerce.LineItem( invoice=invoice, - description="%s - %s" % (product.category.name, product.name), + description=format_product(product), quantity=item.quantity, price=product.price, product=product, @@ -137,7 +144,7 @@ class InvoiceController(ForId, object): for item in discount_items: line_item = commerce.LineItem( invoice=invoice, - description=item.discount.description, + description=format_discount(item.discount, item.product), quantity=item.quantity, price=cls.resolve_discount_value(item) * -1, product=item.product, From 3225a353e075a1e21ea38e561865465796b369e8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 19:51:36 +1000 Subject: [PATCH 239/418] Migrates to the less-deprecated URL syntax --- registrasion/urls.py | 45 +++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 7b28693e..412e7f7c 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -2,21 +2,32 @@ import views from django.conf.urls import url, patterns -urlpatterns = patterns( - "registrasion.views", - url(r"^category/([0-9]+)$", "product_category", name="product_category"), - url(r"^checkout$", "checkout", name="checkout"), - url(r"^credit_note/([0-9]+)$", views.credit_note, name="credit_note"), - url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), - url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), - url(r"^invoice/([0-9]+)/manual_payment$", - views.manual_payment, name="manual_payment"), - url(r"^invoice/([0-9]+)/refund$", - views.refund, name="refund"), - url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, - name="invoice_access"), - url(r"^profile$", "edit_profile", name="attendee_edit"), - url(r"^register$", "guided_registration", name="guided_registration"), - url(r"^register/([0-9]+)$", "guided_registration", - name="guided_registration"), +from .views import ( + product_category, + checkout, + credit_note, + invoice, + manual_payment, + refund, + invoice_access, + edit_profile, + guided_registration, ) + +urlpatterns = [ + url(r"^category/([0-9]+)$", product_category, name="product_category"), + url(r"^checkout$", checkout, name="checkout"), + url(r"^credit_note/([0-9]+)$", credit_note, name="credit_note"), + 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"^profile$", edit_profile, name="attendee_edit"), + url(r"^register$", guided_registration, name="guided_registration"), + url(r"^register/([0-9]+)$", guided_registration, + name="guided_registration"), +] From 00476498a8f4faac682224a897795dae2862579a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 20:33:19 +1000 Subject: [PATCH 240/418] Very first attempt at a staff-facing report (items sold) --- registrasion/forms.py | 13 +++++ registrasion/staff_views.py | 101 ++++++++++++++++++++++++++++++++++++ registrasion/urls.py | 2 + 3 files changed, 116 insertions(+) create mode 100644 registrasion/staff_views.py diff --git a/registrasion/forms.py b/registrasion/forms.py index 552ea991..7e7ddc16 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -345,3 +345,16 @@ class VoucherForm(forms.Form): help_text="If you have a voucher code, enter it here", required=False, ) + + +# Staff-facing forms. + +class ProductAndCategoryForm(forms.Form): + product = forms.ModelMultipleChoiceField( + queryset=inventory.Product.objects.all(), + required=False, + ) + category = forms.ModelMultipleChoiceField( + queryset=inventory.Category.objects.all(), + required=False, + ) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py new file mode 100644 index 00000000..d0debd78 --- /dev/null +++ b/registrasion/staff_views.py @@ -0,0 +1,101 @@ +import forms + +from django.db.models import Q +from django.shortcuts import render +from functools import wraps + +from models import commerce + + +''' + +All reports must be viewable by staff only (permissions?) + +Reports can have: + +A form + * Reports are all *gettable* - you can save a URL and get back to the same + report + * Fetching a report *cannot* break the underlying data. +A table + * Headings + * Data lines + * Formats are pluggable + +''' + + +class Report(object): + + def __init__(self, form, headings, data): + self._form = form + self._headings = headings + self._data = data + + @property + def form(self): + ''' Returns the form. ''' + return self._form + + @property + def headings(self): + ''' Returns the headings for the table. ''' + return self._headings + + @property + def data(self): + ''' Returns the data rows for the table. ''' + return self._data + + +def report(view): + ''' Decorator that converts a report view function into something that + displays a Report. + + ''' + print "hello" + + @wraps(view) + def inner_view(request, *a, **k): + print "lol" + report = view(request, *a, **k) + + ctx = { + "form": report.form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + return inner_view + + +@report +def items_sold(request): + ''' Summarises the items sold and discounts granted for a given set of + products, or products from categories. ''' + + print "beep" + + form = forms.ProductAndCategoryForm(request.GET) + + data = None + headings = None + + if form.is_valid() and form.has_changed(): + products = form.cleaned_data["product"] + categories = form.cleaned_data["category"] + + # TODO augment the form to allow us to filter by invoice status. + line_items = commerce.LineItem.objects.filter( + Q(product=products) | Q(product__category=categories), + invoice__status=commerce.Invoice.STATUS_PAID, + ).select_related("invoice") + + headings = ["invoice_id", "description", "quantity", "price"] + + data = [] + for line in line_items: + data.append([line.invoice.id, line.description, line.quantity, line.price]) + + return Report(form, headings, data) diff --git a/registrasion/urls.py b/registrasion/urls.py index 412e7f7c..89c2aac8 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,5 @@ import views +import staff_views from django.conf.urls import url, patterns @@ -30,4 +31,5 @@ urlpatterns = [ url(r"^register$", guided_registration, name="guided_registration"), url(r"^register/([0-9]+)$", guided_registration, name="guided_registration"), + url(r"^report$", staff_views.items_sold, name="items_sold"), # TODO: rm ] From d131b547f695621c17c29d8c32bcab0a38cc143c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 21:01:32 +1000 Subject: [PATCH 241/418] Delete errant prints --- registrasion/staff_views.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index d0debd78..50c2a18b 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -53,11 +53,9 @@ def report(view): displays a Report. ''' - print "hello" @wraps(view) def inner_view(request, *a, **k): - print "lol" report = view(request, *a, **k) ctx = { @@ -75,8 +73,6 @@ def items_sold(request): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' - print "beep" - form = forms.ProductAndCategoryForm(request.GET) data = None From a320f822fcfa928810bb0299e3fe0baf373b9c64 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 21:05:02 +1000 Subject: [PATCH 242/418] Report for total items sold. --- registrasion/staff_views.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 50c2a18b..8cf1e590 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,6 +1,7 @@ import forms from django.db.models import Q +from django.db.models import Sum from django.shortcuts import render from functools import wraps @@ -84,14 +85,28 @@ def items_sold(request): # TODO augment the form to allow us to filter by invoice status. line_items = commerce.LineItem.objects.filter( - Q(product=products) | Q(product__category=categories), + Q(product__in=products) | Q(product__category__in=categories), invoice__status=commerce.Invoice.STATUS_PAID, ).select_related("invoice") - headings = ["invoice_id", "description", "quantity", "price"] + 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 = [] for line in line_items: - data.append([line.invoice.id, line.description, line.quantity, line.price]) + data.append([ + line["description"], line["total_quantity"], + line["price"], line["total_quantity"] * line["price"], + ]) return Report(form, headings, data) From b7650ca772d6250829f4689c1f052363179ccd9d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 21:10:07 +1000 Subject: [PATCH 243/418] Reports now display titles --- registrasion/staff_views.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 8cf1e590..aa19e443 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -28,11 +28,17 @@ A table class Report(object): - def __init__(self, form, headings, data): + def __init__(self, title, form, headings, data): + self._title = title self._form = form self._headings = headings self._data = data + @property + def title(self): + ''' Returns the form. ''' + return self._title + @property def form(self): ''' Returns the form. ''' @@ -60,6 +66,7 @@ def report(view): report = view(request, *a, **k) ctx = { + "title": report.title, "form": report.form, "report": report, } @@ -74,6 +81,8 @@ def items_sold(request): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' + title = "Paid items" + form = forms.ProductAndCategoryForm(request.GET) data = None @@ -83,7 +92,6 @@ def items_sold(request): products = form.cleaned_data["product"] categories = form.cleaned_data["category"] - # TODO augment the form to allow us to filter by invoice status. line_items = commerce.LineItem.objects.filter( Q(product__in=products) | Q(product__category__in=categories), invoice__status=commerce.Invoice.STATUS_PAID, @@ -109,4 +117,4 @@ def items_sold(request): line["price"], line["total_quantity"] * line["price"], ]) - return Report(form, headings, data) + return Report(title, form, headings, data) From db8f428ee1af0b08924562bf777333ed666fda70 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 13:40:49 +1000 Subject: [PATCH 244/418] Makes the sales report keep a total. --- registrasion/staff_views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index aa19e443..2ba5a222 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -111,10 +111,17 @@ def items_sold(request): 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"], line["total_quantity"] * line["price"], + line["price"], cost, ]) + total_income += cost + + data.append([ + "(TOTAL)", "--", "--", total_income, + ]) return Report(title, form, headings, data) From 5c41a3576caf069e366c3e50ce59182f9c44008c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 13:53:40 +1000 Subject: [PATCH 245/418] re-structures the URLs a bit, puts the items sold report under reports/items_sold --- registrasion/urls.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 89c2aac8..f22b67c6 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,7 +1,8 @@ import views import staff_views -from django.conf.urls import url, patterns +from django.conf.urls import include +from django.conf.urls import url from .views import ( product_category, @@ -15,7 +16,8 @@ from .views import ( guided_registration, ) -urlpatterns = [ + +public = [ url(r"^category/([0-9]+)$", product_category, name="product_category"), url(r"^checkout$", checkout, name="checkout"), url(r"^credit_note/([0-9]+)$", credit_note, name="credit_note"), @@ -31,5 +33,15 @@ urlpatterns = [ url(r"^register$", guided_registration, name="guided_registration"), url(r"^register/([0-9]+)$", guided_registration, name="guided_registration"), - url(r"^report$", staff_views.items_sold, name="items_sold"), # TODO: rm +] + + +reports = [ + url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), +] + + +urlpatterns = [ + url(r"^reports/", include(reports)), + url(r"^", include(public)) # This one must go last. ] From 2a850c49bc5e50938b3fe9ced8124385413a4528 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 14:17:53 +1000 Subject: [PATCH 246/418] Fixes some documentation snafus --- registrasion/models/inventory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py index a84f0fa8..bb1b0e19 100644 --- a/registrasion/models/inventory.py +++ b/registrasion/models/inventory.py @@ -46,7 +46,7 @@ class Category(models.Model): from this Category that each attendee may claim. This extends across multiple Invoices. - display_order (int): An ascending order for displaying the Categories + order (int): An ascending order for displaying the Categories available. By convention, your Category for ticket types should have the lowest display order. ''' @@ -129,7 +129,7 @@ class Product(models.Model): pay for it. This reservation duration determines how long an item should be allowed to be reserved whilst being unpaid. - display_order (int): An ascending order for displaying the Products + order (int): An ascending order for displaying the Products within each Category. ''' From 3607fb19b83c2c07b678714bd769da33e7673017 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 14:59:54 +1000 Subject: [PATCH 247/418] Adds inventory report --- registrasion/staff_views.py | 71 ++++++++++++++++++++++++++++++++++++- registrasion/urls.py | 1 + 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 2ba5a222..9967a3e9 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,11 +1,14 @@ import forms +from django.db import models from django.db.models import Q from django.db.models import Sum +from django.db.models import Case, When, Value from django.shortcuts import render from functools import wraps from models import commerce +from models import inventory ''' @@ -108,7 +111,7 @@ def items_sold(request): print line_items - headings = ["description", "quantity", "price", "total"] + headings = ["Description", "Quantity", "Price", "Total"] data = [] total_income = 0 @@ -125,3 +128,69 @@ def items_sold(request): ]) return Report(title, form, headings, data) + + +@report +def inventory(request): + ''' Summarises the inventory status of the given items, grouping by + invoice status. ''' + + title = "Inventory" + + form = forms.ProductAndCategoryForm(request.GET) + + data = None + headings = None + + if form.is_valid() and form.has_changed(): + 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") + + # TODO annotate with whether the item is reserved or not. + + items = items.annotate(is_reserved=Case( + When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), + default=Value(0), + output_field=models.BooleanField(), + )) + + items = items.order_by( + "cart__status", + "product__category__order", + "product__order", + ).values( + "product", + "product__category__name", + "product__name", + "cart__status", + "is_reserved", + ).annotate( + total_quantity=Sum("quantity"), + ) + + headings = ["Product", "Status", "Quantity"] + data = [] + + def status(reserved, status): + r = "Reserved" if reserved else "Unreserved" + s = "".join( + "%s" % i[1] + for i in commerce.Cart.STATUS_TYPES if i[0]==status + ) + return "%s - %s" % (r, s) + + for item in items: + print commerce.Cart.STATUS_TYPES + data.append([ + "%s - %s" % ( + item["product__category__name"], item["product__name"] + ), + status(item["is_reserved"], item["cart__status"]), + item["total_quantity"], + ]) + + return Report(title, form, headings, data) diff --git a/registrasion/urls.py b/registrasion/urls.py index f22b67c6..43f58110 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -37,6 +37,7 @@ public = [ reports = [ + url(r"^inventory/?$", staff_views.inventory, name="inventory"), url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), ] From 32b887fed3a990039f6a96528e57b2e1a9b3457c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 15:11:08 +1000 Subject: [PATCH 248/418] Makes the reporting framework a bit more DRY. --- registrasion/staff_views.py | 204 +++++++++++++++++------------------- 1 file changed, 97 insertions(+), 107 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 9967a3e9..c406b80f 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -31,22 +31,15 @@ A table class Report(object): - def __init__(self, title, form, headings, data): - self._title = title - self._form = form + def __init__(self, title, headings, data): self._headings = headings self._data = data @property def title(self): - ''' Returns the form. ''' + ''' Returns the title for this report. ''' return self._title - @property - def form(self): - ''' Returns the form. ''' - return self._form - @property def headings(self): ''' Returns the headings for the table. ''' @@ -58,139 +51,136 @@ class Report(object): return self._data -def report(view): +def report(title, form_type): ''' Decorator that converts a report view function into something that displays a Report. + Arguments: + form_type: A form class that can make this report display things. + ''' - @wraps(view) - def inner_view(request, *a, **k): - report = view(request, *a, **k) + def _report(view): - ctx = { - "title": report.title, - "form": report.form, - "report": report, - } + @wraps(view) + def inner_view(request, *a, **k): - return render(request, "registrasion/report.html", ctx) + form = form_type(request.GET) + if form.is_valid() and form.has_changed(): + report = view(request, form, *a, **k) + else: + report = None - return inner_view + ctx = { + "title": title, + "form": form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + return inner_view + return _report -@report -def items_sold(request): +@report("Paid items", forms.ProductAndCategoryForm) +def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' - title = "Paid items" - - form = forms.ProductAndCategoryForm(request.GET) - data = None headings = None - if form.is_valid() and form.has_changed(): - products = form.cleaned_data["product"] - categories = form.cleaned_data["category"] + products = form.cleaned_data["product"] + categories = form.cleaned_data["category"] - line_items = commerce.LineItem.objects.filter( - Q(product__in=products) | Q(product__category__in=categories), - invoice__status=commerce.Invoice.STATUS_PAID, - ).select_related("invoice") + line_items = commerce.LineItem.objects.filter( + Q(product__in=products) | Q(product__category__in=categories), + 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"), - ) + 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 + 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 + headings = ["Description", "Quantity", "Price", "Total"] + data = [] + total_income = 0 + for line in line_items: + cost = line["total_quantity"] * line["price"] data.append([ - "(TOTAL)", "--", "--", total_income, + line["description"], line["total_quantity"], + line["price"], cost, ]) + total_income += cost - return Report(title, form, headings, data) + data.append([ + "(TOTAL)", "--", "--", total_income, + ]) + + return Report("Paid items", headings, data) -@report -def inventory(request): +@report("Inventory", forms.ProductAndCategoryForm) +def inventory(request, form): ''' Summarises the inventory status of the given items, grouping by invoice status. ''' - title = "Inventory" + products = form.cleaned_data["product"] + categories = form.cleaned_data["category"] - form = forms.ProductAndCategoryForm(request.GET) + items = commerce.ProductItem.objects.filter( + Q(product__in=products) | Q(product__category__in=categories), + ).select_related("cart", "product") - data = None - headings = None + # TODO annotate with whether the item is reserved or not. - if form.is_valid() and form.has_changed(): - products = form.cleaned_data["product"] - categories = form.cleaned_data["category"] + items = items.annotate(is_reserved=Case( + When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), + default=Value(0), + output_field=models.BooleanField(), + )) - items = commerce.ProductItem.objects.filter( - Q(product__in=products) | Q(product__category__in=categories), - ).select_related("cart", "product") + items = items.order_by( + "cart__status", + "product__category__order", + "product__order", + ).values( + "product", + "product__category__name", + "product__name", + "cart__status", + "is_reserved", + ).annotate( + total_quantity=Sum("quantity"), + ) - # TODO annotate with whether the item is reserved or not. + headings = ["Product", "Status", "Quantity"] + data = [] - items = items.annotate(is_reserved=Case( - When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), - default=Value(0), - output_field=models.BooleanField(), - )) - - items = items.order_by( - "cart__status", - "product__category__order", - "product__order", - ).values( - "product", - "product__category__name", - "product__name", - "cart__status", - "is_reserved", - ).annotate( - total_quantity=Sum("quantity"), + def status(reserved, status): + r = "Reserved" if reserved else "Unreserved" + # This is a bit weird -- can we simplify? + s = "".join( + "%s" % i[1] for i in commerce.Cart.STATUS_TYPES if i[0]==status ) + return "%s - %s" % (r, s) - headings = ["Product", "Status", "Quantity"] - data = [] + for item in items: + data.append([ + "%s - %s" % ( + item["product__category__name"], item["product__name"] + ), + status(item["is_reserved"], item["cart__status"]), + item["total_quantity"], + ]) - def status(reserved, status): - r = "Reserved" if reserved else "Unreserved" - s = "".join( - "%s" % i[1] - for i in commerce.Cart.STATUS_TYPES if i[0]==status - ) - return "%s - %s" % (r, s) - - for item in items: - print commerce.Cart.STATUS_TYPES - data.append([ - "%s - %s" % ( - item["product__category__name"], item["product__name"] - ), - status(item["is_reserved"], item["cart__status"]), - item["total_quantity"], - ]) - - return Report(title, form, headings, data) + return Report("Inventory", headings, data) From 66226663d53fc131455c1883a33646f60926a33b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 15:53:33 +1000 Subject: [PATCH 249/418] Makes the inventory report even clearer. --- registrasion/staff_views.py | 72 +++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index c406b80f..17b30b5f 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,7 +1,7 @@ import forms from django.db import models -from django.db.models import Q +from django.db.models import F, Q from django.db.models import Sum from django.db.models import Case, When, Value from django.shortcuts import render @@ -141,46 +141,72 @@ def inventory(request, form): Q(product__in=products) | Q(product__category__in=categories), ).select_related("cart", "product") - # TODO annotate with whether the item is reserved or not. - - items = items.annotate(is_reserved=Case( - When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), - default=Value(0), - output_field=models.BooleanField(), - )) + items = items.annotate( + is_reserved=Case( + When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), + default=Value(0), + output_field=models.BooleanField(), + ), + ) items = items.order_by( - "cart__status", "product__category__order", "product__order", ).values( "product", "product__category__name", "product__name", - "cart__status", - "is_reserved", ).annotate( - total_quantity=Sum("quantity"), + 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), + )), ) - headings = ["Product", "Status", "Quantity"] + headings = [ + "Product", "Paid", "Reserved", "Unreserved", "Refunded", + ] data = [] - def status(reserved, status): - r = "Reserved" if reserved else "Unreserved" - # This is a bit weird -- can we simplify? - s = "".join( - "%s" % i[1] for i in commerce.Cart.STATUS_TYPES if i[0]==status - ) - return "%s - %s" % (r, s) - for item in items: data.append([ "%s - %s" % ( item["product__category__name"], item["product__name"] ), - status(item["is_reserved"], item["cart__status"]), - item["total_quantity"], + item["total_paid"], + item["total_reserved"], + item["total_unreserved"], + item["total_refunded"], ]) return Report("Inventory", headings, data) From 1e066952e9677aa5119f845deb509b0e16c325f1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 09:31:12 +1000 Subject: [PATCH 250/418] Reports now need staff credentials to load. --- registrasion/staff_views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 17b30b5f..77226ab0 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,9 +1,12 @@ import forms +import views +from django.contrib.auth.decorators import user_passes_test from django.db import models from django.db.models import F, Q from django.db.models import Sum from django.db.models import Case, When, Value +from django.http import Http404 from django.shortcuts import render from functools import wraps @@ -63,6 +66,7 @@ def report(title, form_type): def _report(view): @wraps(view) + @user_passes_test(views._staff_only) def inner_view(request, *a, **k): form = form_type(request.GET) From fb022bbc7b92f8ba313035882c352ea9f2513597 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 09:55:29 +1000 Subject: [PATCH 251/418] Adds a view that shows all reports --- registrasion/staff_views.py | 39 +++++++++++++++++++++++++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 40 insertions(+) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 77226ab0..c07c5a92 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,7 +1,10 @@ import forms import views +from collections import namedtuple + from django.contrib.auth.decorators import user_passes_test +from django.core.urlresolvers import reverse from django.db import models from django.db.models import F, Q from django.db.models import Sum @@ -54,6 +57,11 @@ class Report(object): return self._data +''' A list of report views objects that can be used to load a list of +reports. ''' +_all_report_views = [] + + def report(title, form_type): ''' Decorator that converts a report view function into something that displays a Report. @@ -83,10 +91,41 @@ def report(title, form_type): return render(request, "registrasion/report.html", ctx) + # Add this report to the list of reports -- makes this reversable. + _all_report_views.append(inner_view) + + # Return the callable return inner_view return _report +@user_passes_test(views._staff_only) +def reports_list(request): + ''' Lists all of the reports currently available. ''' + + reports = [] + + for report in _all_report_views: + reports.append({ + "name" : report.__name__, + "url" : reverse(report), + "description" : report.__doc__, + }) + + reports.sort(key=lambda report: report["name"]) + + ctx = { + "reports" : reports, + } + + print reports + + return render(request, "registrasion/reports_list.html", ctx) + + +# Report functions + + @report("Paid items", forms.ProductAndCategoryForm) def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of diff --git a/registrasion/urls.py b/registrasion/urls.py index 43f58110..d051c277 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -37,6 +37,7 @@ public = [ reports = [ + url(r"^$", staff_views.reports_list, name="reports_list"), url(r"^inventory/?$", staff_views.inventory, name="inventory"), url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), ] From 86d1ab71603ce7c834ed750122ff3ec166366c5c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:08:11 +1000 Subject: [PATCH 252/418] Refactors core reporting bits into a reporting package --- registrasion/reporting/__init__.py | 0 registrasion/reporting/reports.py | 89 ++++++++++++++++++++++++++++++ registrasion/staff_views.py | 89 ++---------------------------- 3 files changed, 93 insertions(+), 85 deletions(-) create mode 100644 registrasion/reporting/__init__.py create mode 100644 registrasion/reporting/reports.py diff --git a/registrasion/reporting/__init__.py b/registrasion/reporting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py new file mode 100644 index 00000000..799711b7 --- /dev/null +++ b/registrasion/reporting/reports.py @@ -0,0 +1,89 @@ +from collections import namedtuple + +from django.contrib.auth.decorators import user_passes_test +from django.core.urlresolvers import reverse +from django.db import models +from django.db.models import F, Q +from django.db.models import Sum +from django.db.models import Case, When, Value +from django.http import Http404 +from django.shortcuts import render +from functools import wraps + +from registrasion import forms +from registrasion import views +from registrasion.models import commerce +from registrasion.models import inventory + + +''' 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, title, headings, data): + self._headings = headings + self._data = data + + @property + def title(self): + ''' Returns the title for this report. ''' + return self._title + + @property + def headings(self): + ''' Returns the headings for the table. ''' + return self._headings + + @property + def data(self): + ''' Returns the data rows for the table. ''' + return self._data + + +def report_view(title, form_type): + ''' Decorator that converts a report view function into something that + displays a Report. + + Arguments: + title (str): + The title of the report. + form_type: + A form class that can make this report display things. + + ''' + + def _report(view): + + @wraps(view) + @user_passes_test(views._staff_only) + def inner_view(request, *a, **k): + + form = form_type(request.GET) + if form.is_valid() and form.has_changed(): + report = view(request, form, *a, **k) + else: + report = None + + ctx = { + "title": title, + "form": form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + # Add this report to the list of reports -- makes this reversable. + _all_report_views.append(inner_view) + + # Return the callable + return inner_view + return _report + + +def get_all_reports(): + ''' Returns all the views that have been registered with @report ''' + + return list(_all_report_views) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index c07c5a92..96ade521 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -16,87 +16,8 @@ from functools import wraps from models import commerce from models import inventory - -''' - -All reports must be viewable by staff only (permissions?) - -Reports can have: - -A form - * Reports are all *gettable* - you can save a URL and get back to the same - report - * Fetching a report *cannot* break the underlying data. -A table - * Headings - * Data lines - * Formats are pluggable - -''' - - -class Report(object): - - def __init__(self, title, headings, data): - self._headings = headings - self._data = data - - @property - def title(self): - ''' Returns the title for this report. ''' - return self._title - - @property - def headings(self): - ''' Returns the headings for the table. ''' - return self._headings - - @property - def data(self): - ''' Returns the data rows for the table. ''' - return self._data - - -''' A list of report views objects that can be used to load a list of -reports. ''' -_all_report_views = [] - - -def report(title, form_type): - ''' Decorator that converts a report view function into something that - displays a Report. - - Arguments: - form_type: A form class that can make this report display things. - - ''' - - def _report(view): - - @wraps(view) - @user_passes_test(views._staff_only) - def inner_view(request, *a, **k): - - form = form_type(request.GET) - if form.is_valid() and form.has_changed(): - report = view(request, form, *a, **k) - else: - report = None - - ctx = { - "title": title, - "form": form, - "report": report, - } - - return render(request, "registrasion/report.html", ctx) - - # Add this report to the list of reports -- makes this reversable. - _all_report_views.append(inner_view) - - # Return the callable - return inner_view - return _report +from reporting.reports import Report +from reporting.reports import report_view @user_passes_test(views._staff_only) @@ -118,15 +39,13 @@ def reports_list(request): "reports" : reports, } - print reports - return render(request, "registrasion/reports_list.html", ctx) # Report functions -@report("Paid items", forms.ProductAndCategoryForm) +@report_view("Paid items", forms.ProductAndCategoryForm) def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' @@ -172,7 +91,7 @@ def items_sold(request, form): return Report("Paid items", headings, data) -@report("Inventory", forms.ProductAndCategoryForm) +@report_view("Inventory", forms.ProductAndCategoryForm) def inventory(request, form): ''' Summarises the inventory status of the given items, grouping by invoice status. ''' From 960de87343fada7dd1ee70cb61fd43d32590f861 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:10:21 +1000 Subject: [PATCH 253/418] oops --- registrasion/staff_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 96ade521..4bedf3d1 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -16,6 +16,7 @@ from functools import wraps from models import commerce from models import inventory +from reporting.reports import get_all_reports from reporting.reports import Report from reporting.reports import report_view @@ -26,7 +27,7 @@ def reports_list(request): reports = [] - for report in _all_report_views: + for report in get_all_reports(): reports.append({ "name" : report.__name__, "url" : reverse(report), From f1c8e90b77d336eeadf7a0e618e8030c5518d6eb Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:28:24 +1000 Subject: [PATCH 254/418] Makes the form type optional for reports --- registrasion/reporting/reports.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 799711b7..17f24dc4 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -50,8 +50,9 @@ def report_view(title, form_type): Arguments: title (str): The title of the report. - form_type: - A form class that can make this report display things. + form_type (forms.Form or None): + A form class that can make this report display things. If None, + no form will be displayed. ''' @@ -61,11 +62,13 @@ def report_view(title, form_type): @user_passes_test(views._staff_only) def inner_view(request, *a, **k): - form = form_type(request.GET) - if form.is_valid() and form.has_changed(): - report = view(request, form, *a, **k) + if form_type is not None: + form = form_type(request.GET) + form.is_valid() else: - report = None + form = None + + report = view(request, form, *a, **k) ctx = { "title": title, @@ -75,7 +78,7 @@ def report_view(title, form_type): return render(request, "registrasion/report.html", ctx) - # Add this report to the list of reports -- makes this reversable. + # Add this report to the list of reports. _all_report_views.append(inner_view) # Return the callable From 499c4209cfdda2fc34804c8405b05314304d6787 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:30:12 +1000 Subject: [PATCH 255/418] Makes form_type *properly* optional --- registrasion/reporting/reports.py | 8 ++++---- registrasion/staff_views.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 17f24dc4..36e11942 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -43,16 +43,16 @@ class Report(object): return self._data -def report_view(title, form_type): +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 (forms.Form or None): - A form class that can make this report display things. If None, - no form will be displayed. + form_type (Optional[forms.Form]): + A form class that can make this report display things. If not + supplied, no form will be displayed. ''' diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 4bedf3d1..a21e663b 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -46,7 +46,7 @@ def reports_list(request): # Report functions -@report_view("Paid items", forms.ProductAndCategoryForm) +@report_view("Paid items", form_type=forms.ProductAndCategoryForm) def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' @@ -92,7 +92,7 @@ def items_sold(request, form): return Report("Paid items", headings, data) -@report_view("Inventory", forms.ProductAndCategoryForm) +@report_view("Inventory", form_type=forms.ProductAndCategoryForm) def inventory(request, form): ''' Summarises the inventory status of the given items, grouping by invoice status. ''' From 372512c6affc4c7b9bb6dde4d4e456896e3f687f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:40:28 +1000 Subject: [PATCH 256/418] Adds report to view credit notes. --- registrasion/staff_views.py | 27 +++++++++++++++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 28 insertions(+) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index a21e663b..6672bfba 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -173,3 +173,30 @@ def inventory(request, form): ]) return Report("Inventory", headings, 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", + ) + + headings = [ + "id", "Owner", "Status", "Value", + ] + + data = [] + for note in notes: + data.append([ + note.id, + note.invoice.user.attendee.attendeeprofilebase.invoice_recipient(), + note.status, + note.value, + ]) + + return Report("Credit Notes", headings, data) diff --git a/registrasion/urls.py b/registrasion/urls.py index d051c277..8f7408da 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -38,6 +38,7 @@ public = [ reports = [ url(r"^$", staff_views.reports_list, name="reports_list"), + url(r"^credit_notes/?$", staff_views.credit_notes, name="credit_notes"), url(r"^inventory/?$", staff_views.inventory, name="inventory"), url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), ] From f9e26a2e492b9b5db0934c3fb80a1de5d3c0f7ce Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:05:38 +1000 Subject: [PATCH 257/418] Adds the link_view concept to reports; adds a link_view to credit notes report --- registrasion/reporting/reports.py | 11 ++++++++++- registrasion/staff_views.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 36e11942..817cc5de 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -23,9 +23,10 @@ _all_report_views = [] class Report(object): - def __init__(self, title, headings, data): + def __init__(self, title, headings, data, link_view=None): self._headings = headings self._data = data + self._link_view = link_view @property def title(self): @@ -42,6 +43,14 @@ class Report(object): ''' Returns the data rows for the table. ''' return self._data + @property + def link_view(self): + ''' Returns the URL name or the view callable that can be used to + view the row's detail. The left-most value is passed into `reverse` + as an argument. ''' + + return self._link_view + def report_view(title, form_type=None): ''' Decorator that converts a report view function into something that diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 6672bfba..47174fdb 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -199,4 +199,4 @@ def credit_notes(request, form): note.value, ]) - return Report("Credit Notes", headings, data) + return Report("Credit Notes", headings, data, link_view="credit_note") From 4664c4711a943e06be9d97ed2082ff2f623509f2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:20:03 +1000 Subject: [PATCH 258/418] Moves staff_views to reporting/views --- .../{staff_views.py => reporting/views.py} | 15 +++++++-------- registrasion/urls.py | 10 +++++----- 2 files changed, 12 insertions(+), 13 deletions(-) rename registrasion/{staff_views.py => reporting/views.py} (95%) diff --git a/registrasion/staff_views.py b/registrasion/reporting/views.py similarity index 95% rename from registrasion/staff_views.py rename to registrasion/reporting/views.py index 47174fdb..935a3299 100644 --- a/registrasion/staff_views.py +++ b/registrasion/reporting/views.py @@ -1,6 +1,3 @@ -import forms -import views - from collections import namedtuple from django.contrib.auth.decorators import user_passes_test @@ -13,12 +10,14 @@ from django.http import Http404 from django.shortcuts import render from functools import wraps -from models import commerce -from models import inventory +from registrasion import forms +from registrasion.models import commerce +from registrasion.models import inventory +from registrasion import views -from reporting.reports import get_all_reports -from reporting.reports import Report -from reporting.reports import report_view +from reports import get_all_reports +from reports import Report +from reports import report_view @user_passes_test(views._staff_only) diff --git a/registrasion/urls.py b/registrasion/urls.py index 8f7408da..22df0e51 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,5 +1,5 @@ import views -import staff_views +from reporting import views as reporting_views from django.conf.urls import include from django.conf.urls import url @@ -37,10 +37,10 @@ public = [ reports = [ - url(r"^$", staff_views.reports_list, name="reports_list"), - url(r"^credit_notes/?$", staff_views.credit_notes, name="credit_notes"), - url(r"^inventory/?$", staff_views.inventory, name="inventory"), - url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), + url(r"^$", reporting_views.reports_list, name="reports_list"), + url(r"^credit_notes/?$", reporting_views.credit_notes, name="inventory"), + url(r"^inventory/?$", reporting_views.inventory, name="inventory"), + url(r"^items_sold/?$", reporting_views.items_sold, name="items_sold"), ] From aacdab7d16ee55a4ba590518c499418da8cbbb0f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:25:50 +1000 Subject: [PATCH 259/418] The reporting module now passes flake8 --- registrasion/reporting/reports.py | 11 ----------- registrasion/reporting/views.py | 17 ++++++----------- registrasion/urls.py | 8 ++++++-- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 817cc5de..f8339a5d 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -1,19 +1,8 @@ -from collections import namedtuple - from django.contrib.auth.decorators import user_passes_test -from django.core.urlresolvers import reverse -from django.db import models -from django.db.models import F, Q -from django.db.models import Sum -from django.db.models import Case, When, Value -from django.http import Http404 from django.shortcuts import render from functools import wraps -from registrasion import forms from registrasion import views -from registrasion.models import commerce -from registrasion.models import inventory ''' A list of report views objects that can be used to load a list of diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 935a3299..d44de514 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -1,18 +1,13 @@ -from collections import namedtuple - from django.contrib.auth.decorators import user_passes_test from django.core.urlresolvers import reverse from django.db import models from django.db.models import F, Q from django.db.models import Sum from django.db.models import Case, When, Value -from django.http import Http404 from django.shortcuts import render -from functools import wraps from registrasion import forms from registrasion.models import commerce -from registrasion.models import inventory from registrasion import views from reports import get_all_reports @@ -28,15 +23,15 @@ def reports_list(request): for report in get_all_reports(): reports.append({ - "name" : report.__name__, - "url" : reverse(report), - "description" : report.__doc__, + "name": report.__name__, + "url": reverse(report), + "description": report.__doc__, }) reports.sort(key=lambda report: report["name"]) ctx = { - "reports" : reports, + "reports": reports, } return render(request, "registrasion/reports_list.html", ctx) @@ -91,8 +86,8 @@ def items_sold(request, form): return Report("Paid items", headings, data) -@report_view("Inventory", form_type=forms.ProductAndCategoryForm) -def inventory(request, form): +@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. ''' diff --git a/registrasion/urls.py b/registrasion/urls.py index 22df0e51..d87b13fe 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -38,8 +38,12 @@ public = [ reports = [ url(r"^$", reporting_views.reports_list, name="reports_list"), - url(r"^credit_notes/?$", reporting_views.credit_notes, name="inventory"), - url(r"^inventory/?$", reporting_views.inventory, name="inventory"), + url(r"^credit_notes/?$", reporting_views.credit_notes, name="credit_notes"), + url( + r"^product_status/?$", + reporting_views.product_status, + name="product_status", + ), url(r"^items_sold/?$", reporting_views.items_sold, name="items_sold"), ] From 64ca477cb89a818252e5fc7336102e0399674622 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:43:27 +1000 Subject: [PATCH 260/418] Fixes flake8 snafus --- registrasion/contrib/mail.py | 8 +++++++- registrasion/controllers/cart.py | 16 +++++++--------- registrasion/controllers/category.py | 1 + registrasion/controllers/discount.py | 1 + registrasion/controllers/flag.py | 1 - registrasion/forms.py | 16 +++++++++------- registrasion/tests/patches.py | 1 + registrasion/tests/test_batch.py | 9 +-------- registrasion/tests/test_cart.py | 2 +- registrasion/urls.py | 7 +++++-- registrasion/views.py | 7 +++++-- 11 files changed, 38 insertions(+), 31 deletions(-) diff --git a/registrasion/contrib/mail.py b/registrasion/contrib/mail.py index 06ca8fd2..e667c570 100644 --- a/registrasion/contrib/mail.py +++ b/registrasion/contrib/mail.py @@ -58,6 +58,12 @@ def __send_email__(template_prefix, to, kind, **kwargs): except AttributeError: bcc_email = None - email = EmailMultiAlternatives(subject, message_plaintext, from_email, to, bcc=bcc_email) + email = EmailMultiAlternatives( + subject, + message_plaintext, + from_email, + to, + bcc=bcc_email, + ) email.attach_alternative(message_html, "text/html") email.send() diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index d0a9f057..9f27ab49 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,5 +1,10 @@ +from .batch import BatchController +from .category import CategoryController +from .discount import DiscountController +from .flag import FlagController +from .product import ProductController + import collections -import contextlib import datetime import functools import itertools @@ -16,12 +21,6 @@ from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import inventory -from.batch import BatchController -from .category import CategoryController -from .discount import DiscountController -from .flag import FlagController -from .product import ProductController - def _modifies_cart(func): ''' Decorator that makes the wrapped function raise ValidationError @@ -94,11 +93,10 @@ class CartController(object): self.cart.time_last_updated = timezone.now() 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'): + if hasattr(self, '_modified_by_batch'): self._end_batch() def _end_batch(self): diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index 4adf09b6..e1865404 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -9,6 +9,7 @@ from django.db.models import Value from .batch import BatchController + class AllProducts(object): pass diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 984fe214..9329f7e6 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -12,6 +12,7 @@ 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. diff --git a/registrasion/controllers/flag.py b/registrasion/controllers/flag.py index c094da59..b8d1b4e3 100644 --- a/registrasion/controllers/flag.py +++ b/registrasion/controllers/flag.py @@ -1,5 +1,4 @@ import itertools -import operator from collections import defaultdict from collections import namedtuple diff --git a/registrasion/forms.py b/registrasion/forms.py index 7e7ddc16..2b5ef97b 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -2,7 +2,6 @@ from registrasion.models import commerce from registrasion.models import inventory from django import forms -from django.core.exceptions import ValidationError class ApplyCreditNoteForm(forms.Form): @@ -54,10 +53,11 @@ def ProductsForm(category, products): type. ''' # Each Category.RENDER_TYPE value has a subclass here. + cat = inventory.Category RENDER_TYPES = { - inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, - inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, - inventory.Category.RENDER_TYPE_ITEM_QUANTITY: _ItemQuantityProductsForm, + 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 @@ -211,13 +211,15 @@ class _RadioButtonProductsForm(_ProductsForm): ) def add_product_error(self, product, error): - self.add_error(cls.FIELD, 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.''' + 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" diff --git a/registrasion/tests/patches.py b/registrasion/tests/patches.py index 7d7cd66c..26c63789 100644 --- a/registrasion/tests/patches.py +++ b/registrasion/tests/patches.py @@ -2,6 +2,7 @@ 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. ''' diff --git a/registrasion/tests/test_batch.py b/registrasion/tests/test_batch.py index 70370799..aa11f113 100644 --- a/registrasion/tests/test_batch.py +++ b/registrasion/tests/test_batch.py @@ -1,16 +1,8 @@ -import datetime import pytz -from django.core.exceptions import ValidationError - -from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase from registrasion.controllers.batch import BatchController -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') @@ -124,6 +116,7 @@ class BatchTestCase(RegistrationCartTestCase): def test_batch_end_functionality_is_called(self): class Ender(object): end_count = 0 + def end_batch(self): self.end_count += 1 diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index bee94322..a6803150 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -375,7 +375,7 @@ class BasicCartTests(RegistrationCartTestCase): with BatchController.batch(self.USER_1): # Memoise the cart - same_cart = TestingCartController.for_user(self.USER_1) + TestingCartController.for_user(self.USER_1) # Do nothing on exit rev_1 = self.reget(cart.cart).revision diff --git a/registrasion/urls.py b/registrasion/urls.py index d87b13fe..b6b120c1 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,3 @@ -import views from reporting import views as reporting_views from django.conf.urls import include @@ -38,7 +37,11 @@ public = [ reports = [ url(r"^$", reporting_views.reports_list, name="reports_list"), - url(r"^credit_notes/?$", reporting_views.credit_notes, name="credit_notes"), + url( + r"^credit_notes/?$", + reporting_views.credit_notes, + name="credit_notes" + ), url( r"^product_status/?$", reporting_views.product_status, diff --git a/registrasion/views.py b/registrasion/views.py index 13ebd927..7216273e 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -355,7 +355,10 @@ def product_category(request, category_id): if not products: messages.warning( request, - "There are no products available from category: " + category.name, + ( + "There are no products available from category: " + + category.name + ), ) return redirect("dashboard") @@ -456,7 +459,7 @@ def _set_quantities_from_products_form(products_form, current_cart): id__in=pks, ).select_related("category").order_by("id") - quantities.sort(key = lambda i: i[0]) + quantities.sort(key=lambda i: i[0]) # Match the product objects to their quantities product_quantities = [ From 25608b1653e7a113a239d11ffe9d96e6ff2c320e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 14:33:23 +1000 Subject: [PATCH 261/418] Moves reports forms into reporting sub package --- registrasion/forms.py | 13 ------------- registrasion/reporting/forms.py | 15 +++++++++++++++ registrasion/reporting/views.py | 3 ++- 3 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 registrasion/reporting/forms.py diff --git a/registrasion/forms.py b/registrasion/forms.py index 2b5ef97b..d6e7878e 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -347,16 +347,3 @@ class VoucherForm(forms.Form): help_text="If you have a voucher code, enter it here", required=False, ) - - -# Staff-facing forms. - -class ProductAndCategoryForm(forms.Form): - product = forms.ModelMultipleChoiceField( - queryset=inventory.Product.objects.all(), - required=False, - ) - category = forms.ModelMultipleChoiceField( - queryset=inventory.Category.objects.all(), - required=False, - ) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py new file mode 100644 index 00000000..b741e51b --- /dev/null +++ b/registrasion/reporting/forms.py @@ -0,0 +1,15 @@ +from registrasion.models import inventory + +from django import forms + +# Staff-facing forms. + +class ProductAndCategoryForm(forms.Form): + product = forms.ModelMultipleChoiceField( + queryset=inventory.Product.objects.all(), + required=False, + ) + category = forms.ModelMultipleChoiceField( + queryset=inventory.Category.objects.all(), + required=False, + ) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index d44de514..d6b6261c 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -1,3 +1,5 @@ +import forms + from django.contrib.auth.decorators import user_passes_test from django.core.urlresolvers import reverse from django.db import models @@ -6,7 +8,6 @@ from django.db.models import Sum from django.db.models import Case, When, Value from django.shortcuts import render -from registrasion import forms from registrasion.models import commerce from registrasion import views From 48a036204d372c117e68f1f34630d95343d384b9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 15:14:54 +1000 Subject: [PATCH 262/418] Reporting framework can now display multiple sections. --- registrasion/reporting/reports.py | 8 ++++++-- registrasion/urls.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index f8339a5d..e756622e 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -13,6 +13,7 @@ _all_report_views = [] class Report(object): def __init__(self, title, headings, data, link_view=None): + self._title = title self._headings = headings self._data = data self._link_view = link_view @@ -66,12 +67,15 @@ def report_view(title, form_type=None): else: form = None - report = view(request, form, *a, **k) + reports = view(request, form, *a, **k) + + if isinstance(reports, Report): + reports = [reports] ctx = { "title": title, "form": form, - "report": report, + "reports": reports, } return render(request, "registrasion/report.html", ctx) diff --git a/registrasion/urls.py b/registrasion/urls.py index b6b120c1..5c304091 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -37,6 +37,8 @@ public = [ reports = [ url(r"^$", reporting_views.reports_list, name="reports_list"), + url(r"^attendee/?$", reporting_views.attendee, name="attendee"), + url(r"^attendee/([0-9]*)$", reporting_views.attendee, name="attendee"), url( r"^credit_notes/?$", reporting_views.credit_notes, From e27e322c41569da61b58cfc9cd0424268f625ad2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 15:15:15 +1000 Subject: [PATCH 263/418] Adds the attendee list and stubs the attendee manifest reports --- registrasion/reporting/forms.py | 7 ++++ registrasion/reporting/views.py | 70 ++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index b741e51b..f4902660 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -13,3 +13,10 @@ class ProductAndCategoryForm(forms.Form): queryset=inventory.Category.objects.all(), required=False, ) + + +class UserIdForm(forms.Form): + user = forms.IntegerField( + label="User ID", + required=False, + ) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index d6b6261c..fcb5d1e6 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -4,11 +4,12 @@ from django.contrib.auth.decorators import user_passes_test from django.core.urlresolvers import reverse from django.db import models from django.db.models import F, Q -from django.db.models import Sum +from django.db.models import Count, Sum from django.db.models import Case, When, Value from django.shortcuts import render from registrasion.models import commerce +from registrasion.models import people from registrasion import views from reports import get_all_reports @@ -195,3 +196,70 @@ def credit_notes(request, form): ]) return Report("Credit Notes", headings, data, link_view="credit_note") + + +@report_view("Attendee", form_type=forms.UserIdForm) +def attendee(request, form, attendee_id=None): + ''' Returns a list of all manifested attendees if no attendee is specified, + else displays the attendee manifest. ''' + + if attendee_id is None and not form.has_changed(): + return attendee_list(request) + + reports = [] + + # TODO: METADATA. + + + # Paid products + headings = ["Product", "Quantity"] + data = [] + reports.append(Report("Paid Products", headings, data)) + + # Unpaid products + headings = ["Product", "Quantity"] + data = [] + reports.append( Report("Unpaid Products", headings, data)) + + # Invoices + headings = ["Invoice ID", "Status", "Amount"] + data = [] + reports.append( Report("Invoices", headings, data)) + + # Credit Notes + headings = ["Note ID", "Status", "Value"] + data = [] + reports.append( Report("Credit Notes", headings, data)) + + return reports + + +def attendee_list(request): + ''' Returns a list of all attendees. ''' + + attendees = people.Attendee.objects.all().select_related( + "attendeeprofilebase", + ) + attendees = attendees.annotate( + has_registered=Count( + Q(user__invoice__status=commerce.Invoice.STATUS_PAID) + ), + ) + + headings = [ + "User ID", "Email", "Has registered", + ] + + data = [] + + for attendee in attendees: + data.append([ + attendee.user.id, + attendee.user.email, + attendee.has_registered > 0, + ]) + + # Sort by whether they've registered, then ID. + data.sort(key=lambda attendee: (-attendee[2], attendee[0])) + + return Report("Attendees", headings, data, link_view="attendee") From d58b2811f9244d3ea9cd7b20fd8286a2b960ffa8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 15:37:57 +1000 Subject: [PATCH 264/418] Makes the attendee list work better. --- registrasion/reporting/views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index fcb5d1e6..58c86430 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -240,7 +240,8 @@ def attendee_list(request): attendees = people.Attendee.objects.all().select_related( "attendeeprofilebase", ) - attendees = attendees.annotate( + + attendees = attendees.values("id", "user__email").annotate( has_registered=Count( Q(user__invoice__status=commerce.Invoice.STATUS_PAID) ), @@ -254,9 +255,9 @@ def attendee_list(request): for attendee in attendees: data.append([ - attendee.user.id, - attendee.user.email, - attendee.has_registered > 0, + attendee["id"], + attendee["user__email"], + attendee["has_registered"], ]) # Sort by whether they've registered, then ID. From 17fc874212bd861749fa660043863fb9dee980f2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 15:43:01 +1000 Subject: [PATCH 265/418] Attendee manifest now displays credit notes. --- registrasion/reporting/views.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 58c86430..f52c2d79 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -206,6 +206,11 @@ def attendee(request, form, attendee_id=None): if attendee_id is None and not form.has_changed(): return attendee_list(request) + if attendee_id is None: + attendee_id = form.user + + attendee = people.Attendee.objects.get(id=attendee_id) + reports = [] # TODO: METADATA. @@ -222,14 +227,34 @@ def attendee(request, form, attendee_id=None): reports.append( Report("Unpaid Products", headings, data)) # Invoices - headings = ["Invoice ID", "Status", "Amount"] + headings = ["Invoice ID", "Status", "Value"] data = [] - reports.append( Report("Invoices", headings, data)) + + invoices = commerce.Invoice.objects.filter( + user=attendee.user, + ) + for invoice in invoices: + data.append([ + invoice.id, invoice.get_status_display(), invoice.value, + ]) + + reports.append(Report("Invoices", headings, data, link_view="invoice")) # Credit Notes headings = ["Note ID", "Status", "Value"] data = [] - reports.append( Report("Credit Notes", headings, data)) + + credit_notes = commerce.CreditNote.objects.filter( + invoice__user=attendee.user, + ) + for credit_note in credit_notes: + data.append([ + credit_note.id, credit_note.status, credit_note.value, + ]) + + reports.append( + Report("Credit Notes", headings, data, link_view="credit_note") + ) return reports From 68aa9b067bdb2d25c2ed50fa19ff95e816312724 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 15:57:20 +1000 Subject: [PATCH 266/418] Factors items_pending and items_purchased into ItemController --- registrasion/controllers/item.py | 95 +++++++++++++++++++ .../templatetags/registrasion_tags.py | 84 ++-------------- 2 files changed, 101 insertions(+), 78 deletions(-) create mode 100644 registrasion/controllers/item.py diff --git a/registrasion/controllers/item.py b/registrasion/controllers/item.py new file mode 100644 index 00000000..a36604e5 --- /dev/null +++ b/registrasion/controllers/item.py @@ -0,0 +1,95 @@ +''' NEEDS TESTS ''' + +from registrasion.models import commerce +from registrasion.models import inventory + +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_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. + + ''' + + in_cart = ( + Q(productitem__cart__user=self.user) & + Q(productitem__cart__status=commerce.Cart.STATUS_PAID) + ) + + 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(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. + + ''' + + all_items = commerce.ProductItem.objects.filter( + cart__user=self.user, + cart__status=commerce.Cart.STATUS_ACTIVE, + ).select_related( + "product", + "product__category", + ).order_by( + "product__category__order", + "product__order", + ) + return all_items diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 9074781c..2d6f51a4 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -1,31 +1,12 @@ from registrasion.models import commerce -from registrasion.models import inventory from registrasion.controllers.category import CategoryController +from registrasion.controllers.item import ItemController -from collections import namedtuple from django import template -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 register = template.Library() -_ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) - - -class ProductAndQuantity(_ProductAndQuantity): - ''' Class that holds a product and a quantity. - - Attributes: - product (models.inventory.Product) - - quantity (int) - - ''' - pass - @register.assignment_tag(takes_context=True) def available_categories(context): @@ -67,71 +48,18 @@ def invoices(context): @register.assignment_tag(takes_context=True) def items_pending(context): - ''' 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. - - ''' - - all_items = commerce.ProductItem.objects.filter( - cart__user=context.request.user, - cart__status=commerce.Cart.STATUS_ACTIVE, - ).select_related( - "product", - "product__category", - ).order_by( - "product__category__order", - "product__order", - ) - return all_items + ''' Gets all of the items that the user from this context has reserved.''' + return ItemController(context.request.user).items_pending() @register.assignment_tag(takes_context=True) def items_purchased(context, category=None): - ''' Aggregates the items that this user has purchased. + ''' Returns the items purchased for this user. ''' - 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. - - ''' - - in_cart = ( - Q(productitem__cart__user=context.request.user) & - Q(productitem__cart__status=commerce.Cart.STATUS_PAID) + return ItemController(context.request.user).items_purchased( + category=category ) - 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 - @register.filter def multiply(value, arg): From 964fe380da0974089fbb21602a6d3f2ffbe8edc4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 15:57:44 +1000 Subject: [PATCH 267/418] Attendee manifest page now reports the items a user has pending and purchased. --- registrasion/reporting/views.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index f52c2d79..ec1e03db 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -8,6 +8,7 @@ from django.db.models import Count, Sum from django.db.models import Case, When, Value from django.shortcuts import render +from registrasion.controllers.item import ItemController from registrasion.models import commerce from registrasion.models import people from registrasion import views @@ -215,15 +216,29 @@ def attendee(request, form, attendee_id=None): # TODO: METADATA. - + ic = ItemController(attendee.user) # Paid products headings = ["Product", "Quantity"] data = [] + + for pq in ic.items_purchased(): + data.append([ + pq.product, + pq.quantity, + ]) + reports.append(Report("Paid Products", headings, data)) # Unpaid products headings = ["Product", "Quantity"] data = [] + + for pq in ic.items_pending(): + data.append([ + pq.product, + pq.quantity, + ]) + reports.append( Report("Unpaid Products", headings, data)) # Invoices From 5b03ae8ff6296e5c982118bcd1951fdd6992e8b3 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 16:03:29 +1000 Subject: [PATCH 268/418] Fixes credit note bug --- registrasion/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registrasion/views.py b/registrasion/views.py index 7216273e..95e97793 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -747,6 +747,7 @@ def credit_note(request, note_id, access_code=None): ''' + note_id = int(note_id) current_note = CreditNoteController.for_id_or_404(note_id) apply_form = forms.ApplyCreditNoteForm( From e7556b02b756fdd2e4ade67fc5a1e9506ae89320 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 16:14:58 +1000 Subject: [PATCH 269/418] Fixes a minor oops --- registrasion/reporting/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index ec1e03db..0f1fa1c9 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -207,8 +207,8 @@ def attendee(request, form, attendee_id=None): if attendee_id is None and not form.has_changed(): return attendee_list(request) - if attendee_id is None: - attendee_id = form.user + if form.cleaned_data["user"] is not None: + attendee_id = form.cleaned_data["user"] attendee = people.Attendee.objects.get(id=attendee_id) From 5a7819b0d725413b3a2e06382207ac12e04d198b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 11:07:46 +1000 Subject: [PATCH 270/418] Test for issue 64 --- registrasion/tests/test_invoice.py | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index bd8c4340..4b971f73 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -534,6 +534,44 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice = TestingInvoiceController.for_cart(cart.cart) + 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. ''' + + cart = TestingCartController.for_user(self.USER_1) + + cart.add_to_cart(self.PROD_1, 1) + invoice = TestingInvoiceController.for_cart(cart.cart) + + # 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, and apply partial payments + invoice = TestingInvoiceController.for_cart(cart.cart) + cn.apply_to_invoice(invoice.invoice) + + # 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) + invoice2 = TestingInvoiceController.for_cart(cart.cart) + + invoice.invoice.refresh_from_db() + self.assertEquals( + commerce.invoice.STATUS_REFUNDED, + invoice.invoice.status, + ) + + self.assertEquals(cn.credit_note.value, invoice.total_payments()) + def test_sends_email_on_invoice_creation(self): invoice = self._invoice_containing_prod_1(1) self.assertEquals(1, len(self.emails)) From 0329ee7bb2c9d63c258dd7a7e7ddf23a829802e7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 11:11:44 +1000 Subject: [PATCH 271/418] Amends test to test *both* paths for validating invoices. --- registrasion/tests/test_invoice.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 4b971f73..8528a708 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -562,6 +562,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # 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) invoice.invoice.refresh_from_db() From cdc6e229dc6529d9b03cb38aa086292ca763688d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 11:31:39 +1000 Subject: [PATCH 272/418] Etc (squash. srsly) --- registrasion/tests/test_invoice.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 8528a708..57e51adc 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -565,13 +565,19 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice = TestingInvoiceController.for_id(invoice.invoice.id) invoice2 = TestingInvoiceController.for_cart(cart.cart) - invoice.invoice.refresh_from_db() + invoice._refresh() + + # The first invoice should be refunded self.assertEquals( commerce.invoice.STATUS_REFUNDED, invoice.invoice.status, ) - self.assertEquals(cn.credit_note.value, invoice.total_payments()) + # The credit note should be equal to the payments value of first inv + self.assertEquals( + cn.credit_note.value, + invoice.total_payments(), + ) def test_sends_email_on_invoice_creation(self): invoice = self._invoice_containing_prod_1(1) From 1e6c90163dd06cec9348e49174d33e4d751a8f62 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 11:46:24 +1000 Subject: [PATCH 273/418] Fixes #64 --- registrasion/controllers/invoice.py | 12 ++++++++---- registrasion/tests/test_invoice.py | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 616f418d..2c69faed 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -43,16 +43,16 @@ class InvoiceController(ForId, object): cart_controller = CartController(cart) cart_controller.validate_cart() # Raises ValidationError on fail. - cls.void_all_invoices(cart) + cls.update_old_invoices(cart) invoice = cls._generate(cart) return cls(invoice) @classmethod - def void_all_invoices(cls, cart): + def update_old_invoices(cls, cart): invoices = commerce.Invoice.objects.filter(cart=cart).all() for invoice in invoices: - cls(invoice).void() + cls(invoice).update_status() @classmethod def resolve_discount_value(cls, item): @@ -299,7 +299,11 @@ class InvoiceController(ForId, object): def update_validity(self): ''' Voids this invoice if the cart it is attached to has updated. ''' if not self._invoice_matches_cart(): - self.void() + if self.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. ''' diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 57e51adc..6d36d082 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -564,19 +564,20 @@ class InvoiceTestCase(RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) invoice = TestingInvoiceController.for_id(invoice.invoice.id) invoice2 = TestingInvoiceController.for_cart(cart.cart) + cn2 = self._credit_note_for_invoice(invoice.invoice) invoice._refresh() # The first invoice should be refunded self.assertEquals( - commerce.invoice.STATUS_REFUNDED, + commerce.Invoice.STATUS_VOID, invoice.invoice.status, ) - # The credit note should be equal to the payments value of first inv + # Both credit notes should be for the same amount self.assertEquals( cn.credit_note.value, - invoice.total_payments(), + cn2.credit_note.value, ) def test_sends_email_on_invoice_creation(self): From da42bb2baca8915b21cac39e180cee46bad0146b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 11:51:12 +1000 Subject: [PATCH 274/418] Shows all the payments an attendee has made. Fixes #66 --- registrasion/reporting/views.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 0f1fa1c9..39518480 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -271,6 +271,23 @@ def attendee(request, form, attendee_id=None): Report("Credit Notes", headings, data, link_view="credit_note") ) + # All payments + headings = ["To Invoice", "Payment ID", "Reference", "Amount"] + data = [] + + payments = commerce.PaymentBase.objects.filter( + invoice__user=attendee.user, + ) + for payment in payments: + data.append([ + payment.invoice.id, payment.id, payment.reference, payment.amount, + ]) + + reports.append( + Report("Payments", headings, data, link_view="invoice") + ) + + return reports From 96e691c5dd8e6b776a359c3a138f444a9f585720 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 12:16:52 +1000 Subject: [PATCH 275/418] Tidies up reporting URLs --- registrasion/urls.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 5c304091..ba9f447c 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,4 @@ -from reporting import views as reporting_views +from reporting import views as rv from django.conf.urls import include from django.conf.urls import url @@ -36,20 +36,12 @@ public = [ reports = [ - url(r"^$", reporting_views.reports_list, name="reports_list"), - url(r"^attendee/?$", reporting_views.attendee, name="attendee"), - url(r"^attendee/([0-9]*)$", reporting_views.attendee, name="attendee"), - url( - r"^credit_notes/?$", - reporting_views.credit_notes, - name="credit_notes" - ), - url( - r"^product_status/?$", - reporting_views.product_status, - name="product_status", - ), - url(r"^items_sold/?$", reporting_views.items_sold, name="items_sold"), + url(r"^$", rv.reports_list, name="reports_list"), + url(r"^attendee/?$", rv.attendee, name="attendee"), + url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"), + url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), + url(r"^items_sold/?$", rv.items_sold, name="items_sold"), + url(r"^product_status/?$", rv.product_status, name="product_status"), ] From 4dbe69574c2a9249418ffe7f168eeb8a62c23d74 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 12:17:03 +1000 Subject: [PATCH 276/418] Adds report that tracks the free money in the system Fixes #52 --- registrasion/reporting/views.py | 36 +++++++++++++++++++++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 37 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 39518480..02557945 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -89,6 +89,42 @@ def items_sold(request, form): return Report("Paid items", headings, data) +@report_view("Reconcilitation") +def reconciliation(request, form): + ''' Reconciles all sales in the system with the payments in the + system. ''' + + headings = ["Thing", "Total"] + data = [] + + sales = commerce.LineItem.objects.filter( + invoice__status=commerce.Invoice.STATUS_PAID, + ).values( + "price", "quantity" + ).aggregate(total=Sum(F("price") * F("quantity"))) + + data.append(["Paid items", sales["total"]]) + + payments = commerce.PaymentBase.objects.values( + "amount", + ).aggregate(total=Sum("amount")) + + data.append(["Payments", payments["total"]]) + + ucn = commerce.CreditNote.unclaimed().values( + "amount" + ).aggregate(total=Sum("amount")) + + data.append(["Unclaimed credit notes", 0 - ucn["total"]]) + + data.append([ + "(Money not on invoices)", + sales["total"] - payments["total"] - ucn["total"], + ]) + + return Report("Sales and Payments", headings, data) + + @report_view("Product status", form_type=forms.ProductAndCategoryForm) def product_status(request, form): ''' Summarises the inventory status of the given items, grouping by diff --git a/registrasion/urls.py b/registrasion/urls.py index ba9f447c..ae6ac8a1 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -42,6 +42,7 @@ reports = [ url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), url(r"^items_sold/?$", rv.items_sold, name="items_sold"), url(r"^product_status/?$", rv.product_status, name="product_status"), + url(r"^reconciliation/?$", rv.reconciliation, name="reconciliation"), ] From f3a08a82bb967f500de955269a8bd696adf32fd5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 12:45:21 +1000 Subject: [PATCH 277/418] =?UTF-8?q?Shows=20the=20attendee=E2=80=99s=20name?= =?UTF-8?q?=20in=20the=20attendee=20list.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/models/people.py | 7 +++++++ registrasion/reporting/views.py | 14 ++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/registrasion/models/people.py b/registrasion/models/people.py index e36bd728..64d0ac40 100644 --- a/registrasion/models/people.py +++ b/registrasion/models/people.py @@ -67,6 +67,13 @@ class AttendeeProfileBase(models.Model): ''' 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): ''' diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 02557945..eb512454 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -332,28 +332,30 @@ def attendee_list(request): attendees = people.Attendee.objects.all().select_related( "attendeeprofilebase", + "user", ) - attendees = attendees.values("id", "user__email").annotate( + attendees = attendees.annotate( has_registered=Count( Q(user__invoice__status=commerce.Invoice.STATUS_PAID) ), ) headings = [ - "User ID", "Email", "Has registered", + "User ID", "Name", "Email", "Has registered", ] data = [] for attendee in attendees: data.append([ - attendee["id"], - attendee["user__email"], - attendee["has_registered"], + attendee.id, + attendee.attendeeprofilebase.attendee_name(), + attendee.user.email, + attendee.has_registered > 0, ]) # Sort by whether they've registered, then ID. - data.sort(key=lambda attendee: (-attendee[2], attendee[0])) + data.sort(key=lambda attendee: (-attendee[3], attendee[0])) return Report("Attendees", headings, data, link_view="attendee") From 897915f1217dedb96bdefe378b9d0012a9f2574d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 14:22:32 +1000 Subject: [PATCH 278/418] Adds the amend_registration view, which currently can display all of the products that the user has added to their current cart, and not much else. --- registrasion/forms.py | 15 +++++++++++++++ registrasion/urls.py | 2 ++ registrasion/views.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index d6e7878e..229f7bc5 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -347,3 +347,18 @@ class VoucherForm(forms.Form): help_text="If you have a voucher code, enter it here", required=False, ) + + +class StaffProductsForm(forms.Form): + ''' Form for allowing staff to add an item to a user's cart. ''' + + product = forms.ModelChoiceField( + widget=forms.Select, + queryset=inventory.Product.objects.all(), + ) + + quantity = forms.IntegerField( + min_value=0, + ) + +StaffProductsFormSet = forms.formset_factory(StaffProductsForm) diff --git a/registrasion/urls.py b/registrasion/urls.py index ae6ac8a1..dab49b00 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -13,10 +13,12 @@ from .views import ( invoice_access, edit_profile, guided_registration, + amend_registration, ) public = [ + url(r"^amend/([0-9]+)$", amend_registration, name="amend_registration"), url(r"^category/([0-9]+)$", product_category, name="product_category"), url(r"^checkout$", checkout, name="checkout"), url(r"^credit_note/([0-9]+)$", credit_note, name="credit_note"), diff --git a/registrasion/views.py b/registrasion/views.py index 95e97793..f4368be8 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -18,6 +18,7 @@ from collections import namedtuple from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import user_passes_test +from django.contrib.auth.models import User from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError @@ -790,3 +791,30 @@ def credit_note(request, note_id, access_code=None): } return render(request, "registrasion/credit_note.html", data) + + +@user_passes_test(_staff_only) +def amend_registration(request, user_id): + ''' Allows staff to amend a user's current registration cart, and etc etc. + ''' + + user = User.objects.get(id=int(user_id)) + current_cart = CartController.for_user(user) + + items = commerce.ProductItem.objects.filter( + cart=current_cart.cart, + ).select_related("product") + + initial = [{"product": i.product, "quantity": i.quantity} for i in items] + + form = forms.StaffProductsFormSet( + request.POST or None, + initial=initial, + prefix="products", + ) + + data = { + "form": form, + } + + return render(request, "registrasion/amend_registration.html", data) From 83b8b62d7457bd1261c03efb78ab31f618cda9a1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 14:24:58 +1000 Subject: [PATCH 279/418] Attendee view now uses user_id, like the rest of the app --- registrasion/reporting/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index eb512454..6a507135 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -236,17 +236,17 @@ def credit_notes(request, form): @report_view("Attendee", form_type=forms.UserIdForm) -def attendee(request, form, attendee_id=None): +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 attendee_id is None and not form.has_changed(): + if user_id is None and not form.has_changed(): return attendee_list(request) if form.cleaned_data["user"] is not None: - attendee_id = form.cleaned_data["user"] + user_id = form.cleaned_data["user"] - attendee = people.Attendee.objects.get(id=attendee_id) + attendee = people.Attendee.objects.get(user__id=user_id) reports = [] @@ -349,7 +349,7 @@ def attendee_list(request): for attendee in attendees: data.append([ - attendee.id, + attendee.user.id, attendee.attendeeprofilebase.attendee_name(), attendee.user.email, attendee.has_registered > 0, From 84c40a1e1f28d630f3aa0c65de8203e8a75ddb17 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 15:08:25 +1000 Subject: [PATCH 280/418] Refactors ItemController, add items_released --- registrasion/controllers/item.py | 42 ++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/registrasion/controllers/item.py b/registrasion/controllers/item.py index a36604e5..f2d0a2ae 100644 --- a/registrasion/controllers/item.py +++ b/registrasion/controllers/item.py @@ -30,7 +30,7 @@ class ItemController(object): def __init__(self, user): self.user = user - def items_purchased(self, category=None): + def _items(self, cart_status, category=None): ''' Aggregates the items that this user has purchased. Arguments: @@ -45,7 +45,7 @@ class ItemController(object): in_cart = ( Q(productitem__cart__user=self.user) & - Q(productitem__cart__status=commerce.Cart.STATUS_PAID) + Q(productitem__cart__status=cart_status) ) quantities_in_cart = When( @@ -72,6 +72,20 @@ class ItemController(object): out.append(ProductAndQuantity(prod, prod.quantity)) return out + 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) + def items_pending(self): ''' Gets all of the items that the user has reserved, but has not yet paid for. @@ -82,14 +96,16 @@ class ItemController(object): ''' - all_items = commerce.ProductItem.objects.filter( - cart__user=self.user, - cart__status=commerce.Cart.STATUS_ACTIVE, - ).select_related( - "product", - "product__category", - ).order_by( - "product__category__order", - "product__order", - ) - return all_items + 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) From c2065dd4b90a20e430c98ad6221a6d4021e3c714 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 15:08:44 +1000 Subject: [PATCH 281/418] =?UTF-8?q?The=20form=20can=20now=20amend=20a=20us?= =?UTF-8?q?er=E2=80=99s=20registration.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/views.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index f4368be8..58e2891c 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -10,6 +10,7 @@ from registrasion.controllers.cart import CartController from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.discount import DiscountController from registrasion.controllers.invoice import InvoiceController +from registrasion.controllers.item import ItemController from registrasion.controllers.product import ProductController from registrasion.exceptions import CartValidationError @@ -804,17 +805,40 @@ def amend_registration(request, user_id): items = commerce.ProductItem.objects.filter( cart=current_cart.cart, ).select_related("product") - initial = [{"product": i.product, "quantity": i.quantity} for i in items] - form = forms.StaffProductsFormSet( + formset = forms.StaffProductsFormSet( request.POST or None, initial=initial, prefix="products", ) + if request.POST and formset.is_valid(): + print formset._errors + + pq = [ + (f.cleaned_data["product"], f.cleaned_data["quantity"]) + for f in formset + if "product" in f.cleaned_data and + f.cleaned_data["product"] is not None + ] + + try: + current_cart.set_quantities(pq) + return redirect(amend_registration, user_id) + except ValidationError as ve: + for ve_field in ve.error_list: + product, message = ve_field.message + for form in formset: + if form.cleaned_data["product"] == product: + form.add_error("quantity", message) + + ic = ItemController(user) data = { - "form": form, + "user": user, + "paid": ic.items_purchased(), + "cancelled": ic.items_released(), + "form": formset, } return render(request, "registrasion/amend_registration.html", data) From 1152e185d136ca50a448086907c8814e5e4ad64b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 15:16:46 +1000 Subject: [PATCH 282/418] Staff can now check out an invoice for a user --- registrasion/urls.py | 1 + registrasion/views.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index dab49b00..ac7faace 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -21,6 +21,7 @@ public = [ url(r"^amend/([0-9]+)$", amend_registration, name="amend_registration"), 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"^invoice/([0-9]+)$", invoice, name="invoice"), url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", invoice, name="invoice"), diff --git a/registrasion/views.py b/registrasion/views.py index 58e2891c..f457a629 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -506,7 +506,7 @@ def _handle_voucher(request, prefix): @login_required -def checkout(request): +def checkout(request, user_id=None): ''' Runs the checkout process for the current cart. If the query string contains ``fix_errors=true``, Registrasion will attempt @@ -514,6 +514,10 @@ def checkout(request): cancelling expired discounts and vouchers, and removing any unavailable products. + Arguments: + user_id (castable to int): + If the requesting user is staff, then the user ID can be used to + run checkout for another user. Returns: render or redirect: If the invoice is generated successfully, or there's already a @@ -527,7 +531,15 @@ def checkout(request): ''' - current_cart = CartController.for_user(request.user) + if user_id is not None: + if request.user.is_staff: + user = User.objects.get(id=int(user_id)) + else: + raise Http404() + else: + user = request.user + + current_cart = CartController.for_user(user) if "fix_errors" in request.GET and request.GET["fix_errors"] == "true": current_cart.fix_simple_errors() From b9ee438b891e9414d3391606858807a2da5fbf05 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 15:43:04 +1000 Subject: [PATCH 283/418] Registration amendments are now limited the products that the user is allowed to add. --- registrasion/forms.py | 36 ++++++++++++++++++++++++++---------- registrasion/views.py | 3 ++- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 229f7bc5..90bf1b48 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -1,3 +1,4 @@ +from registrasion.controllers.product import ProductController from registrasion.models import commerce from registrasion.models import inventory @@ -349,16 +350,31 @@ class VoucherForm(forms.Form): ) -class StaffProductsForm(forms.Form): - ''' Form for allowing staff to add an item to a user's cart. ''' +def staff_products_form_factory(user): + ''' Creates a StaffProductsForm that restricts the available products to + those that are available to a user. ''' - product = forms.ModelChoiceField( - widget=forms.Select, - queryset=inventory.Product.objects.all(), - ) + products = inventory.Product.objects.all() + products = ProductController.available_products(user, products=products) - quantity = forms.IntegerField( - min_value=0, - ) + product_ids = [product.id for product in products] + product_set = inventory.Product.objects.filter(id__in=product_ids) -StaffProductsFormSet = forms.formset_factory(StaffProductsForm) + 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) diff --git a/registrasion/views.py b/registrasion/views.py index f457a629..a7cf2391 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -819,7 +819,8 @@ def amend_registration(request, user_id): ).select_related("product") initial = [{"product": i.product, "quantity": i.quantity} for i in items] - formset = forms.StaffProductsFormSet( + StaffProductsFormSet = forms.staff_products_formset_factory(user) + formset = StaffProductsFormSet( request.POST or None, initial=initial, prefix="products", From 5703221fbaf6d2969de179af3264d46137142a0b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 15:53:54 +1000 Subject: [PATCH 284/418] Adds voucher form to registration amendment --- registrasion/views.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index a7cf2391..c457295f 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -826,8 +826,12 @@ def amend_registration(request, user_id): prefix="products", ) + voucher_form = forms.VoucherForm( + request.POST or None, + prefix="voucher", + ) + if request.POST and formset.is_valid(): - print formset._errors pq = [ (f.cleaned_data["product"], f.cleaned_data["quantity"]) @@ -846,12 +850,20 @@ def amend_registration(request, user_id): if form.cleaned_data["product"] == product: form.add_error("quantity", message) + if request.POST and voucher_form.is_valid(): + try: + current_cart.apply_voucher(voucher_form.cleaned_data["voucher"]) + return redirect(amend_registration, user_id) + except ValidationError as ve: + voucher_form.add_error(None, ve) + ic = ItemController(user) data = { "user": user, "paid": ic.items_purchased(), "cancelled": ic.items_released(), "form": formset, + "voucher_form": voucher_form, } return render(request, "registrasion/amend_registration.html", data) From 8e1f79951315b83d11e0928dc39d3aa6fd241e60 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 16:11:28 +1000 Subject: [PATCH 285/418] Test case for issue #68 --- registrasion/tests/test_flag.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index 094ac1a4..4952c8a7 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -6,6 +6,7 @@ from registrasion.models import commerce from registrasion.models import conditions from registrasion.controllers.category import CategoryController from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController from registrasion.controllers.product import ProductController from test_cart import RegistrationCartTestCase @@ -350,3 +351,29 @@ class FlagTestCases(RegistrationCartTestCase): # 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_oops(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) From 43649002cb0333a3ccb46dd19c798ce6c0ec32aa Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 3 Sep 2016 16:18:27 +1000 Subject: [PATCH 286/418] Makes ProductCondition work if you have both valid and cancelled instances of a product. Fixes #68 --- registrasion/controllers/conditions.py | 11 ++++++++++- registrasion/tests/test_flag.py | 7 ++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 51078016..72e592a2 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -172,11 +172,20 @@ class ProductConditionController(IsMetByFilter, ConditionController): 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) + queryset = queryset.exclude( + in_released_carts & not_in_paid_or_active_carts + ) return queryset diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index 4952c8a7..3d8914a1 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -352,7 +352,7 @@ class FlagTestCases(RegistrationCartTestCase): items = commerce.ProductItem.objects.filter(cart=cart.cart) self.assertTrue([i for i in items if i.product == self.PROD_1]) - def test_oops(self): + 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. ''' @@ -377,3 +377,8 @@ class FlagTestCases(RegistrationCartTestCase): # 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) From 1333fcdea1790b3ab70a7d9e4d913b302b7e78b6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 11:30:29 +1000 Subject: [PATCH 287/418] Refactors flags and discount classes to be DRYer. --- registrasion/models/conditions.py | 142 ++++++++++++++++-------------- 1 file changed, 75 insertions(+), 67 deletions(-) diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index 41e1a320..837753e1 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -9,7 +9,75 @@ from django.utils.translation import ugettext_lazy as _ from model_utils.managers import InheritanceManager -# Product Modifiers +# 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."), + ) + + +# Discounts @python_2_unicode_compatible class DiscountBase(models.Model): @@ -154,7 +222,7 @@ class DiscountForCategory(models.Model): quantity = models.PositiveIntegerField() -class TimeOrStockLimitDiscount(DiscountBase): +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. @@ -175,27 +243,8 @@ class TimeOrStockLimitDiscount(DiscountBase): verbose_name = _("discount (time/stock limit)") verbose_name_plural = _("discounts (time/stock limit)") - start_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("Start time"), - help_text=_("This discount will only be available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("End time"), - help_text=_("This discount will only be available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name=_("Limit"), - help_text=_("This discount may only be applied this many times."), - ) - -class VoucherDiscount(DiscountBase): +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. @@ -210,15 +259,8 @@ class VoucherDiscount(DiscountBase): verbose_name = _("discount (enabled by voucher)") verbose_name_plural = _("discounts (enabled by voucher)") - voucher = models.OneToOneField( - inventory.Voucher, - on_delete=models.CASCADE, - verbose_name=_("Voucher"), - db_index=True, - ) - -class IncludedProductDiscount(DiscountBase): +class IncludedProductDiscount(IncludedProductCondition, DiscountBase): ''' Discounts that are enabled because another product has been purchased. e.g. A conference ticket includes a free t-shirt. @@ -233,13 +275,6 @@ class IncludedProductDiscount(DiscountBase): verbose_name = _("discount (product inclusions)") verbose_name_plural = _("discounts (product inclusions)") - enabling_products = models.ManyToManyField( - inventory.Product, - verbose_name=_("Including product"), - help_text=_("If one of these products are purchased, the discounts " - "below will be enabled."), - ) - class RoleDiscount(object): ''' Discounts that are enabled because the active user has a specific @@ -330,7 +365,7 @@ class FlagBase(models.Model): ) -class TimeOrStockLimitFlag(FlagBase): +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. @@ -352,28 +387,9 @@ class TimeOrStockLimitFlag(FlagBase): verbose_name = _("flag (time/stock limit)") verbose_name_plural = _("flags (time/stock limit)") - start_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available after this time."), - ) - end_time = models.DateTimeField( - null=True, - blank=True, - help_text=_("Products included in this condition will only be " - "available before this time."), - ) - limit = models.PositiveIntegerField( - null=True, - blank=True, - help_text=_("The number of items under this grouping that can be " - "purchased."), - ) - @python_2_unicode_compatible -class ProductFlag(FlagBase): +class ProductFlag(IncludedProductCondition, FlagBase): ''' The condition is met because a specific product is purchased. Attributes: @@ -389,12 +405,6 @@ class ProductFlag(FlagBase): def __str__(self): return "Enabled by products: " + str(self.enabling_products.all()) - enabling_products = models.ManyToManyField( - inventory.Product, - help_text=_("If one of these products are purchased, this condition " - "is met."), - ) - @python_2_unicode_compatible class CategoryFlag(FlagBase): @@ -422,7 +432,7 @@ class CategoryFlag(FlagBase): @python_2_unicode_compatible -class VoucherFlag(FlagBase): +class VoucherFlag(VoucherCondition, FlagBase): ''' The condition is met because a Voucher is present. This is for e.g. enabling sponsor tickets. ''' @@ -434,8 +444,6 @@ class VoucherFlag(FlagBase): def __str__(self): return "Enabled by voucher: %s" % self.voucher - voucher = models.OneToOneField(inventory.Voucher) - # @python_2_unicode_compatible class RoleFlag(object): From 63fe8196e2ba4bae277ccaf05729a5e16f3f43d7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 12:36:20 +1000 Subject: [PATCH 288/418] Adds SpeakerCondition, SpeakerDiscount, and SpeakerFlag --- registrasion/models/conditions.py | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index 837753e1..c04b453f 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -8,6 +8,8 @@ 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 @@ -77,6 +79,30 @@ class IncludedProductCondition(models.Model): ) +class SpeakerCondition(models.Model): + ''' Conditions that are met if a user is a presenter, or copresenter, + of a specific 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."), + ) + + # Discounts @python_2_unicode_compatible @@ -276,6 +302,29 @@ class IncludedProductDiscount(IncludedProductCondition, DiscountBase): 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 RoleDiscount(object): ''' Discounts that are enabled because the active user has a specific role. This is for e.g. volunteers who can get a discount ticket. ''' @@ -445,6 +494,29 @@ class VoucherFlag(VoucherCondition, FlagBase): 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)") + + # @python_2_unicode_compatible class RoleFlag(object): ''' The condition is met because the active user has a particular Role. From b3d86e214876b52638b66d3e34532dcd25ac7055 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 12:39:52 +1000 Subject: [PATCH 289/418] Adds stub for SpeakerConditionController --- registrasion/controllers/conditions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 72e592a2..831faa78 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -25,6 +25,8 @@ class ConditionController(object): conditions.CategoryFlag: CategoryConditionController, conditions.IncludedProductDiscount: ProductConditionController, conditions.ProductFlag: ProductConditionController, + conditions.SpeakerFlag: SpeakerConditionController, + conditions.SpeakerDiscount: SpeakerConditionController, conditions.TimeOrStockLimitDiscount: TimeOrStockLimitDiscountController, conditions.TimeOrStockLimitFlag: @@ -299,3 +301,13 @@ class VoucherConditionController(IsMetByFilter, ConditionController): 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 proposal. ''' + + return queryset From 9134fa5ed29621f78c8ced7b11a88ab1b6b6bd53 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 13:11:45 +1000 Subject: [PATCH 290/418] Initial version of test_speaker, which creates all of the boilerplate for proposals --- registrasion/tests/test_speaker.py | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 registrasion/tests/test_speaker.py diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py new file mode 100644 index 00000000..cdad2181 --- /dev/null +++ b/registrasion/tests/test_speaker.py @@ -0,0 +1,106 @@ +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 controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController +from registrasion.controllers.product import ProductController + +from symposion.conference import models as conference_models +from symposion.proposals import models as proposal_models +from symposion.speakers import models as speaker_models + +from 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( + 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", + description="Description", + 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", + description="Description", + 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 + + 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) From 786bc0324a59b6e76b5adba4c6c7cdba1a14b28d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 13:17:56 +1000 Subject: [PATCH 291/418] Stubs out tests for test_speaker --- registrasion/tests/test_speaker.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py index cdad2181..15a614c8 100644 --- a/registrasion/tests/test_speaker.py +++ b/registrasion/tests/test_speaker.py @@ -104,3 +104,12 @@ class SpeakerTestCase(RegistrationCartTestCase): self.assertIsNotNone(self.KIND_2) self.assertIsNotNone(self.PROPOSAL_1) self.assertIsNotNone(self.PROPOSAL_2) + + def test_primary_speaker_enables_item(self): + raise NotImplementedError() + + def test_additional_speaker_enables_item(self): + raise NotImplementedError() + + def test_speaker_on_different_proposal_kind_does_not_enable_item(self): + raise NotImplementedError() From 0b306fd59edc1d20617903fdc4ab6fe417d4a578 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 13:41:49 +1000 Subject: [PATCH 292/418] Adds test for user being a primary presenter of a proposal --- registrasion/controllers/conditions.py | 4 +- registrasion/tests/test_speaker.py | 89 +++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 831faa78..f9007096 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -310,4 +310,6 @@ class SpeakerConditionController(IsMetByFilter, ConditionController): ''' Returns all of the items from queryset which are enabled by a user being a presenter or copresenter of a proposal. ''' - return queryset + # User is a presenter + + return queryset.filter(proposal_kind__section__presentations__speaker__user=user) diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py index 15a614c8..0262561d 100644 --- a/registrasion/tests/test_speaker.py +++ b/registrasion/tests/test_speaker.py @@ -12,6 +12,7 @@ from registrasion.controllers.product import ProductController from symposion.conference import models as conference_models from symposion.proposals import models as proposal_models from symposion.speakers import models as speaker_models +from symposion.reviews.models import promote_proposal from test_cart import RegistrationCartTestCase @@ -97,6 +98,32 @@ class SpeakerTestCase(RegistrationCartTestCase): 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() @@ -106,10 +133,66 @@ class SpeakerTestCase(RegistrationCartTestCase): self.assertIsNotNone(self.PROPOSAL_2) def test_primary_speaker_enables_item(self): - raise NotImplementedError() + 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): - raise NotImplementedError() + 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): - raise NotImplementedError() + self._create_proposals() + self._create_flag_for_primary_speaker() + + # USER_1 cannot see PROD_1 until proposal is promoted. + + # promote proposal_2 so that USER_1 becomes a speaker, but of + # KIND_2, which is not covered by this condition + + # USER_2 cannot see PROD_1 From 04eefa4e0e7642330a72fe527932261d4aa69131 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 13:54:05 +1000 Subject: [PATCH 293/418] Passes first two tests --- registrasion/controllers/conditions.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index f9007096..7ca8e431 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -310,6 +310,17 @@ class SpeakerConditionController(IsMetByFilter, ConditionController): ''' Returns all of the items from queryset which are enabled by a user being a presenter or copresenter of a proposal. ''' - # User is a presenter + u = user - return queryset.filter(proposal_kind__section__presentations__speaker__user=user) + # User is a presenter + user_is_presenter = Q( + is_presenter=True, + proposal_kind__section__presentations__speaker__user=u, + ) + # User is a copresenter + user_is_copresenter = Q( + is_copresenter=True, + proposal_kind__section__presentations__additional_speakers__user=u, + ) + + return queryset.filter(user_is_presenter | user_is_copresenter) From af30063a92c07c50ba8646d6eea8802944630c10 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 14:00:56 +1000 Subject: [PATCH 294/418] Adds final test, all three now pass. --- registrasion/controllers/conditions.py | 5 ++--- registrasion/tests/test_speaker.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 7ca8e431..ac198347 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -311,16 +311,15 @@ class SpeakerConditionController(IsMetByFilter, ConditionController): being a presenter or copresenter of a proposal. ''' u = user - # User is a presenter user_is_presenter = Q( is_presenter=True, - proposal_kind__section__presentations__speaker__user=u, + proposal_kind__proposalbase__presentation__speaker__user=u, ) # User is a copresenter user_is_copresenter = Q( is_copresenter=True, - proposal_kind__section__presentations__additional_speakers__user=u, + proposal_kind__proposalbase__presentation__additional_speakers__user=u, ) return queryset.filter(user_is_presenter | user_is_copresenter) diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py index 0262561d..e3b14ed0 100644 --- a/registrasion/tests/test_speaker.py +++ b/registrasion/tests/test_speaker.py @@ -191,8 +191,19 @@ class SpeakerTestCase(RegistrationCartTestCase): 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_2 cannot see PROD_1 + # 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) From c2a702d699f70b68860e5a4bd433d4c91fd1338d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 14:21:30 +1000 Subject: [PATCH 295/418] Adds admin and migration for speaker tickets. --- registrasion/admin.py | 43 +++++---- .../migrations/0003_auto_20160904_0235.py | 90 +++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 registrasion/migrations/0003_auto_20160904_0235.py diff --git a/registrasion/admin.py b/registrasion/admin.py index 35f73e9f..4d704c9b 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -12,6 +12,7 @@ class EffectsDisplayMixin(object): def effects(self, obj): return list(obj.effects()) + # Inventory admin @@ -69,14 +70,11 @@ class TimeOrStockLimitDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): @admin.register(conditions.IncludedProductDiscount) -class IncludedProductDiscountAdmin(admin.ModelAdmin): +class IncludedProductDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): def enablers(self, obj): return list(obj.enabling_products.all()) - def effects(self, obj): - return list(obj.effects()) - list_display = ("description", "enablers", "effects") inlines = [ @@ -84,6 +82,20 @@ class IncludedProductDiscountAdmin(admin.ModelAdmin): 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, + ] + # Vouchers @@ -172,18 +184,13 @@ class CategoryFlagAdmin( ordering = ("enabling_category",) -# Enabling conditions -@admin.register(conditions.TimeOrStockLimitFlag) -class TimeOrStockLimitFlagAdmin( - nested_admin.NestedAdmin, - EffectsDisplayMixin): - model = conditions.TimeOrStockLimitFlag +@admin.register(conditions.SpeakerFlag) +class SpeakerFlagAdmin(nested_admin.NestedAdmin, EffectsDisplayMixin): - list_display = ( - "description", - "start_time", - "end_time", - "limit", - "effects", - ) - ordering = ("start_time", "end_time", "limit") + 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") diff --git a/registrasion/migrations/0003_auto_20160904_0235.py b/registrasion/migrations/0003_auto_20160904_0235.py new file mode 100644 index 00000000..77b74ab0 --- /dev/null +++ b/registrasion/migrations/0003_auto_20160904_0235.py @@ -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'), + ), + ] From 0601470006eb40c812a1cddc60f0406c59871d28 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 4 Sep 2016 14:31:21 +1000 Subject: [PATCH 296/418] Fixes bug in Radio Buttons products form. Fixes #69. --- registrasion/forms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 90bf1b48..df75cc9f 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -208,7 +208,6 @@ class _RadioButtonProductsForm(_ProductsForm): yield ( choice_value, 1 if ours == choice_value else 0, - self.FIELD, ) def add_product_error(self, product, error): From 136c68aa0a6e9b1fdd111248caa538f49eb9f612 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 10:01:36 +1000 Subject: [PATCH 297/418] Adds GroupMemberCondition, derivatives, and controllers. --- registrasion/controllers/conditions.py | 14 ++++++- registrasion/models/conditions.py | 58 ++++++++++++++++++++------ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index ac198347..31413d27 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -23,6 +23,8 @@ class ConditionController(object): def _controllers(): return { conditions.CategoryFlag: CategoryConditionController, + conditions.GroupMemberDiscount: GroupMemberConditionController, + conditions.GroupMemberFlag: GroupMemberConditionController, conditions.IncludedProductDiscount: ProductConditionController, conditions.ProductFlag: ProductConditionController, conditions.SpeakerFlag: SpeakerConditionController, @@ -319,7 +321,17 @@ class SpeakerConditionController(IsMetByFilter, ConditionController): # User is a copresenter user_is_copresenter = Q( is_copresenter=True, - proposal_kind__proposalbase__presentation__additional_speakers__user=u, + 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, queryset, user): + ''' Returns all of the items from queryset which are enabled by a user + being member of a Django Auth Group. ''' + + return queryset diff --git a/registrasion/models/conditions.py b/registrasion/models/conditions.py index c04b453f..8fac369b 100644 --- a/registrasion/models/conditions.py +++ b/registrasion/models/conditions.py @@ -2,6 +2,7 @@ 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 @@ -81,7 +82,7 @@ class IncludedProductCondition(models.Model): class SpeakerCondition(models.Model): ''' Conditions that are met if a user is a presenter, or copresenter, - of a specific of presentation. ''' + of a specific kind of presentation. ''' class Meta: abstract = True @@ -103,6 +104,20 @@ class SpeakerCondition(models.Model): ) +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 @@ -325,12 +340,23 @@ class SpeakerDiscount(SpeakerCondition, DiscountBase): verbose_name_plural = _("discounts (speaker)") -class RoleDiscount(object): - ''' Discounts that are enabled because the active user has a specific - role. This is for e.g. volunteers who can get a discount ticket. ''' - # TODO: implement RoleDiscount - pass +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): @@ -517,9 +543,17 @@ class SpeakerFlag(SpeakerCondition, FlagBase): verbose_name_plural = _("flags (speaker)") -# @python_2_unicode_compatible -class RoleFlag(object): - ''' The condition is met because the active user has a particular Role. - This is for e.g. enabling Team tickets. ''' - # TODO: implement RoleFlag - pass +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)") From 1128e43150bba9753ebc3d81fa4370beffb2dc74 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 10:18:08 +1000 Subject: [PATCH 298/418] =?UTF-8?q?Adds=20test=20for=20GroupMemberConditio?= =?UTF-8?q?n=20=E2=80=94=20it=20fails,=20obviously.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/tests/test_group_member.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 registrasion/tests/test_group_member.py diff --git a/registrasion/tests/test_group_member.py b/registrasion/tests/test_group_member.py new file mode 100644 index 00000000..84b194aa --- /dev/null +++ b/registrasion/tests/test_group_member.py @@ -0,0 +1,65 @@ +import pytz + +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError + +from registrasion.models import commerce +from registrasion.models import conditions +from registrasion.controllers.category import CategoryController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController +from registrasion.controllers.product import ProductController + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + + +class GroupMemberTestCase(RegistrationCartTestCase): + + @classmethod + def _create_group_and_flag(cls): + ''' Creates cls.GROUP, and restricts cls.PROD_1 only to users who are + members of the group. ''' + + group = Group.objects.create( + name="TEST GROUP", + ) + + flag = conditions.GroupMemberFlag.objects.create( + description="Group member flag", + condition=conditions.FlagBase.ENABLE_IF_TRUE, + ) + flag.group.add(group) + flag.products.add(cls.PROD_1) + + cls.GROUP = group + + 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() + + # USER_1 cannot see PROD_1 until they're in GROUP. + available = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available) + + self.USER_1.groups.add(self.GROUP) + + # USER_1 cannot see PROD_1 until they're in GROUP. + available = ProductController.available_products( + self.USER_1, + products=[self.PROD_1], + ) + self.assertIn(self.PROD_1, available) + + # USER_2 is still locked out + available = ProductController.available_products( + self.USER_2, + products=[self.PROD_1], + ) + self.assertNotIn(self.PROD_1, available) From 0f488e7a127b82a1a847d8e00cc030fa61038510 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 10:42:50 +1000 Subject: [PATCH 299/418] Makes TeamMemberCondition work --- registrasion/controllers/conditions.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 31413d27..e9efb625 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -330,8 +330,10 @@ class SpeakerConditionController(IsMetByFilter, ConditionController): class GroupMemberConditionController(IsMetByFilter, ConditionController): @classmethod - def pre_filter(self, queryset, user): - ''' Returns all of the items from queryset which are enabled by a user - being member of a Django Auth Group. ''' + 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 queryset + return conditions.filter( + group=user.groups.all(), + ) From 1214b23077f9c58aa9e6bfe3397958cd5a2ddd79 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 10:48:38 +1000 Subject: [PATCH 300/418] Adds admin and migration for GroupMember conditions --- registrasion/admin.py | 21 ++++++++++ ...004_groupmemberdiscount_groupmemberflag.py | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 registrasion/migrations/0004_groupmemberdiscount_groupmemberflag.py diff --git a/registrasion/admin.py b/registrasion/admin.py index 4d704c9b..95e46b70 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -97,6 +97,19 @@ class SpeakerDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): ] +@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): @@ -194,3 +207,11 @@ class SpeakerFlagAdmin(nested_admin.NestedAdmin, EffectsDisplayMixin): 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") + + list_display = ("description", "effects") diff --git a/registrasion/migrations/0004_groupmemberdiscount_groupmemberflag.py b/registrasion/migrations/0004_groupmemberdiscount_groupmemberflag.py new file mode 100644 index 00000000..0b980e57 --- /dev/null +++ b/registrasion/migrations/0004_groupmemberdiscount_groupmemberflag.py @@ -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), + ), + ] From 17dd91d56bf4240f6b7fa0a8bbf8bb1395edf4fa Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 14:45:51 +1000 Subject: [PATCH 301/418] Fixes bug in the t-shirt-style widget. --- registrasion/forms.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index df75cc9f..cc555b78 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -340,6 +340,14 @@ class _ItemQuantityProductsFormSet(_HasProductsFields, forms.BaseFormSet): 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): voucher = forms.CharField( From 878da1f2d8291550a2cc2d96ecd7ccdd568a28f8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 19:45:44 +1000 Subject: [PATCH 302/418] Use textfield for some things rather than char field --- .../migrations/0005_auto_20160905_0945.py | 35 +++++++++++++++++++ registrasion/models/inventory.py | 10 +++--- 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 registrasion/migrations/0005_auto_20160905_0945.py diff --git a/registrasion/migrations/0005_auto_20160905_0945.py b/registrasion/migrations/0005_auto_20160905_0945.py new file mode 100644 index 00000000..e5909394 --- /dev/null +++ b/registrasion/migrations/0005_auto_20160905_0945.py @@ -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'), + ), + ] diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py index bb1b0e19..575566e2 100644 --- a/registrasion/models/inventory.py +++ b/registrasion/models/inventory.py @@ -71,11 +71,10 @@ class Category(models.Model): ] name = models.CharField( - max_length=65, + max_length=255, verbose_name=_("Name"), ) - description = models.CharField( - max_length=255, + description = models.TextField( verbose_name=_("Description"), ) limit_per_user = models.PositiveIntegerField( @@ -143,11 +142,10 @@ class Product(models.Model): return "%s - %s" % (self.category.name, self.name) name = models.CharField( - max_length=65, + max_length=255, verbose_name=_("Name"), ) - description = models.CharField( - max_length=255, + description = models.TextField( verbose_name=_("Description"), null=True, blank=True, From ea599bbaad6559241531b726f6ab774a5a4d19cd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 5 Sep 2016 21:10:21 +1000 Subject: [PATCH 303/418] Addresses #72, hopefully. --- registrasion/reporting/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 6a507135..489a8203 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -139,8 +139,8 @@ def product_status(request, form): items = items.annotate( is_reserved=Case( - When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), - default=Value(0), + When(cart__in=commerce.Cart.reserved_carts(), then=Value(True)), + default=Value(False), output_field=models.BooleanField(), ), ) From f5e303584bda0d33c11ae87b6b38fd976c1a7195 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 12:27:07 +1000 Subject: [PATCH 304/418] Adds an output_field type to reconciliation sums. Fixes #75 --- registrasion/reporting/views.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 489a8203..962b7071 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -18,6 +18,10 @@ from reports import Report from reports import report_view +def CURRENCY(): + return models.DecimalField(decimal_places=2) + + @user_passes_test(views._staff_only) def reports_list(request): ''' Lists all of the reports currently available. ''' @@ -101,7 +105,9 @@ def reconciliation(request, form): invoice__status=commerce.Invoice.STATUS_PAID, ).values( "price", "quantity" - ).aggregate(total=Sum(F("price") * F("quantity"))) + ).aggregate( + total=Sum(F("price") * F("quantity"), output_field=CURRENCY()), + ) data.append(["Paid items", sales["total"]]) From a27264ac92898c1d09a14536e5f76b1060b90c2c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 12:28:48 +1000 Subject: [PATCH 305/418] Filters items_purchased by category. Fixes #74 --- registrasion/controllers/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/controllers/item.py b/registrasion/controllers/item.py index f2d0a2ae..9a1a9eb2 100644 --- a/registrasion/controllers/item.py +++ b/registrasion/controllers/item.py @@ -84,7 +84,7 @@ class ItemController(object): aggregating like products from across multiple invoices. ''' - return self._items(commerce.Cart.STATUS_PAID) + 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 From 9dd31128bbbac9d4cc1f447053abb3655de14089 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 13:19:09 +1000 Subject: [PATCH 306/418] =?UTF-8?q?Restricts=20=E2=80=98amend=E2=80=99=20p?= =?UTF-8?q?roduct=20widgets=20to=20the=20original=20product.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #76. --- registrasion/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/registrasion/views.py b/registrasion/views.py index c457295f..027dc819 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -826,6 +826,10 @@ def amend_registration(request, user_id): prefix="products", ) + for item, form in zip(items, formset): + queryset = inventory.Product.objects.filter(id=item.product.id) + form.fields["product"].queryset = queryset + voucher_form = forms.VoucherForm( request.POST or None, prefix="voucher", From 3903d2be56d6160f55c455b73792c44a2d871806 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 13:19:31 +1000 Subject: [PATCH 307/418] Fixes issues on /amend --- registrasion/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index 027dc819..704b05c2 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -851,10 +851,13 @@ def amend_registration(request, user_id): for ve_field in ve.error_list: product, message = ve_field.message for form in formset: + if "product" not in form.cleaned_data: + # This is the empty form. + continue if form.cleaned_data["product"] == product: form.add_error("quantity", message) - if request.POST and voucher_form.is_valid(): + if request.POST and voucher_form.has_changed() and voucher_form.is_valid(): try: current_cart.apply_voucher(voucher_form.cleaned_data["voucher"]) return redirect(amend_registration, user_id) From 1c239c361f00246a21f70e4a5a61600634e1ccca Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 13:19:53 +1000 Subject: [PATCH 308/418] Propagates the per_user_limit category error to the products, rather than the category. Fixes #79. --- registrasion/controllers/cart.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 9f27ab49..3e417f1a 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -205,12 +205,11 @@ class CartController(object): to_add = sum(i[1] for i in by_cat[category]) if to_add > limit: - errors.append(( - category, - "You may only have %d items in category: %s" % ( - limit, category.name, - ) - )) + message = "You may only have %d items in category: %s" % ( + limit, category.name, + ) + for product, quantity in by_cat[category]: + errors.append((product, message)) # Test the flag conditions errs = FlagController.test_flags( From 2658c2ccde510fc8ea82e3b905b64f931bf23029 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 13:33:20 +1000 Subject: [PATCH 309/418] Improves the error message when per_user_limit on category is breached. Fixes #80 --- registrasion/controllers/cart.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 3e417f1a..ad4458ea 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -205,7 +205,8 @@ class CartController(object): to_add = sum(i[1] for i in by_cat[category]) if to_add > limit: - message = "You may only have %d items in category: %s" % ( + message_base = "You may only add %d items from category: %s" + message = message_base % ( limit, category.name, ) for product, quantity in by_cat[category]: From f3e419d66d07b3ca6e718248a912944362d833c0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 15:32:55 +1000 Subject: [PATCH 310/418] Refactors reports so that rendering of links is done within Python code, not templates. --- registrasion/reporting/reports.py | 87 +++++++++++++++++++++++++++---- registrasion/reporting/views.py | 22 ++++---- 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index e756622e..2dc35d1d 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -1,5 +1,6 @@ from django.contrib.auth.decorators import user_passes_test from django.shortcuts import render +from django.core.urlresolvers import reverse from functools import wraps from registrasion import views @@ -12,35 +13,94 @@ _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) + + @staticmethod + def _html_link(address, text): + return '%s' % (address, text) + + +class ReportTemplateWrapper(object): + + 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 OldReport(Report): + def __init__(self, title, headings, data, link_view=None): + super(OldReport, self).__init__() self._title = title self._headings = headings self._data = data self._link_view = link_view - @property def title(self): ''' Returns the title for this report. ''' return self._title - @property def headings(self): ''' Returns the headings for the table. ''' return self._headings - @property - def data(self): + def rows(self, content_type): ''' Returns the data rows for the table. ''' - return self._data - @property - def link_view(self): - ''' Returns the URL name or the view callable that can be used to - view the row's detail. The left-most value is passed into `reverse` - as an argument. ''' + def cell_text(index, text): + if index > 0 or not self._link_view: + return text + else: + address = reverse(self._link_view, args=[text]) + return self._linked_text(content_type, address, text) - return self._link_view + for row in self._data: + yield [cell_text(i, cell) for i, cell in enumerate(row)] + def _get_link(self, argument): + return reverse(self._link_view, argument) def report_view(title, form_type=None): ''' Decorator that converts a report view function into something that @@ -72,6 +132,11 @@ def report_view(title, form_type=None): if isinstance(reports, Report): reports = [reports] + reports = [ + ReportTemplateWrapper("text/html", report) + for report in reports + ] + ctx = { "title": title, "form": form, diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 962b7071..6a5522a5 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -14,7 +14,7 @@ from registrasion.models import people from registrasion import views from reports import get_all_reports -from reports import Report +from reports import OldReport from reports import report_view @@ -90,7 +90,7 @@ def items_sold(request, form): "(TOTAL)", "--", "--", total_income, ]) - return Report("Paid items", headings, data) + return OldReport("Paid items", headings, data) @report_view("Reconcilitation") @@ -128,7 +128,7 @@ def reconciliation(request, form): sales["total"] - payments["total"] - ucn["total"], ]) - return Report("Sales and Payments", headings, data) + return OldReport("Sales and Payments", headings, data) @report_view("Product status", form_type=forms.ProductAndCategoryForm) @@ -211,7 +211,7 @@ def product_status(request, form): item["total_refunded"], ]) - return Report("Inventory", headings, data) + return OldReport("Inventory", headings, data) @report_view("Credit notes") @@ -238,7 +238,7 @@ def credit_notes(request, form): note.value, ]) - return Report("Credit Notes", headings, data, link_view="credit_note") + return OldReport("Credit Notes", headings, data, link_view="credit_note") @report_view("Attendee", form_type=forms.UserIdForm) @@ -269,7 +269,7 @@ def attendee(request, form, user_id=None): pq.quantity, ]) - reports.append(Report("Paid Products", headings, data)) + reports.append(OldReport("Paid Products", headings, data)) # Unpaid products headings = ["Product", "Quantity"] @@ -281,7 +281,7 @@ def attendee(request, form, user_id=None): pq.quantity, ]) - reports.append( Report("Unpaid Products", headings, data)) + reports.append( OldReport("Unpaid Products", headings, data)) # Invoices headings = ["Invoice ID", "Status", "Value"] @@ -295,7 +295,7 @@ def attendee(request, form, user_id=None): invoice.id, invoice.get_status_display(), invoice.value, ]) - reports.append(Report("Invoices", headings, data, link_view="invoice")) + reports.append(OldReport("Invoices", headings, data, link_view="invoice")) # Credit Notes headings = ["Note ID", "Status", "Value"] @@ -310,7 +310,7 @@ def attendee(request, form, user_id=None): ]) reports.append( - Report("Credit Notes", headings, data, link_view="credit_note") + OldReport("Credit Notes", headings, data, link_view="credit_note") ) # All payments @@ -326,7 +326,7 @@ def attendee(request, form, user_id=None): ]) reports.append( - Report("Payments", headings, data, link_view="invoice") + OldReport("Payments", headings, data, link_view="invoice") ) @@ -364,4 +364,4 @@ def attendee_list(request): # Sort by whether they've registered, then ID. data.sort(key=lambda attendee: (-attendee[3], attendee[0])) - return Report("Attendees", headings, data, link_view="attendee") + return OldReport("Attendees", headings, data, link_view="attendee") From e8cfd024d3acfc5a3dcfa38cb49103a7212e0cc5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 15:39:25 +1000 Subject: [PATCH 311/418] Makes the reports use actual objects rather than strings --- registrasion/reporting/views.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 6a5522a5..574df7d1 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -238,7 +238,12 @@ def credit_notes(request, form): note.value, ]) - return OldReport("Credit Notes", headings, data, link_view="credit_note") + return OldReport( + "Credit Notes", + headings, + data, + link_view=views.credit_note, + ) @report_view("Attendee", form_type=forms.UserIdForm) @@ -295,7 +300,9 @@ def attendee(request, form, user_id=None): invoice.id, invoice.get_status_display(), invoice.value, ]) - reports.append(OldReport("Invoices", headings, data, link_view="invoice")) + reports.append( + OldReport("Invoices", headings, data, link_view=views.invoice) + ) # Credit Notes headings = ["Note ID", "Status", "Value"] @@ -310,7 +317,7 @@ def attendee(request, form, user_id=None): ]) reports.append( - OldReport("Credit Notes", headings, data, link_view="credit_note") + OldReport("Credit Notes", headings, data, link_view=views.credit_note) ) # All payments @@ -326,7 +333,7 @@ def attendee(request, form, user_id=None): ]) reports.append( - OldReport("Payments", headings, data, link_view="invoice") + OldReport("Payments", headings, data, link_view=views.invoice) ) @@ -353,15 +360,15 @@ def attendee_list(request): data = [] - for attendee in attendees: + for a in attendees: data.append([ - attendee.user.id, - attendee.attendeeprofilebase.attendee_name(), - attendee.user.email, - attendee.has_registered > 0, + a.user.id, + a.attendeeprofilebase.attendee_name(), + a.user.email, + a.has_registered > 0, ]) # Sort by whether they've registered, then ID. - data.sort(key=lambda attendee: (-attendee[3], attendee[0])) + data.sort(key=lambda a: (-a[3], a[0])) - return OldReport("Attendees", headings, data, link_view="attendee") + return OldReport("Attendees", headings, data, link_view=attendee) From 53e6278116da6d32edfc70e2dc33ac2a4d195e68 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 16:02:18 +1000 Subject: [PATCH 312/418] Adds a Links report type, which can be used to generate a list of links to display with a report. --- registrasion/reporting/reports.py | 40 +++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 2dc35d1d..ae079104 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -47,13 +47,17 @@ class Report(object): if content_type == "text/html": return Report._html_link(address, text) + else: + return text @staticmethod def _html_link(address, text): return '%s' % (address, text) -class ReportTemplateWrapper(object): +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 @@ -93,14 +97,40 @@ class OldReport(Report): if index > 0 or not self._link_view: return text else: - address = reverse(self._link_view, args=[text]) + address = self.get_link(text) return self._linked_text(content_type, address, text) for row in self._data: yield [cell_text(i, cell) for i, cell in enumerate(row)] - def _get_link(self, argument): - return reverse(self._link_view, argument) + def get_link(self, argument): + return reverse(self._link_view, args=[argument]) + + +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 @@ -133,7 +163,7 @@ def report_view(title, form_type=None): reports = [reports] reports = [ - ReportTemplateWrapper("text/html", report) + _ReportTemplateWrapper("text/html", report) for report in reports ] From fa717dee65c4d1a09665f8ff39eae56623e4e34a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 16:19:18 +1000 Subject: [PATCH 313/418] Adds QuerysetReport, which allows directly adding a queryset to a report rather than having to preprocess it into a list. --- registrasion/reporting/reports.py | 62 +++++++++++++++++++++++-------- registrasion/reporting/views.py | 49 +++++++++++++++--------- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index ae079104..1061a903 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -73,13 +73,12 @@ class _ReportTemplateWrapper(object): return self.report.rows(self.content_type) -class OldReport(Report): +class BasicReport(Report): - def __init__(self, title, headings, data, link_view=None): - super(OldReport, self).__init__() + def __init__(self, title, headings, link_view=None): + super(BasicReport, self).__init__() self._title = title self._headings = headings - self._data = data self._link_view = link_view def title(self): @@ -90,23 +89,54 @@ class OldReport(Report): ''' Returns the headings for the table. ''' return self._headings - def rows(self, content_type): - ''' Returns the data rows for the table. ''' - - def cell_text(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) - - for row in self._data: - yield [cell_text(i, cell) for i, cell in enumerate(row)] + 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, headings, attributes, queryset, link_view=None): + super(QuerysetReport, self).__init__(title, headings, link_view=link_view) + self._attributes = attributes + self._queryset = queryset + + def rows(self, content_type): + + def rgetattr(item, attr): + for i in attr.split("__"): + item = getattr(item, i) + 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): diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 574df7d1..0ea7a1f6 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -14,7 +14,9 @@ from registrasion.models import people from registrasion import views from reports import get_all_reports -from reports import OldReport +from reports import Links +from reports import ListReport +from reports import QuerysetReport from reports import report_view @@ -90,7 +92,7 @@ def items_sold(request, form): "(TOTAL)", "--", "--", total_income, ]) - return OldReport("Paid items", headings, data) + return ListReport("Paid items", headings, data) @report_view("Reconcilitation") @@ -128,7 +130,7 @@ def reconciliation(request, form): sales["total"] - payments["total"] - ucn["total"], ]) - return OldReport("Sales and Payments", headings, data) + return ListReport("Sales and Payments", headings, data) @report_view("Product status", form_type=forms.ProductAndCategoryForm) @@ -211,7 +213,7 @@ def product_status(request, form): item["total_refunded"], ]) - return OldReport("Inventory", headings, data) + return ListReport("Inventory", headings, data) @report_view("Credit notes") @@ -238,7 +240,7 @@ def credit_notes(request, form): note.value, ]) - return OldReport( + return ListReport( "Credit Notes", headings, data, @@ -258,10 +260,16 @@ def attendee(request, form, user_id=None): user_id = form.cleaned_data["user"] attendee = people.Attendee.objects.get(user__id=user_id) + name = attendee.attendeeprofilebase.attendee_name() reports = [] - # TODO: METADATA. + links = [] + links.append(( + reverse(views.amend_registration, args=[user_id]), + "Amend current cart", + )) + reports.append(Links("Actions for " + name, links)) ic = ItemController(attendee.user) # Paid products @@ -274,7 +282,7 @@ def attendee(request, form, user_id=None): pq.quantity, ]) - reports.append(OldReport("Paid Products", headings, data)) + reports.append(ListReport("Paid Products", headings, data)) # Unpaid products headings = ["Product", "Quantity"] @@ -286,7 +294,7 @@ def attendee(request, form, user_id=None): pq.quantity, ]) - reports.append( OldReport("Unpaid Products", headings, data)) + reports.append(ListReport("Unpaid Products", headings, data)) # Invoices headings = ["Invoice ID", "Status", "Value"] @@ -301,7 +309,7 @@ def attendee(request, form, user_id=None): ]) reports.append( - OldReport("Invoices", headings, data, link_view=views.invoice) + ListReport("Invoices", headings, data, link_view=views.invoice) ) # Credit Notes @@ -317,7 +325,7 @@ def attendee(request, form, user_id=None): ]) reports.append( - OldReport("Credit Notes", headings, data, link_view=views.credit_note) + ListReport("Credit Notes", headings, data, link_view=views.credit_note) ) # All payments @@ -327,14 +335,14 @@ def attendee(request, form, user_id=None): payments = commerce.PaymentBase.objects.filter( invoice__user=attendee.user, ) - for payment in payments: - data.append([ - payment.invoice.id, payment.id, payment.reference, payment.amount, - ]) - reports.append( - OldReport("Payments", headings, data, link_view=views.invoice) - ) + reports.append(QuerysetReport( + "Payments", + headings, + ["invoice__id", "id", "reference", "amount"], + payments, + link_view=views.invoice, + )) return reports @@ -371,4 +379,9 @@ def attendee_list(request): # Sort by whether they've registered, then ID. data.sort(key=lambda a: (-a[3], a[0])) - return OldReport("Attendees", headings, data, link_view=attendee) + class Report(ListReport): + + def get_link(self, argument): + return reverse(self._link_view) + "?user=%d" % int(argument) + + return Report("Attendees", headings, data, link_view=attendee) From f7326eedf7c1a0e1eae6df71a71573e7069bfa60 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 16:24:50 +1000 Subject: [PATCH 314/418] Makes as many reports under attendee() as possible a QuerysetReport --- registrasion/reporting/views.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 0ea7a1f6..8e648505 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -303,6 +303,7 @@ def attendee(request, form, user_id=None): invoices = commerce.Invoice.objects.filter( user=attendee.user, ) + # TODO make this a querysetreport for invoice in invoices: data.append([ invoice.id, invoice.get_status_display(), invoice.value, @@ -313,32 +314,24 @@ def attendee(request, form, user_id=None): ) # Credit Notes - headings = ["Note ID", "Status", "Value"] - data = [] - credit_notes = commerce.CreditNote.objects.filter( invoice__user=attendee.user, ) - for credit_note in credit_notes: - data.append([ - credit_note.id, credit_note.status, credit_note.value, - ]) - - reports.append( - ListReport("Credit Notes", headings, data, link_view=views.credit_note) - ) + reports.append(QuerysetReport( + "Credit Notes", + ["Note ID", "Status", "Value"], + ["id", "status", "value"], + credit_notes, + link_view=views.credit_note, + )) # All payments - headings = ["To Invoice", "Payment ID", "Reference", "Amount"] - data = [] - payments = commerce.PaymentBase.objects.filter( invoice__user=attendee.user, ) - reports.append(QuerysetReport( "Payments", - headings, + ["To Invoice", "Payment ID", "Reference", "Amount"], ["invoice__id", "id", "reference", "amount"], payments, link_view=views.invoice, From 4c9f426a472cd55e10c58ea5879c9f80eb65a921 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 16:26:40 +1000 Subject: [PATCH 315/418] Simplifies a bunch of older reports. --- registrasion/reporting/views.py | 36 +++++++++++---------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 8e648505..934705ce 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -271,39 +271,27 @@ def attendee(request, form, user_id=None): )) reports.append(Links("Actions for " + name, links)) + # Paid and pending products ic = ItemController(attendee.user) - # Paid products - headings = ["Product", "Quantity"] - data = [] - - for pq in ic.items_purchased(): - data.append([ - pq.product, - pq.quantity, - ]) - - reports.append(ListReport("Paid Products", headings, data)) - - # Unpaid products - headings = ["Product", "Quantity"] - data = [] - - for pq in ic.items_pending(): - data.append([ - pq.product, - pq.quantity, - ]) - - reports.append(ListReport("Unpaid Products", headings, data)) + 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 + # TODO make this a querysetreport headings = ["Invoice ID", "Status", "Value"] data = [] invoices = commerce.Invoice.objects.filter( user=attendee.user, ) - # TODO make this a querysetreport for invoice in invoices: data.append([ invoice.id, invoice.get_status_display(), invoice.value, From bbce369a38f25facf19e35fca826bb8b629b31de Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 18:44:13 +1000 Subject: [PATCH 316/418] Allows for callable attributes to be specified in QuerysetReports. --- registrasion/reporting/reports.py | 7 +++++++ registrasion/reporting/views.py | 20 +++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 1061a903..d533ecf1 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -128,6 +128,13 @@ class QuerysetReport(BasicReport): 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: diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 934705ce..0149c987 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -285,21 +285,16 @@ def attendee(request, form, user_id=None): )) # Invoices - # TODO make this a querysetreport - headings = ["Invoice ID", "Status", "Value"] - data = [] - invoices = commerce.Invoice.objects.filter( user=attendee.user, ) - for invoice in invoices: - data.append([ - invoice.id, invoice.get_status_display(), invoice.value, - ]) - - reports.append( - ListReport("Invoices", headings, data, link_view=views.invoice) - ) + reports.append(QuerysetReport( + "Invoices", + ["Invoice ID", "Status", "Value"], + ["id", "get_status_display", "value"], + invoices, + link_view=views.invoice, + )) # Credit Notes credit_notes = commerce.CreditNote.objects.filter( @@ -325,7 +320,6 @@ def attendee(request, form, user_id=None): link_view=views.invoice, )) - return reports From 12b665acb8c7bc4b560f5d371d6f4d9877e68b8c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 18:47:51 +1000 Subject: [PATCH 317/418] =?UTF-8?q?DRYs=20QuerysetReport=E2=80=99s=20heade?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/reporting/reports.py | 11 ++++++++++- registrasion/reporting/views.py | 4 +--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index d533ecf1..9ba63f06 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -118,11 +118,20 @@ class ListReport(BasicReport): class QuerysetReport(BasicReport): - def __init__(self, title, headings, attributes, queryset, link_view=None): + 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): diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 0149c987..bb89ae1a 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -290,9 +290,9 @@ def attendee(request, form, user_id=None): ) reports.append(QuerysetReport( "Invoices", - ["Invoice ID", "Status", "Value"], ["id", "get_status_display", "value"], invoices, + headings=["Invoice ID", "Status", "Value"], link_view=views.invoice, )) @@ -302,7 +302,6 @@ def attendee(request, form, user_id=None): ) reports.append(QuerysetReport( "Credit Notes", - ["Note ID", "Status", "Value"], ["id", "status", "value"], credit_notes, link_view=views.credit_note, @@ -314,7 +313,6 @@ def attendee(request, form, user_id=None): ) reports.append(QuerysetReport( "Payments", - ["To Invoice", "Payment ID", "Reference", "Amount"], ["invoice__id", "id", "reference", "amount"], payments, link_view=views.invoice, From cb50f2a3bea93a873d844945918debf30b1f6f5f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 18:53:01 +1000 Subject: [PATCH 318/418] Replaces a bunch of reports with QuerysetReports --- registrasion/reporting/views.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index bb89ae1a..88f4eada 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -227,23 +227,11 @@ def credit_notes(request, form): "invoice__user__attendee__attendeeprofilebase", ) - headings = [ - "id", "Owner", "Status", "Value", - ] - - data = [] - for note in notes: - data.append([ - note.id, - note.invoice.user.attendee.attendeeprofilebase.invoice_recipient(), - note.status, - note.value, - ]) - - return ListReport( + return QuerysetReport( "Credit Notes", - headings, - data, + ["id", "invoice__user__attendee__attendeeprofilebase__invoice_recipient", "status", "value"], # NOQA + notes, + headings=["id", "Owner", "Status", "Value"], link_view=views.credit_note, ) From f0730b4de9fa32fc43d2cec9f2fd1548036d77cf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 13 Sep 2016 18:54:28 +1000 Subject: [PATCH 319/418] Flake8 fixes for reports --- registrasion/reporting/forms.py | 1 + registrasion/reporting/reports.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index f4902660..94e99491 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -4,6 +4,7 @@ from django import forms # Staff-facing forms. + class ProductAndCategoryForm(forms.Form): product = forms.ModelMultipleChoiceField( queryset=inventory.Product.objects.all(), diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 9ba63f06..ab8e924d 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -120,7 +120,9 @@ class QuerysetReport(BasicReport): def __init__(self, title, attributes, queryset, headings=None, link_view=None): - super(QuerysetReport, self).__init__(title, headings, link_view=link_view) + super(QuerysetReport, self).__init__( + title, headings, link_view=link_view + ) self._attributes = attributes self._queryset = queryset @@ -178,6 +180,7 @@ class Links(Report): 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. From faa25c9b3a7db39e148fa0c0dddb61893d46903d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 14 Sep 2016 13:28:15 +1000 Subject: [PATCH 320/418] Adds missing_categories tag --- registrasion/controllers/item.py | 22 ++++++++++++++++--- .../templatetags/registrasion_tags.py | 15 +++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/item.py b/registrasion/controllers/item.py index 9a1a9eb2..48456f76 100644 --- a/registrasion/controllers/item.py +++ b/registrasion/controllers/item.py @@ -1,8 +1,11 @@ ''' NEEDS TESTS ''' +import operator + 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 @@ -34,6 +37,7 @@ class ItemController(object): ''' 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. @@ -43,11 +47,18 @@ class ItemController(object): ''' - in_cart = ( - Q(productitem__cart__user=self.user) & - Q(productitem__cart__status=cart_status) + 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) + + print in_cart + quantities_in_cart = When( in_cart, then="productitem__quantity", @@ -72,6 +83,11 @@ class ItemController(object): 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. diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 2d6f51a4..89db7e25 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -20,6 +20,21 @@ def available_categories(context): return CategoryController.available_categories(context.request.user) +@register.assignment_tag(takes_context=True) +def missing_categories(context): + ''' Adds the categories that the user does not currently have. ''' + user = context.request.user + 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. From 640db7e3dced4514f49e73bfc5978ec71e7581d5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 14 Sep 2016 14:59:01 +1000 Subject: [PATCH 321/418] Replaces the final stage of guided registration with a review page, which shows after adding anything to your cart. Fixes #87 --- registrasion/urls.py | 14 ++++++++------ registrasion/views.py | 28 ++++++++++++++++++---------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index ac7faace..4d746388 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -4,16 +4,17 @@ from django.conf.urls import include from django.conf.urls import url from .views import ( - product_category, + amend_registration, checkout, credit_note, - invoice, - manual_payment, - refund, - invoice_access, edit_profile, guided_registration, - amend_registration, + invoice, + invoice_access, + manual_payment, + product_category, + refund, + review, ) @@ -33,6 +34,7 @@ public = [ name="invoice_access"), 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"), ] diff --git a/registrasion/views.py b/registrasion/views.py index 704b05c2..f272593a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -98,11 +98,7 @@ def guided_registration(request): attendee = people.Attendee.get_instance(request.user) if attendee.completed_registration: - return render( - request, - "registrasion/guided_registration_complete.html", - {}, - ) + return redirect(review) # Step 1: Fill in a badge and collect a voucher code try: @@ -234,6 +230,17 @@ def guided_registration(request): return render(request, "registrasion/guided_registration.html", data) +@login_required +def review(request): + ''' View for the review page. ''' + + return render( + request, + "registrasion/guided_registration_complete.html", + {}, + ) + + @login_required def edit_profile(request): ''' View for editing an attendee's profile @@ -370,11 +377,12 @@ def product_category(request, category_id): if request.POST and not voucher_handled and not products_form.errors: # Only return to the dashboard if we didn't add a voucher code # and if there's no errors in the products form - messages.success( - request, - "Your reservations have been updated.", - ) - return redirect("dashboard") + if products_form.has_changed(): + messages.success( + request, + "Your reservations have been updated.", + ) + return redirect(review) data = { "category": category, From b5cbc3e39e273b54ee479b072a27743d79748d53 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 14 Sep 2016 15:00:53 +1000 Subject: [PATCH 322/418] Renames guided_registration_complete to review --- registrasion/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index f272593a..f96104f1 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -236,7 +236,7 @@ def review(request): return render( request, - "registrasion/guided_registration_complete.html", + "registrasion/review.html", {}, ) From 3f53d6f4ffb601beb19947841cd803322d9bfe08 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 14 Sep 2016 15:09:02 +1000 Subject: [PATCH 323/418] Removes spurious print statement. --- registrasion/controllers/item.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/registrasion/controllers/item.py b/registrasion/controllers/item.py index 48456f76..bff4aa3b 100644 --- a/registrasion/controllers/item.py +++ b/registrasion/controllers/item.py @@ -57,8 +57,6 @@ class ItemController(object): in_cart = Q(productitem__cart__user=self.user) in_cart = in_cart & reduce(operator.__or__, status_query) - print in_cart - quantities_in_cart = When( in_cart, then="productitem__quantity", From 613667aa30e93894428f6824f5d75f7209129ced Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 14 Sep 2016 19:44:36 +1000 Subject: [PATCH 324/418] Re-arranges invoice generation code. - Reduces number of db queries - Localises the code that interrogates the cart and the code that generates the invoice itself. --- registrasion/controllers/invoice.py | 61 +++++++++++++++-------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 2c69faed..b88abb09 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -79,26 +79,7 @@ class InvoiceController(ForId, object): cart.refresh_from_db() - issued = timezone.now() - reservation_limit = cart.reservation_duration + cart.time_last_updated - # Never generate a due time that is before the issue time - due = max(issued, reservation_limit) - - # Get the invoice recipient - profile = people.AttendeeProfileBase.objects.get_subclass( - id=cart.user.attendee.attendeeprofilebase.id, - ) - recipient = profile.invoice_recipient() - invoice = commerce.Invoice.objects.create( - user=cart.user, - cart=cart, - cart_revision=cart.revision, - status=commerce.Invoice.STATUS_UNPAID, - value=Decimal(), - issue_time=issued, - due_time=due, - recipient=recipient, - ) + # Generate the line items from the cart. product_items = commerce.ProductItem.objects.filter(cart=cart) product_items = product_items.select_related( @@ -129,35 +110,57 @@ class InvoiceController(ForId, object): description = discount.description return "%s (%s)" % (description, format_product(product)) - invoice_value = Decimal() for item in product_items: product = item.product line_item = commerce.LineItem( - invoice=invoice, description=format_product(product), quantity=item.quantity, price=product.price, product=product, ) line_items.append(line_item) - invoice_value += line_item.quantity * line_item.price for item in discount_items: line_item = commerce.LineItem( - invoice=invoice, 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) - invoice_value += line_item.quantity * line_item.price + + # Generate the invoice + + user = cart.user + reservation_limit = cart.reservation_duration + cart.time_last_updated + # Never generate a due time that is before the issue time + issued = timezone.now() + due = max(issued, reservation_limit) + + # 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, + 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) - invoice.value = invoice_value - - invoice.save() - cls.email_on_invoice_creation(invoice) return invoice From a9bc6475707c6f6aaea7f512b9140cc67abc54d2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 08:38:33 +1000 Subject: [PATCH 325/418] Replaces _generate with _generate_from_cart and _generate --- registrasion/controllers/invoice.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index b88abb09..118b49cb 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -44,7 +44,7 @@ class InvoiceController(ForId, object): cart_controller.validate_cart() # Raises ValidationError on fail. cls.update_old_invoices(cart) - invoice = cls._generate(cart) + invoice = cls._generate_from_cart(cart) return cls(invoice) @@ -74,7 +74,7 @@ class InvoiceController(ForId, object): @classmethod @transaction.atomic - def _generate(cls, cart): + def _generate_from_cart(cls, cart): ''' Generates an invoice for the given cart. ''' cart.refresh_from_db() @@ -86,14 +86,13 @@ class InvoiceController(ForId, object): "product", "product__category", ) - - if len(product_items) == 0: - raise ValidationError("Your cart is empty.") - 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", @@ -101,8 +100,6 @@ class InvoiceController(ForId, object): "product__category", ) - line_items = [] - def format_product(product): return "%s - %s" % (product.category.name, product.name) @@ -110,6 +107,8 @@ class InvoiceController(ForId, object): description = discount.description return "%s (%s)" % (description, format_product(product)) + line_items = [] + for item in product_items: product = item.product line_item = commerce.LineItem( @@ -131,10 +130,17 @@ class InvoiceController(ForId, object): # Generate the invoice user = cart.user - reservation_limit = cart.reservation_duration + cart.time_last_updated + 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, reservation_limit) + due = max(issued, min_due_time) # Get the invoice recipient profile = people.AttendeeProfileBase.objects.get_subclass( From 2e5a8e3668908ddefbce0d4dcfb9b701098e248f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 08:50:56 +1000 Subject: [PATCH 326/418] First pass at allowing manual invoices. --- registrasion/controllers/invoice.py | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 118b49cb..3d4df7fc 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -72,6 +72,38 @@ class InvoiceController(ForId, object): 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): @@ -129,7 +161,6 @@ class InvoiceController(ForId, object): # Generate the invoice - user = cart.user min_due_time = cart.reservation_duration + cart.time_last_updated return cls._generate(cart.user, cart, min_due_time, line_items) From 6469bcd8e78a749a4069ccefdf254038204c543a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 09:08:29 +1000 Subject: [PATCH 327/418] Adds test for manual invoicing --- registrasion/controllers/invoice.py | 2 +- registrasion/tests/test_invoice.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 3d4df7fc..a2adca4e 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -184,7 +184,7 @@ class InvoiceController(ForId, object): invoice = commerce.Invoice.objects.create( user=user, cart=cart, - cart_revision=cart.revision, + cart_revision=cart.revision if cart else None, status=commerce.Invoice.STATUS_UNPAID, value=invoice_value, issue_time=issued, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 6d36d082..4f73bf3c 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -580,6 +580,32 @@ class InvoiceTestCase(RegistrationCartTestCase): cn2.credit_note.value, ) + 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)) From 23658be49a76f46021a77ff83d3ad6c536b4e46e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 09:29:31 +1000 Subject: [PATCH 328/418] Starts test_helpers.py, so we can get credit note testing stuff into its own module. --- registrasion/tests/test_helpers.py | 11 +++++++++++ registrasion/tests/test_invoice.py | 13 ++----------- 2 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 registrasion/tests/test_helpers.py diff --git a/registrasion/tests/test_helpers.py b/registrasion/tests/test_helpers.py new file mode 100644 index 00000000..cdd23de2 --- /dev/null +++ b/registrasion/tests/test_helpers.py @@ -0,0 +1,11 @@ +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 _credit_note_for_invoice(self, invoice): + note = commerce.CreditNote.objects.get(invoice=invoice) + return TestingCreditNoteController(note) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 4f73bf3c..334db137 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -10,23 +10,14 @@ from registrasion.models import inventory from controller_helpers import TestingCartController from controller_helpers import TestingCreditNoteController from controller_helpers import TestingInvoiceController +from test_helpers import TestHelperMixin from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') -class InvoiceTestCase(RegistrationCartTestCase): - - 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 _credit_note_for_invoice(self, invoice): - note = commerce.CreditNote.objects.get(invoice=invoice) - return TestingCreditNoteController(note) +class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): def test_create_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) From 66f423eafac09d89f7e4586ae019acbaba34aa59 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 09:38:42 +1000 Subject: [PATCH 329/418] Moves tests for credit note functionality into its own test module --- registrasion/tests/test_credit_note.py | 328 +++++++++++++++++++++++++ registrasion/tests/test_helpers.py | 6 + registrasion/tests/test_invoice.py | 308 ----------------------- 3 files changed, 334 insertions(+), 308 deletions(-) create mode 100644 registrasion/tests/test_credit_note.py diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py new file mode 100644 index 00000000..c204cfd4 --- /dev/null +++ b/registrasion/tests/test_credit_note.py @@ -0,0 +1,328 @@ +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 controller_helpers import TestingCartController +from controller_helpers import TestingCreditNoteController +from controller_helpers import TestingInvoiceController +from test_helpers import TestHelperMixin + +from test_cart import RegistrationCartTestCase + +UTC = pytz.timezone('UTC') + + +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.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.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.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.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): + 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): + invoice = self._invoice_containing_prod_1(2) + + 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) + + self.assertEquals(1, commerce.CreditNote.unclaimed().count()) + + # Create a new cart (of half value of inv 1) and get invoice + cart = TestingCartController.for_user(self.USER_1) + 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) + + # 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): + 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)) + 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. ''' + + cart = TestingCartController.for_user(self.USER_1) + + cart.add_to_cart(self.PROD_1, 1) + invoice = TestingInvoiceController.for_cart(cart.cart) + + # 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, and apply partial payments + invoice = TestingInvoiceController.for_cart(cart.cart) + cn.apply_to_invoice(invoice.invoice) + + # 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) + 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, + ) diff --git a/registrasion/tests/test_helpers.py b/registrasion/tests/test_helpers.py index cdd23de2..6ef5c1d2 100644 --- a/registrasion/tests/test_helpers.py +++ b/registrasion/tests/test_helpers.py @@ -1,3 +1,9 @@ +from registrasion.models import commerce + +from controller_helpers import TestingCartController +from controller_helpers import TestingCreditNoteController +from controller_helpers import TestingInvoiceController + class TestHelperMixin(object): def _invoice_containing_prod_1(self, qty=1): diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 334db137..6ae662a3 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -229,268 +229,6 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice.validate_allowed_to_pay() - 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.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.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.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.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): - 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): - invoice = self._invoice_containing_prod_1(2) - - 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) - - self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - - # Create a new cart (of half value of inv 1) and get invoice - cart = TestingCartController.for_user(self.USER_1) - 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) - - # 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): - 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)) - 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_required_category_constraints_prevent_invoicing(self): self.CAT_1.required = True self.CAT_1.save() @@ -525,52 +263,6 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice = TestingInvoiceController.for_cart(cart.cart) - 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. ''' - - cart = TestingCartController.for_user(self.USER_1) - - cart.add_to_cart(self.PROD_1, 1) - invoice = TestingInvoiceController.for_cart(cart.cart) - - # 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, and apply partial payments - invoice = TestingInvoiceController.for_cart(cart.cart) - cn.apply_to_invoice(invoice.invoice) - - # 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) - 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_can_generate_manual_invoice(self): description_price_pairs = [ From 05c5cfcb4e8e8a188ed1839cd722a0a82d79e52f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 10:03:27 +1000 Subject: [PATCH 330/418] Adds first tests for automatic credit note application --- registrasion/tests/test_credit_note.py | 37 +++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index c204cfd4..ce704652 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -288,10 +288,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): means that invoice cannot be voided, and new invoices cannot be created. ''' - cart = TestingCartController.for_user(self.USER_1) - - cart.add_to_cart(self.PROD_1, 1) - invoice = TestingInvoiceController.for_cart(cart.cart) + invoice = self._invoice_containing_prod_1(1) # Now get a credit note invoice.pay("Lol", invoice.invoice.value) @@ -326,3 +323,35 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): 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 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. + ''' + + raise NotImplementedError() + + 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. + ''' + + raise NotImplementedError() + + def test_credit_notes_are_not_applied_if_user_has_multiple_invoices(self): + + raise NotImplementedError() From 82254a7bf513fc41237d4623b4eaf08ca6f5958c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 10:22:12 +1000 Subject: [PATCH 331/418] Credit note is automatically applied if you have a single invoice --- registrasion/controllers/invoice.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index a2adca4e..9fadcd11 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -198,10 +198,30 @@ class InvoiceController(ForId, object): 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. + ''' + + notes = commerce.CreditNote.objects.filter(invoice__user=invoice.user) + + if len(notes) == 0: + return + + 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. From 04b7a7998c964c9e3793a78a695c4de8f6bcbd0c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 10:32:22 +1000 Subject: [PATCH 332/418] Tests correct behaviour when there are multiple credit notes to be applied --- registrasion/controllers/invoice.py | 11 +++- registrasion/tests/test_credit_note.py | 81 +++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 9fadcd11..09eb02da 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -208,11 +208,16 @@ class InvoiceController(ForId, object): ''' Applies the user's credit notes to the given invoice on creation. ''' - notes = commerce.CreditNote.objects.filter(invoice__user=invoice.user) - - if len(notes) == 0: + # 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.objects.filter(invoice__user=invoice.user) for note in notes: try: CreditNoteController(note).apply_to_invoice(invoice) diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index ce704652..d725048b 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -336,13 +336,37 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): invoice2 = self._invoice_containing_prod_1(1) self.assertTrue(invoice2.invoice.is_paid) + def _generate_multiple_credit_notes(self): + items = [("Item 1", 5), ("Item 2", 6)] + due = datetime.timedelta(hours=1) + inv1 = TestingInvoiceController.manual_invoice(self.USER_1, due, items) + inv2 = TestingInvoiceController.manual_invoice(self.USER_1, due, items) + invoice1 = TestingInvoiceController(inv1) + invoice1.pay("Pay", inv1.value) + invoice1.refund() + invoice2 = TestingInvoiceController(inv2) + invoice2.pay("Pay", inv2.value) + invoice2.refund() + return inv1.value + inv2.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. ''' - raise NotImplementedError() + notes_value = self._generate_multiple_credit_notes() + item = [("Item", notes_value + 1)] + due = datetime.timedelta(hours=1) + inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) + invoice = TestingInvoiceController(inv) + + self.assertEqual(notes_value, 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. @@ -350,8 +374,59 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): Sum of credit note values will be *GREATER* than the new invoice. ''' - raise NotImplementedError() + notes_value = self._generate_multiple_credit_notes() + item = [("Item", notes_value - 1)] + due = datetime.timedelta(hours=1) + inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) + invoice = TestingInvoiceController(inv) + + self.assertEqual(notes_value - 1, 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() + 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 + item = [("Item", 1)] + due = datetime.timedelta(hours=1) + inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) + + 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): - raise NotImplementedError() + # Have an invoice pending with no credit notes; no payment will be made + invoice1 = self._invoice_containing_prod_1(1) + # Create some credit notes. + self._generate_multiple_credit_notes() + + item = [("Item", notes_value)] + due = datetime.timedelta(hours=1) + inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) + invoice = TestingInvoiceController(inv) + + # Because there's already an invoice open for this user + # The credit notes are not automatically applied. + self.assertEqual(0, invoice.total_payments()) + self.assertTrue(invoice.invoice.is_unpaid) From 5fce13d3862fda20ff7d2fda4852e518e34d44b1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 10:55:07 +1000 Subject: [PATCH 333/418] Simplifies credit note tests --- registrasion/tests/test_credit_note.py | 37 +++++++++----------------- registrasion/tests/test_helpers.py | 9 +++++++ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index d725048b..6b2c66ac 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -16,6 +16,8 @@ from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') +HOURS = datetime.timedelta(hours=1) + class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): @@ -337,17 +339,13 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): self.assertTrue(invoice2.invoice.is_paid) def _generate_multiple_credit_notes(self): - items = [("Item 1", 5), ("Item 2", 6)] - due = datetime.timedelta(hours=1) - inv1 = TestingInvoiceController.manual_invoice(self.USER_1, due, items) - inv2 = TestingInvoiceController.manual_invoice(self.USER_1, due, items) - invoice1 = TestingInvoiceController(inv1) - invoice1.pay("Pay", inv1.value) + invoice1 = self._manual_invoice(11) + invoice2 = self._manual_invoice(11) + invoice1.pay("Pay", invoice1.invoice.value) invoice1.refund() - invoice2 = TestingInvoiceController(inv2) - invoice2.pay("Pay", inv2.value) + invoice2.pay("Pay", invoice2.invoice.value) invoice2.refund() - return inv1.value + inv2.value + 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. @@ -356,10 +354,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): ''' notes_value = self._generate_multiple_credit_notes() - item = [("Item", notes_value + 1)] - due = datetime.timedelta(hours=1) - inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) - invoice = TestingInvoiceController(inv) + invoice = self._manual_invoice(notes_value + 1) self.assertEqual(notes_value, invoice.total_payments()) self.assertTrue(invoice.invoice.is_unpaid) @@ -375,10 +370,8 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): ''' notes_value = self._generate_multiple_credit_notes() - item = [("Item", notes_value - 1)] - due = datetime.timedelta(hours=1) - inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) - invoice = TestingInvoiceController(inv) + invoice = self._manual_invoice(notes_value - 1) + self.assertEqual(notes_value - 1, invoice.total_payments()) self.assertTrue(invoice.invoice.is_paid) @@ -402,10 +395,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): # Create a manual invoice whose value is smaller than any of the # credit notes we created - item = [("Item", 1)] - due = datetime.timedelta(hours=1) - inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) - + invoice = self._manual_invoice(1) notes_new = commerce.CreditNote.unclaimed().filter( invoice__user=self.USER_1 ) @@ -421,10 +411,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): # Create some credit notes. self._generate_multiple_credit_notes() - item = [("Item", notes_value)] - due = datetime.timedelta(hours=1) - inv = TestingInvoiceController.manual_invoice(self.USER_1, due, item) - invoice = TestingInvoiceController(inv) + invoice = self._manual_invoice(2) # Because there's already an invoice open for this user # The credit notes are not automatically applied. diff --git a/registrasion/tests/test_helpers.py b/registrasion/tests/test_helpers.py index 6ef5c1d2..c656a2d0 100644 --- a/registrasion/tests/test_helpers.py +++ b/registrasion/tests/test_helpers.py @@ -1,3 +1,5 @@ +import datetime + from registrasion.models import commerce from controller_helpers import TestingCartController @@ -12,6 +14,13 @@ class TestHelperMixin(object): 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) From 77a7689de50a0b6d8072167c4687377a52a6ed57 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 11:01:24 +1000 Subject: [PATCH 334/418] Fixes credit note tests that were broken with the old behaviour --- registrasion/tests/test_credit_note.py | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index 6b2c66ac..3e6d1cd6 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -95,6 +95,12 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): 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 @@ -122,10 +128,11 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): 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) - to_pay = invoice.invoice.value - invoice.pay("Reference", to_pay) + invoice.pay("Reference", invoice.invoice.value) self.assertTrue(invoice.invoice.is_paid) invoice.refund() @@ -135,13 +142,9 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): self.assertEquals(1, commerce.CreditNote.unclaimed().count()) - # Create a new cart (of half value of inv 1) and get invoice - cart = TestingCartController.for_user(self.USER_1) - cart.add_to_cart(self.PROD_1, 1) - - invoice2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) - - cn.apply_to_invoice(invoice2.invoice) + # 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, @@ -160,6 +163,12 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): ) 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 @@ -237,7 +246,9 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): cart.add_to_cart(self.PROD_1, 1) invoice_2 = TestingInvoiceController.for_cart(self.reget(cart.cart)) - cn.apply_to_invoice(invoice_2.invoice) + 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()) @@ -301,9 +312,9 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 2) - # Create a current invoice, and apply partial payments + # Create a current invoice + # This will automatically apply `cn` to the invoice invoice = TestingInvoiceController.for_cart(cart.cart) - cn.apply_to_invoice(invoice.invoice) # Adding to cart will mean that the old invoice for this cart # will be invalidated. A new invoice should be generated. From fd9980efc578d9acd89bf8ca454358e3c09f0c6d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 11:41:50 +1000 Subject: [PATCH 335/418] Makes sure we only apply unclaimed credit notes when auto-applying credit notes. --- registrasion/controllers/invoice.py | 4 +++- registrasion/tests/test_credit_note.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 09eb02da..d4be50b1 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -217,7 +217,9 @@ class InvoiceController(ForId, object): if invoices.count() > 1: return - notes = commerce.CreditNote.objects.filter(invoice__user=invoice.user) + notes = commerce.CreditNote.unclaimed().filter( + invoice__user=invoice.user + ) for note in notes: try: CreditNoteController(note).apply_to_invoice(invoice) diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index 3e6d1cd6..2857ce7f 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -428,3 +428,15 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): # The credit notes are not automatically applied. self.assertEqual(0, 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 xrange(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) From d4f4312178cd8c54bad8544570dc9fc98bb6b790 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 12:15:40 +1000 Subject: [PATCH 336/418] Adds cancellation fee implementation and tests --- registrasion/controllers/credit_note.py | 26 +++++++++++++++++++++++++ registrasion/tests/test_credit_note.py | 25 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index 182c10e9..e4784c8b 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -1,3 +1,5 @@ +import datetime + from django.db import transaction from registrasion.models import commerce @@ -53,3 +55,27 @@ class CreditNoteController(ForId, object): 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. + ''' + + from invoice import InvoiceController # Circular imports bleh. + + 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) diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index 2857ce7f..c4be1041 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -440,3 +440,28 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): # 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) + self.test_cancellation_fee_is_applied() From 2ca644e5002d93218f0d827ba4dfe4ffe5811828 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 12:25:34 +1000 Subject: [PATCH 337/418] Adds form for generating a cancellation fee. --- registrasion/forms.py | 9 +++++++++ registrasion/views.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index cc555b78..4839b733 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,6 +3,7 @@ from registrasion.models import commerce from registrasion.models import inventory from django import forms +from django.core.exceptions import ValidationError class ApplyCreditNoteForm(forms.Form): @@ -31,6 +32,14 @@ class ApplyCreditNoteForm(forms.Form): ) +class CancellationFeeForm(forms.Form): + + percentage = forms.DecimalField( + required=True, + min_value=0, + max_value=100, + ) + class ManualCreditNoteRefundForm(forms.ModelForm): class Meta: diff --git a/registrasion/views.py b/registrasion/views.py index f96104f1..9248dce4 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -765,6 +765,9 @@ def credit_note(request, note_id, access_code=None): # to an invoice. "refund_form": form, # A form for applying a *manual* # refund of the credit note. + "cancellation_fee_form" : form, # A form for generating an + # invoice with a + # cancellation fee } ''' @@ -783,6 +786,11 @@ def credit_note(request, note_id, access_code=None): prefix="refund_note" ) + cancellation_fee_form = forms.CancellationFeeForm( + request.POST or None, + prefix="cancellation_fee" + ) + if request.POST and apply_form.is_valid(): inv_id = apply_form.cleaned_data["invoice"] invoice = commerce.Invoice.objects.get(pk=inv_id) @@ -805,10 +813,20 @@ def credit_note(request, note_id, access_code=None): prefix="refund_note", ) + elif request.POST and cancellation_fee_form.is_valid(): + percentage = cancellation_fee_form.cleaned_data["percentage"] + invoice = current_note.cancellation_fee(percentage) + messages.success( + request, + "Generated cancellation fee for credit note %d." % note_id, + ) + return redirect("invoice", invoice.invoice.id) + data = { "credit_note": current_note.credit_note, "apply_form": apply_form, "refund_form": refund_form, + "cancellation_fee_form": cancellation_fee_form, } return render(request, "registrasion/credit_note.html", data) From 2c8ed9a51aa68ce81fa560b7bbfab729aaa7a59c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 15:33:43 +1000 Subject: [PATCH 338/418] Adds test for GroupMemberCondition --- registrasion/tests/test_group_member.py | 70 ++++++++++++++----------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/registrasion/tests/test_group_member.py b/registrasion/tests/test_group_member.py index 84b194aa..96e1d58a 100644 --- a/registrasion/tests/test_group_member.py +++ b/registrasion/tests/test_group_member.py @@ -19,21 +19,24 @@ class GroupMemberTestCase(RegistrationCartTestCase): @classmethod def _create_group_and_flag(cls): - ''' Creates cls.GROUP, and restricts cls.PROD_1 only to users who are - members of the group. ''' + ''' Creates cls.GROUP_1, and restricts cls.PROD_1 only to users who are + members of the group. Likewise GROUP_2 and PROD_2 ''' - group = Group.objects.create( - name="TEST GROUP", - ) + 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) - flag = conditions.GroupMemberFlag.objects.create( - description="Group member flag", - condition=conditions.FlagBase.ENABLE_IF_TRUE, - ) - flag.group.add(group) - flag.products.add(cls.PROD_1) + groups.append(group) - cls.GROUP = 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 @@ -41,25 +44,30 @@ class GroupMemberTestCase(RegistrationCartTestCase): self._create_group_and_flag() - # USER_1 cannot see PROD_1 until they're in GROUP. - available = ProductController.available_products( - self.USER_1, - products=[self.PROD_1], - ) - self.assertNotIn(self.PROD_1, available) + groups = [self.GROUP_1, self.GROUP_2] + products = [self.PROD_1, self.PROD_2] - self.USER_1.groups.add(self.GROUP) + 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=[self.PROD_1], - ) - self.assertIn(self.PROD_1, available) + # USER_1 cannot see PROD_1 until they're in GROUP. + available = ProductController.available_products( + self.USER_1, + products=[product], + ) + self.assertNotIn(product, available) - # USER_2 is still locked out - available = ProductController.available_products( - self.USER_2, - products=[self.PROD_1], - ) - self.assertNotIn(self.PROD_1, 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) From 52fa696a01b2ec2030cd0735b8ba69b913b1abfe Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 15:33:52 +1000 Subject: [PATCH 339/418] Fixes GroupMemberCondition test --- registrasion/controllers/conditions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index e9efb625..01f53e7e 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -334,6 +334,4 @@ class GroupMemberConditionController(IsMetByFilter, ConditionController): ''' Returns all of the items from conditions which are enabled by a user being member of a Django Auth Group. ''' - return conditions.filter( - group=user.groups.all(), - ) + return conditions.filter(group__in=user.groups.all()) From 4026dac3a3599ee2875ddd644e1c462eabd96241 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 15:46:45 +1000 Subject: [PATCH 340/418] Re-adds admin for TimeOrStockLimitFlag Fixes #82 --- registrasion/admin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/registrasion/admin.py b/registrasion/admin.py index 95e46b70..6c0b44e9 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -215,3 +215,15 @@ class GroupMemberFlagAdmin(admin.ModelAdmin, EffectsDisplayMixin): fields = ("description", "group") 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") From 3517bdd2813f380c407529340d25d586a41011b4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 16:01:49 +1000 Subject: [PATCH 341/418] Makes sure that discounts always apply to the most expensive product in the cart first. Adds test to that effect. Fixes #88. --- registrasion/controllers/cart.py | 7 ++++--- registrasion/tests/test_discount.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index ad4458ea..204dc9f6 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -399,9 +399,11 @@ class CartController(object): # 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", "product__price" - ) + ).order_by("-product__price") products = [i.product for i in product_items] discounts = DiscountController.available_discounts( @@ -411,8 +413,7 @@ class CartController(object): ) # The highest-value discounts will apply to the highest-value - # products first. - product_items = reversed(product_items) + # products first, because of the order_by clause for item in product_items: self._add_discount(item.product, item.quantity, discounts) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index d7920a10..2696535b 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -243,6 +243,29 @@ class DiscountTestCase(RegistrationCartTestCase): # 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( From fc81f107eda106cf8de97cc9dd634b6308c2ba60 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 15 Sep 2016 16:33:19 +1000 Subject: [PATCH 342/418] =?UTF-8?q?When=20setting=20quantities=20on=20prod?= =?UTF-8?q?ucts,=20only=20raise=20errors=20if=20they=E2=80=99re=20due=20to?= =?UTF-8?q?=20changes=20made=20during=20the=20current=20call=20to=20set=5F?= =?UTF-8?q?quantities.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #54 --- registrasion/controllers/cart.py | 12 +++++++++++- registrasion/tests/test_flag.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 204dc9f6..ff31bb95 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -132,6 +132,7 @@ class CartController(object): 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( @@ -140,7 +141,16 @@ class CartController(object): )).items() # Validate that the limits we're adding are OK - self._test_limits(all_product_quantities) + 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 = [] diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index 3d8914a1..0b446c26 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -382,3 +382,15 @@ class FlagTestCases(RegistrationCartTestCase): 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) From 4a50d699365b9b7befec74261edfd1a5925f58e2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 16 Sep 2016 09:35:12 +1000 Subject: [PATCH 343/418] Moves total_payments() to Invoice model; adds balance_due() --- registrasion/controllers/invoice.py | 15 ++++--------- registrasion/models/commerce.py | 18 +++++++++++++++- .../templatetags/registrasion_tags.py | 21 ------------------- registrasion/tests/test_credit_note.py | 18 +++++++++------- registrasion/tests/test_invoice.py | 11 ++++++++++ 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index d4be50b1..84af2a87 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -269,19 +269,12 @@ class InvoiceController(ForId, object): CartController(self.invoice.cart).validate_cart() - def total_payments(self): - ''' Returns the total amount paid towards this invoice. ''' - - payments = commerce.PaymentBase.objects.filter(invoice=self.invoice) - total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0 - return total_paid - def update_status(self): ''' Updates the status of this invoice based upon the total payments.''' old_status = self.invoice.status - total_paid = self.total_payments() + total_paid = self.invoice.total_payments() num_payments = commerce.PaymentBase.objects.filter( invoice=self.invoice, ).count() @@ -366,7 +359,7 @@ class InvoiceController(ForId, object): def update_validity(self): ''' Voids this invoice if the cart it is attached to has updated. ''' if not self._invoice_matches_cart(): - if self.total_payments() > 0: + if self.invoice.total_payments() > 0: # Free up the payments made to this invoice self.refund() else: @@ -374,7 +367,7 @@ class InvoiceController(ForId, object): def void(self): ''' Voids the invoice if it is valid to do so. ''' - if self.total_payments() > 0: + 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.") @@ -394,7 +387,7 @@ class InvoiceController(ForId, object): raise ValidationError("Void invoices cannot be refunded") # Raises a credit note fot the value of the invoice. - amount = self.total_payments() + amount = self.invoice.total_payments() if amount == 0: self.void() diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index a9fc7341..0ab035d3 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -4,7 +4,7 @@ from . import inventory from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F, Q +from django.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 _ @@ -175,6 +175,17 @@ class Invoice(models.Model): 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) @@ -224,6 +235,11 @@ class LineItem(models.Model): 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() diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 89db7e25..fda5b2c0 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -74,24 +74,3 @@ def items_purchased(context, category=None): return ItemController(context.request.user).items_purchased( category=category ) - - -@register.filter -def multiply(value, arg): - ''' Multiplies value by arg. - - This is useful when displaying invoices, as it lets you multiply the - quantity by the unit value. - - Arguments: - - value (number) - - arg (number) - - Returns: - number: value * arg - - ''' - - return value * arg diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index c4be1041..5cd43af2 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -29,7 +29,9 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): invoice.pay("Reference", to_pay) # The total paid should be equal to the value of the invoice only - self.assertEqual(invoice.invoice.value, invoice.total_payments()) + 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. @@ -46,7 +48,9 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): 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.total_payments()) + self.assertEqual( + invoice.invoice.value, invoice.invoice.total_payments() + ) self.assertTrue(invoice.invoice.is_paid) # There should be no credit notes @@ -64,7 +68,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): invoice.refund() # The total paid should be zero - self.assertEqual(0, invoice.total_payments()) + self.assertEqual(0, invoice.invoice.total_payments()) self.assertTrue(invoice.invoice.is_void) # There should be a credit note generated out of the invoice. @@ -84,7 +88,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): invoice.refund() # The total paid should be zero - self.assertEqual(0, invoice.total_payments()) + self.assertEqual(0, invoice.invoice.total_payments()) self.assertTrue(invoice.invoice.is_refunded) # There should be a credit note generated out of the invoice. @@ -367,7 +371,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): notes_value = self._generate_multiple_credit_notes() invoice = self._manual_invoice(notes_value + 1) - self.assertEqual(notes_value, invoice.total_payments()) + self.assertEqual(notes_value, invoice.invoice.total_payments()) self.assertTrue(invoice.invoice.is_unpaid) user_unclaimed = commerce.CreditNote.unclaimed() @@ -384,7 +388,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): invoice = self._manual_invoice(notes_value - 1) - self.assertEqual(notes_value - 1, invoice.total_payments()) + self.assertEqual(notes_value - 1, invoice.invoice.total_payments()) self.assertTrue(invoice.invoice.is_paid) user_unclaimed = commerce.CreditNote.unclaimed().filter( @@ -426,7 +430,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): # Because there's already an invoice open for this user # The credit notes are not automatically applied. - self.assertEqual(0, invoice.total_payments()) + 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): diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 6ae662a3..a1769fe0 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -97,6 +97,17 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): 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) + for i in xrange(0, 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", From 7e74a2e0da427b99f20f44d823dc3494c228aa78 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 13:25:02 +1000 Subject: [PATCH 344/418] =?UTF-8?q?Updates=20the=20treasurer=E2=80=99s=20r?= =?UTF-8?q?econciliation=20view=20to=20be=20MUCH=20more=20comprehensive.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/models/commerce.py | 4 ++ registrasion/reporting/views.py | 95 ++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 0ab035d3..846d1ea6 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -307,6 +307,10 @@ class CreditNote(PaymentBase): creditnoterefund=None, ) + @classmethod + def refunded(cls): + return cls.objects.exclude(creditnoterefund=None) + @property def status(self): if self.is_unclaimed: diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 88f4eada..9de2d34f 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -48,20 +48,27 @@ def reports_list(request): # 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. ''' -@report_view("Paid items", form_type=forms.ProductAndCategoryForm) -def items_sold(request, form): + 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 - products = form.cleaned_data["product"] - categories = form.cleaned_data["category"] - line_items = commerce.LineItem.objects.filter( - Q(product__in=products) | Q(product__category__in=categories), invoice__status=commerce.Invoice.STATUS_PAID, ).select_related("invoice") @@ -95,14 +102,20 @@ def items_sold(request, form): return ListReport("Paid items", headings, data) -@report_view("Reconcilitation") -def reconciliation(request, form): - ''' Reconciles all sales in the system with the payments in the - system. ''' +def sales_payment_summary(): + ''' Summarises paid items and payments. ''' - headings = ["Thing", "Total"] + 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( @@ -110,27 +123,59 @@ def reconciliation(request, form): ).aggregate( total=Sum(F("price") * F("quantity"), output_field=CURRENCY()), ) + sales = value_or_zero(sales, "total") - data.append(["Paid items", sales["total"]]) + all_payments = sum_amount(commerce.PaymentBase.objects.all()) - payments = commerce.PaymentBase.objects.values( - "amount", - ).aggregate(total=Sum("amount")) + # Manual payments + # Credit notes generated (total) + # Payments made by credit note + # Claimed credit notes - data.append(["Payments", payments["total"]]) - - ucn = commerce.CreditNote.unclaimed().values( - "amount" - ).aggregate(total=Sum("amount")) - - data.append(["Unclaimed credit notes", 0 - ucn["total"]]) + 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([ - "(Money not on invoices)", - sales["total"] - payments["total"] - ucn["total"], + "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", headings, data) + 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, + ) @report_view("Product status", form_type=forms.ProductAndCategoryForm) From 2c99114d9fa622955fa9bb9bdd3791c53a9e48c5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 13:26:46 +1000 Subject: [PATCH 345/418] Improves wording on reconciliation report --- registrasion/reporting/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 9de2d34f..237330f7 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -99,7 +99,7 @@ def items_sold(): "(TOTAL)", "--", "--", total_income, ]) - return ListReport("Paid items", headings, data) + return ListReport("Items sold", headings, data) def sales_payment_summary(): @@ -147,7 +147,7 @@ def sales_payment_summary(): 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", + "Credit notes - (claimed credit notes + unclaimed credit notes)", all_credit_notes - claimed_credit_notes - refunded_credit_notes - unclaimed_credit_notes, ]) From 851c37508a1f3611deaf561261c2717f9ac4b2ec Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 13:39:39 +1000 Subject: [PATCH 346/418] Factors out annotating objects by cart status --- registrasion/reporting/views.py | 46 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 237330f7..7770d76e 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -178,19 +178,8 @@ def credit_note_refunds(): ) -@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 = items.annotate( +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), @@ -198,14 +187,8 @@ def product_status(request, form): ), ) - items = items.order_by( - "product__category__order", - "product__order", - ).values( - "product", - "product__category__name", - "product__name", - ).annotate( + values = queryset.order_by(*order).values(*values) + values = values.annotate( total_paid=Sum(Case( When( cart__status=commerce.Cart.STATUS_PAID, @@ -242,6 +225,27 @@ def product_status(request, form): )), ) + 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", ] From f41bd9c65bec605e36e346c2d1a8a5e2a2f146f5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 15:03:21 +1000 Subject: [PATCH 347/418] Adds paid invoices by date report --- registrasion/reporting/views.py | 43 ++++++++++++++++++++++++++++++++- registrasion/urls.py | 5 ++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 7770d76e..1eea40f3 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -1,10 +1,13 @@ import forms +import collections +import datetime + from django.contrib.auth.decorators import user_passes_test from django.core.urlresolvers import reverse from django.db import models from django.db.models import F, Q -from django.db.models import Count, Sum +from django.db.models import Count, Max, Sum from django.db.models import Case, When, Value from django.shortcuts import render @@ -265,6 +268,44 @@ def product_status(request, form): return ListReport("Inventory", 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, + ) + + 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")) + + by_date = collections.defaultdict(int) + + for line in invoice_max_time: + time = line["max_time"] + 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. ''' diff --git a/registrasion/urls.py b/registrasion/urls.py index 4d746388..64fecbe0 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -46,6 +46,11 @@ reports = [ url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"), url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), url(r"^items_sold/?$", rv.items_sold, name="items_sold"), + 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"), ] From e2d027f71b093083e18d133df58c12e23196c62e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 10:33:43 +1000 Subject: [PATCH 348/418] Adds a report for consumption of a discount. Fixes #78 --- registrasion/reporting/forms.py | 10 +++++++++- registrasion/reporting/views.py | 34 +++++++++++++++++++++++++++++++++ registrasion/urls.py | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 94e99491..8543209b 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -1,8 +1,16 @@ +from registrasion.models import conditions from registrasion.models import inventory from django import forms -# Staff-facing forms. +# Reporting forms. + + +class DiscountForm(forms.Form): + discount = forms.ModelMultipleChoiceField( + queryset=conditions.DiscountBase.objects.all(), + required=False, + ) class ProductAndCategoryForm(forms.Form): diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 1eea40f3..2ec5d08f 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -268,6 +268,40 @@ def product_status(request, form): 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 diff --git a/registrasion/urls.py b/registrasion/urls.py index 64fecbe0..d6850ef7 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -45,7 +45,7 @@ reports = [ url(r"^attendee/?$", rv.attendee, name="attendee"), url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"), url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), - url(r"^items_sold/?$", rv.items_sold, name="items_sold"), + url(r"^discount_status/?$", rv.discount_status, name="discount_status"), url( r"^paid_invoices_by_date/?$", rv.paid_invoices_by_date, From 6e4d2fab16ad84e5a8898bf5b3bfe87725563c0e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 11:10:48 +1000 Subject: [PATCH 349/418] Adds ATTENDEE_PROFILE_MODEL as a thing that needs to be specified in settings.py. Fixes #65 --- docs/integration.rst | 8 ++++++-- registrasion/reporting/views.py | 1 - registrasion/views.py | 15 ++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index f8c58a64..dc012359 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -46,9 +46,13 @@ Because every conference is different, Registrasion lets you define your own att .. autoclass :: AttendeeProfileBase :members: name_field, invoice_recipient -Once you've subclassed ``AttendeeProfileBase``, you'll need to implement a form that lets attendees fill out their profile. +You specify how to find that model in your Django ``settings.py`` file:: -You specify how to find that form in your Django ``settings.py`` file:: + ATTENDEE_PROFILE_MODEL = "democon.models.AttendeeProfile" + +When Registrasion asks the to edit their profile, a default form will be generated, showing all of the fields on the profile model. + +If you want to customise the profile editing form, you need to specify the location of that form in your ``settings.py`` file as well. ATTENDEE_PROFILE_FORM = "democon.forms.AttendeeProfileForm" diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 2ec5d08f..19dd4449 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -274,7 +274,6 @@ def discount_status(request, form): discounts = form.cleaned_data["discount"] - items = commerce.DiscountItem.objects.filter( Q(discount__in=discounts), ).select_related("cart", "product", "product__category") diff --git a/registrasion/views.py b/registrasion/views.py index 9248dce4..2ee1469d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,7 @@ from registrasion.exceptions import CartValidationError from collections import namedtuple +from django import forms as django_forms from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import user_passes_test @@ -59,7 +60,7 @@ class GuidedRegistrationSection(_GuidedRegistrationSection): pass -def get_form(name): +def get_object(name): dot = name.rindex(".") mod_name, form_name = name[:dot], name[dot + 1:] __import__(mod_name) @@ -274,6 +275,16 @@ def edit_profile(request): return render(request, "registrasion/profile_form.html", data) +# Define the attendee profile form, or get a default. +try: + ProfileForm = get_object(settings.ATTENDEE_PROFILE_FORM) +except: + class ProfileForm(django_forms.ModelForm): + class Meta: + model = get_object(settings.ATTENDEE_PROFILE_MODEL) + exclude = ["attendee"] + + def _handle_profile(request, prefix): ''' Returns a profile form instance, and a boolean which is true if the form was handled. ''' @@ -287,8 +298,6 @@ def _handle_profile(request, prefix): except ObjectDoesNotExist: profile = None - ProfileForm = get_form(settings.ATTENDEE_PROFILE_FORM) - # Load a pre-entered name from the speaker's profile, # if they have one. try: From 6611546a355c41378dd113e04fcc648f6532f0cd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 11:24:50 +1000 Subject: [PATCH 350/418] Moves get_object_from_name into util. --- registrasion/util.py | 17 +++++++++++++++++ registrasion/views.py | 12 +++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/registrasion/util.py b/registrasion/util.py index 54f56a1e..4d09fea4 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -1,4 +1,5 @@ import string +import sys from django.utils.crypto import get_random_string @@ -55,3 +56,19 @@ def lazy(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) diff --git a/registrasion/views.py b/registrasion/views.py index 2ee1469d..724c2aae 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,4 +1,5 @@ import sys +import util from registrasion import forms from registrasion import util @@ -60,13 +61,6 @@ class GuidedRegistrationSection(_GuidedRegistrationSection): pass -def get_object(name): - dot = name.rindex(".") - mod_name, form_name = name[:dot], name[dot + 1:] - __import__(mod_name) - return getattr(sys.modules[mod_name], form_name) - - @login_required def guided_registration(request): ''' Goes through the registration process in order, making sure user sees @@ -277,11 +271,11 @@ def edit_profile(request): # Define the attendee profile form, or get a default. try: - ProfileForm = get_object(settings.ATTENDEE_PROFILE_FORM) + ProfileForm = util.get_object_from_name(settings.ATTENDEE_PROFILE_FORM) except: class ProfileForm(django_forms.ModelForm): class Meta: - model = get_object(settings.ATTENDEE_PROFILE_MODEL) + model = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL) exclude = ["attendee"] From e3b662fb67fd339eb9e36011b33166e6202e38b0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 12:08:03 +1000 Subject: [PATCH 351/418] Adds attendee profile data to the attendee page --- registrasion/reporting/views.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 19dd4449..3e2fa514 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -3,6 +3,7 @@ import forms import collections import datetime +from django.conf import settings from django.contrib.auth.decorators import user_passes_test from django.core.urlresolvers import reverse from django.db import models @@ -14,6 +15,7 @@ from django.shortcuts import render 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 reports import get_all_reports @@ -27,6 +29,9 @@ 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. ''' @@ -375,6 +380,22 @@ def attendee(request, form, user_id=None): reports = [] + profile_data = [] + profile = people.AttendeeProfileBase.objects.get_subclass( + attendee=attendee + ) + exclude = set(["attendeeprofilebase_ptr", "id"]) + for field in profile._meta.get_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) + profile_data.append((field.verbose_name, value)) + + reports.append(ListReport("Profile", ["", ""], profile_data)) + links = [] links.append(( reverse(views.amend_registration, args=[user_id]), From 2ed0a47f15bde1dfd1bdc4281043d21d0e99d3c9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 13:36:49 +1000 Subject: [PATCH 352/418] Adds attendance by field report Fixes #93 --- registrasion/reporting/forms.py | 19 ++++++ registrasion/reporting/views.py | 103 ++++++++++++++++++++++++++++++-- registrasion/urls.py | 1 + 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 8543209b..2e983491 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -29,3 +29,22 @@ class UserIdForm(forms.Form): label="User ID", 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 diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 3e2fa514..628102b7 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -364,6 +364,12 @@ def credit_notes(request, form): ) +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, @@ -484,9 +490,98 @@ def attendee_list(request): # Sort by whether they've registered, then ID. data.sort(key=lambda a: (-a[3], a[0])) - class Report(ListReport): + return AttendeeListReport("Attendees", headings, data, link_view=attendee) - def get_link(self, argument): - return reverse(self._link_view) + "?user=%d" % int(argument) - return Report("Attendees", headings, data, link_view=attendee) +ProfileForm = forms.model_fields_form_factory(AttendeeProfile) +class ProductCategoryProfileForm(forms.ProductAndCategoryForm, ProfileForm): + pass + + +@report_view( + "Attendees By Product/Category", + form_type=ProductCategoryProfileForm, +) +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 = [] + + 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", "product" + ).order_by("cart__status") + + # Get all of the relevant attendee profiles in one hit. + profiles = AttendeeProfile.objects.filter( + attendee__user__cart__productitem__in=items + ).select_related("attendee__user") + by_user = {} + for profile in profiles: + by_user[profile.attendee.user] = profile + + for field in fields: + field_verbose = AttendeeProfile._meta.get_field(field).verbose_name + + cart = "attendee__user__cart" + cart_status = cart + "__status" + product = cart + "__productitem__product" + product_name = product + "__name" + category_name = product + "__category__name" + + p = profiles.order_by(product, field).values( + cart_status, product, product_name, category_name, field + ).annotate(count=Count("id")) + output.append(ListReport( + "Grouped by %s" % field_verbose, + ["Product", "Status", field_verbose, "count"], + [ + ( + "%s - %s" % (i[category_name], i[product_name]), + status_display[i[cart_status]], + i[field], + i["count"] or 0, + ) + for i in p + ], + )) + + # DO the report for individual attendees + + field_names = [ + AttendeeProfile._meta.get_field(field).verbose_name for field in fields + ] + + headings = ["User ID", "Name", "Product", "Item Status"] + field_names + data = [] + for item in items: + profile = by_user[item.cart.user] + line = [ + item.cart.user.id, + getattr(profile, name_field), + item.product, + status_display[item.cart.status], + ] + [ + getattr(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 diff --git a/registrasion/urls.py b/registrasion/urls.py index d6850ef7..d7e440df 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -43,6 +43,7 @@ public = [ 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"^discount_status/?$", rv.discount_status, name="discount_status"), From 2d469bb398937da6d778ed4667009b957145556a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 14:04:38 +1000 Subject: [PATCH 353/418] One more addition. --- registrasion/reporting/views.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 628102b7..0d7b261f 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -535,6 +535,7 @@ def attendee_data(request, form, user_id=None): for profile in profiles: by_user[profile.attendee.user] = profile + # Group the responses per-field. for field in fields: field_verbose = AttendeeProfile._meta.get_field(field).verbose_name @@ -544,18 +545,31 @@ def attendee_data(request, form, user_id=None): product_name = product + "__name" category_name = product + "__category__name" + status_count = lambda status: Case(When( + 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) + p = profiles.order_by(product, field).values( - cart_status, product, product_name, category_name, field - ).annotate(count=Count("id")) + product, product_name, category_name, field + ).annotate( + paid_count=Sum(paid_count), + unpaid_count=Sum(unpaid_count), + ) output.append(ListReport( "Grouped by %s" % field_verbose, - ["Product", "Status", field_verbose, "count"], + ["Product", field_verbose, "paid", "unpaid"], [ ( "%s - %s" % (i[category_name], i[product_name]), - status_display[i[cart_status]], i[field], - i["count"] or 0, + i["paid_count"] or 0, + i["unpaid_count"] or 0, ) for i in p ], From 7c5c1553701ed19aeba9b7b168a68ae12f8ef698 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 18:44:23 +1000 Subject: [PATCH 354/418] Shows an email address. --- registrasion/reporting/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 0d7b261f..50901821 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -581,13 +581,14 @@ def attendee_data(request, form, user_id=None): AttendeeProfile._meta.get_field(field).verbose_name for field in fields ] - headings = ["User ID", "Name", "Product", "Item Status"] + field_names + headings = ["User ID", "Name", "Email", "Product", "Item Status"] + 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], ] + [ From 94a8c3e3d922da1f0ad5c16b3ab62148c798d888 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 19:18:09 +1000 Subject: [PATCH 355/418] Adds speaker registration data. Addresses #77 --- registrasion/reporting/forms.py | 10 +++++++++ registrasion/reporting/views.py | 39 +++++++++++++++++++++++++++++++++ registrasion/urls.py | 5 +++++ 3 files changed, 54 insertions(+) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 2e983491..010a8f24 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -1,6 +1,8 @@ from registrasion.models import conditions from registrasion.models import inventory +from symposion.proposals import models as proposals_models + from django import forms # Reporting forms. @@ -31,6 +33,14 @@ class UserIdForm(forms.Form): ) +class ProposalKindForm(forms.Form): + kind = forms.ModelMultipleChoiceField( + queryset=proposals_models.ProposalKind.objects.all(), + required=False, + ) + + + def model_fields_form_factory(model): ''' Creates a form for specifying fields from a model to display. ''' diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 50901821..3e3f9308 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -5,6 +5,7 @@ import datetime 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 @@ -18,6 +19,8 @@ from registrasion.models import people from registrasion import util from registrasion import views +from symposion.schedule import models as schedule_models + from reports import get_all_reports from reports import Links from reports import ListReport @@ -600,3 +603,39 @@ def attendee_data(request, form, user_id=None): "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=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 [] diff --git a/registrasion/urls.py b/registrasion/urls.py index d7e440df..c7f008d0 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -54,6 +54,11 @@ reports = [ ), 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", + ), ] From a16cb7146301aedb82640b579109c128b1a0246f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 21 Sep 2016 15:58:58 +1000 Subject: [PATCH 356/418] Fixes issue in for_id_or_404 --- registrasion/controllers/for_id.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/controllers/for_id.py b/registrasion/controllers/for_id.py index 3748b151..8412b2ac 100644 --- a/registrasion/controllers/for_id.py +++ b/registrasion/controllers/for_id.py @@ -21,4 +21,4 @@ class ForId(object): try: return cls.for_id(id_) except ObjectDoesNotExist: - return Http404 + raise Http404() From e775e5afd906ddbae620ddcb16ad9425a1aca060 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 11:23:56 +1000 Subject: [PATCH 357/418] Documentation fix. --- registrasion/models/commerce.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 846d1ea6..8c8bcd9b 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -253,7 +253,7 @@ class PaymentBase(models.Model): class to handle implementation-specific issues. Attributes: - invoice (inventory.Invoice): The invoice that this payment applies to. + 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. From aec9e58edfd0398deb9ab56a0541609efe988019 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 23 Sep 2016 15:21:57 +1000 Subject: [PATCH 358/418] Removes avenue for crash in reporting attendees. --- registrasion/reporting/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 3e3f9308..d181aa72 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -485,7 +485,8 @@ def attendee_list(request): for a in attendees: data.append([ a.user.id, - a.attendeeprofilebase.attendee_name(), + a.attendeeprofilebase.attendee_name() + if hasattr(a, "attendeeprofilebase") else "", a.user.email, a.has_registered > 0, ]) From 33eb1a6c0b924f5c1d216f6db2f644e0c79559aa Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 24 Sep 2016 09:30:37 +1000 Subject: [PATCH 359/418] Temporarily Removes Symposion as a dependency --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 5343b3dc..a87391f9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ django-nested-admin==2.2.6 -symposion==1.0b2.dev3 +#symposion==1.0b2.dev3 From c25f19d66e48435ad6caf9e2f8b59a7c7dd34ff1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 25 Sep 2016 11:33:07 +1000 Subject: [PATCH 360/418] Increases search space for access codes. --- registrasion/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/util.py b/registrasion/util.py index 4d09fea4..b5fa0620 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -10,10 +10,10 @@ def generate_access_code(): The access code will 4 characters long, which allows for 1,500,625 unique codes, which really should be enough for anyone. ''' - length = 4 + length = 6 # all upper-case letters + digits 1-9 (no 0 vs O confusion) chars = string.uppercase + string.digits[1:] - # 4 chars => 35 ** 4 = 1500625 (should be enough for anyone) + # 6 chars => 35 ** 6 = 1838265625 (should be enough for anyone) return get_random_string(length=length, allowed_chars=chars) From 922a7ff1d9cb44feb465fe95343372b86cb311bf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 2 Oct 2016 10:40:10 -0700 Subject: [PATCH 361/418] Adds product/categories to admin view for group member flag --- registrasion/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/admin.py b/registrasion/admin.py index 6c0b44e9..5fc62067 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -212,7 +212,7 @@ class SpeakerFlagAdmin(nested_admin.NestedAdmin, EffectsDisplayMixin): @admin.register(conditions.GroupMemberFlag) class GroupMemberFlagAdmin(admin.ModelAdmin, EffectsDisplayMixin): - fields = ("description", "group") + fields = ("description", "group", "products", "categories") list_display = ("description", "effects") From f0ab1f944f44001b6317c00c54d75633d9861a7b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 10:38:03 -0700 Subject: [PATCH 362/418] paid_invoices_by_date now counts invoices with a $0 value. Fixes #96 --- registrasion/reporting/views.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index d181aa72..0832e723 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -2,6 +2,7 @@ import forms import collections import datetime +import itertools from django.conf import settings from django.contrib.auth.decorators import user_passes_test @@ -318,21 +319,33 @@ def paid_invoices_by_date(request, form): categories = form.cleaned_data["category"] invoices = commerce.Invoice.objects.filter( - Q(lineitem__product__in=products) | Q(lineitem__product__category__in=categories), + ( + 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")) + 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 line in invoice_max_time: - time = line["max_time"] + for time in times: date = datetime.datetime( year=time.year, month=time.month, day=time.day ) From bf21d478a8f19b626b9fd1b6106ecc7159e76639 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 11:08:58 -0700 Subject: [PATCH 363/418] Adds ability to group by category instead of by product Fixes #98. --- registrasion/reporting/forms.py | 22 ++++++++++++++ registrasion/reporting/views.py | 52 +++++++++++++++++++++------------ 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 010a8f24..e5b0e1af 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -8,6 +8,13 @@ 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): discount = forms.ModelMultipleChoiceField( queryset=conditions.DiscountBase.objects.all(), @@ -40,6 +47,21 @@ class ProposalKindForm(forms.Form): ) +class GroupByForm(forms.Form): + 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, + ) + + def model_fields_form_factory(model): ''' Creates a form for specifying fields from a model to display. ''' diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 0832e723..b44da6b0 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -511,13 +511,12 @@ def attendee_list(request): ProfileForm = forms.model_fields_form_factory(AttendeeProfile) -class ProductCategoryProfileForm(forms.ProductAndCategoryForm, ProfileForm): - pass - @report_view( "Attendees By Product/Category", - form_type=ProductCategoryProfileForm, + 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 @@ -531,6 +530,9 @@ def attendee_data(request, form, user_id=None): output = [] + by_category = form.cleaned_data["group_by"] == forms.GroupByForm.GROUP_BY_CATEGORY + print by_category + products = form.cleaned_data["product"] categories = form.cleaned_data["category"] fields = form.cleaned_data["fields"] @@ -552,16 +554,28 @@ def attendee_data(request, form, user_id=None): for profile in profiles: by_user[profile.attendee.user] = profile + cart = "attendee__user__cart" + cart_status = cart + "__status" + 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], ) + 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]) + # Group the responses per-field. for field in fields: field_verbose = AttendeeProfile._meta.get_field(field).verbose_name - cart = "attendee__user__cart" - cart_status = cart + "__status" - product = cart + "__productitem__product" - product_name = product + "__name" - category_name = product + "__category__name" - status_count = lambda status: Case(When( attendee__user__cart__status=status, then=Value(1), @@ -572,23 +586,25 @@ def attendee_data(request, form, user_id=None): paid_count = status_count(commerce.Cart.STATUS_PAID) unpaid_count = status_count(commerce.Cart.STATUS_ACTIVE) - p = profiles.order_by(product, field).values( - product, product_name, category_name, field + 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, - ["Product", field_verbose, "paid", "unpaid"], + [first_column, field_verbose, "paid", "unpaid"], [ ( - "%s - %s" % (i[category_name], i[product_name]), - i[field], - i["paid_count"] or 0, - i["unpaid_count"] or 0, + group_name(group), + group[field], + group["paid_count"] or 0, + group["unpaid_count"] or 0, ) - for i in p + for group in groups ], )) From 7058260e5c425f182dbc21a1f784838d9102cbbf Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 11:59:08 -0700 Subject: [PATCH 364/418] Resolves values of related fields --- registrasion/reporting/forms.py | 1 + registrasion/reporting/views.py | 38 +++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index e5b0e1af..a02d96e0 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -59,6 +59,7 @@ class GroupByForm(forms.Form): group_by = forms.ChoiceField( label="Group by", choices=choices, + required=False, ) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index b44da6b0..deda9bf1 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -12,6 +12,7 @@ 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.item import ItemController @@ -531,7 +532,6 @@ def attendee_data(request, form, user_id=None): output = [] by_category = form.cleaned_data["group_by"] == forms.GroupByForm.GROUP_BY_CATEGORY - print by_category products = form.cleaned_data["product"] categories = form.cleaned_data["category"] @@ -574,7 +574,28 @@ def attendee_data(request, form, user_id=None): # Group the responses per-field. for field in fields: - field_verbose = AttendeeProfile._meta.get_field(field).verbose_name + concrete_field = AttendeeProfile._meta.get_field(field) + field_verbose = concrete_field.verbose_name + + # Render the correct values for related fields + if isinstance(concrete_field, RelatedField): + # 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( attendee__user__cart__status=status, @@ -600,7 +621,7 @@ def attendee_data(request, form, user_id=None): [ ( group_name(group), - group[field], + display_field(group[field]), group["paid_count"] or 0, group["unpaid_count"] or 0, ) @@ -614,6 +635,15 @@ def attendee_data(request, form, user_id=None): 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()] + else: + return attr + headings = ["User ID", "Name", "Email", "Product", "Item Status"] + field_names data = [] for item in items: @@ -625,7 +655,7 @@ def attendee_data(request, form, user_id=None): item.product, status_display[item.cart.status], ] + [ - getattr(profile, field) for field in fields + display_field(profile, field) for field in fields ] data.append(line) From ffe5194893359cdd9602710a01ce01018e8f6b89 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 12:07:38 -0700 Subject: [PATCH 365/418] Query optimisation on attendee_data form --- registrasion/reporting/forms.py | 2 +- registrasion/reporting/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index a02d96e0..e9f8cf0a 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -24,7 +24,7 @@ class DiscountForm(forms.Form): class ProductAndCategoryForm(forms.Form): product = forms.ModelMultipleChoiceField( - queryset=inventory.Product.objects.all(), + queryset=inventory.Product.objects.select_related("category"), required=False, ) category = forms.ModelMultipleChoiceField( diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index deda9bf1..5d25adc8 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -543,7 +543,7 @@ def attendee_data(request, form, user_id=None): ).exclude( cart__status=commerce.Cart.STATUS_RELEASED ).select_related( - "cart", "product" + "cart", "cart__user", "product", "product__category", ).order_by("cart__status") # Get all of the relevant attendee profiles in one hit. From ace7aa3efa207c43025d2136954d70b8c6bb0e06 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 12:17:40 -0700 Subject: [PATCH 366/418] Final query optimisation for attendee_data view --- registrasion/reporting/views.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 5d25adc8..d17149cc 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -543,13 +543,19 @@ def attendee_data(request, form, user_id=None): ).exclude( cart__status=commerce.Cart.STATUS_RELEASED ).select_related( - "cart", "cart__user", "product", "product__category", + "cart", "cart__user", "product", "product__category", ).order_by("cart__status") + # 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") + ).select_related("attendee__user").prefetch_related(*related_fields) by_user = {} for profile in profiles: by_user[profile.attendee.user] = profile @@ -578,7 +584,7 @@ def attendee_data(request, form, user_id=None): field_verbose = concrete_field.verbose_name # Render the correct values for related fields - if isinstance(concrete_field, RelatedField): + 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] @@ -640,7 +646,7 @@ def attendee_data(request, form, user_id=None): attr = getattr(profile, field) if isinstance(field_type, models.ManyToManyField): - return [str(i) for i in attr.all()] + return [str(i) for i in attr.all()] or "" else: return attr From 62858b0f6ed4035dbf1b6dc8a1e2f675cdf19f00 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 12:28:43 -0700 Subject: [PATCH 367/418] Optimises some queries on attendee profile page --- registrasion/reporting/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index d17149cc..7a74728d 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -454,7 +454,8 @@ def attendee(request, form, user_id=None): # 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"], @@ -465,7 +466,8 @@ def attendee(request, form, user_id=None): # All payments payments = commerce.PaymentBase.objects.filter( invoice__user=attendee.user, - ) + ).select_related("invoice") + reports.append(QuerysetReport( "Payments", ["invoice__id", "id", "reference", "amount"], From 36d658e57f6ad56c31604d5acfdeaf4d1ea4c968 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 12:52:56 -0700 Subject: [PATCH 368/418] More query optimisation --- registrasion/reporting/views.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 7a74728d..28dfa037 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -467,7 +467,7 @@ def attendee(request, form, user_id=None): payments = commerce.PaymentBase.objects.filter( invoice__user=attendee.user, ).select_related("invoice") - + reports.append(QuerysetReport( "Payments", ["invoice__id", "id", "reference", "amount"], @@ -481,11 +481,18 @@ def attendee(request, form, user_id=None): def attendee_list(request): ''' Returns a list of all attendees. ''' - attendees = people.Attendee.objects.all().select_related( + 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) @@ -501,8 +508,8 @@ def attendee_list(request): for a in attendees: data.append([ a.user.id, - a.attendeeprofilebase.attendee_name() - if hasattr(a, "attendeeprofilebase") else "", + (profiles_by_attendee[a].attendee_name() + if a in profiles_by_attendee else ""), a.user.email, a.has_registered > 0, ]) From 1129a4605c0a016c8997f08faf6179489f9bd06a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 5 Oct 2016 13:07:44 -0700 Subject: [PATCH 369/418] Fixes a bug, hopefully --- registrasion/reporting/views.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 28dfa037..d5ba66a3 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -690,7 +690,7 @@ def speaker_registrations(request, form): kinds = form.cleaned_data["kind"] presentations = schedule_models.Presentation.objects.filter( - proposal_base__kind=kinds, + proposal_base__kind__in=kinds, ).exclude( cancelled=True, ) @@ -702,9 +702,13 @@ def speaker_registrations(request, form): 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()) + 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") + users = users.order_by("paid_carts") return QuerysetReport( "Speaker Registration Status", From 360175f86a59ff4500222795345dbcc5d3fdbabe Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 6 Oct 2016 11:52:46 -0700 Subject: [PATCH 370/418] Adds tests for reservation duration --- registrasion/tests/test_cart.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index a6803150..d6a4a612 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -423,3 +423,39 @@ class BasicCartTests(RegistrationCartTestCase): 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) From b323c0eb25512e09ce1893c0f9c335df6abb6766 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 6 Oct 2016 12:12:50 -0700 Subject: [PATCH 371/418] Cart reservation durations now take the residual from the last reservation duration into account. --- registrasion/controllers/cart.py | 11 +++++++-- registrasion/tests/test_cart.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index ff31bb95..5caa4ccb 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -76,7 +76,14 @@ class CartController(object): determine whether the cart has reserved the items and discounts it holds. ''' - reservations = [datetime.timedelta()] + 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: @@ -90,7 +97,7 @@ class CartController(object): if product_max is not None: reservations.append(product_max) - self.cart.time_last_updated = timezone.now() + self.cart.time_last_updated = time self.cart.reservation_duration = max(reservations) def end_batch(self): diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d6a4a612..1bfdba43 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -459,3 +459,44 @@ class BasicCartTests(RegistrationCartTestCase): 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) From d31d81200117dfa123e124dcae8f47a79b157338 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 6 Oct 2016 12:33:53 -0700 Subject: [PATCH 372/418] Adds functionality to increase the reservation duration --- registrasion/controllers/cart.py | 26 +++++++++++++++++ registrasion/tests/test_cart.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 5caa4ccb..c2249d77 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -124,6 +124,32 @@ class CartController(object): 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 diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 1bfdba43..ecd0e72d 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -500,3 +500,53 @@ class BasicCartTests(RegistrationCartTestCase): 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, + ) From 6dbc303e7c380cf5aecc70ffa2498d90cc1aa4d4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 6 Oct 2016 12:44:06 -0700 Subject: [PATCH 373/418] =?UTF-8?q?Adds=20ability=20for=20staff=20to=20ext?= =?UTF-8?q?end=20a=20user=E2=80=99s=20reservations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/reporting/views.py | 10 ++++++++++ registrasion/urls.py | 2 ++ registrasion/views.py | 13 +++++++++++++ 3 files changed, 25 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index d5ba66a3..2c1c8a86 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -15,6 +15,7 @@ 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 @@ -417,6 +418,10 @@ def attendee(request, form, user_id=None): value = getattr(profile, field.name) 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 = [] @@ -424,6 +429,11 @@ def attendee(request, form, user_id=None): 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 diff --git a/registrasion/urls.py b/registrasion/urls.py index c7f008d0..05b10aab 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -8,6 +8,7 @@ from .views import ( checkout, credit_note, edit_profile, + extend_reservation, guided_registration, invoice, invoice_access, @@ -24,6 +25,7 @@ public = [ 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$", diff --git a/registrasion/views.py b/registrasion/views.py index 724c2aae..8b0d2b5d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,3 +1,4 @@ +import datetime import sys import util @@ -903,3 +904,15 @@ def amend_registration(request, user_id): } return render(request, "registrasion/amend_registration.html", data) + + +@user_passes_test(_staff_only) +def extend_reservation(request, user_id, days=7): + ''' Allows staff to extend the reservation on a given user's cart. + ''' + + user = User.objects.get(id=int(user_id)) + cart = CartController.for_user(user) + cart.extend_reservation(datetime.timedelta(days=days)) + + return redirect(request.META["HTTP_REFERER"]) From 3ca2be8c4b1cd09481ae654e541865213638b118 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 6 Oct 2016 12:49:37 -0700 Subject: [PATCH 374/418] Attendee data page is now slightly more useful --- registrasion/reporting/views.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 2c1c8a86..c29d8cb9 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -405,17 +405,26 @@ def attendee(request, form, user_id=None): reports = [] profile_data = [] - profile = people.AttendeeProfileBase.objects.get_subclass( - attendee=attendee - ) + 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 profile._meta.get_fields(): + 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) From e05265edd2c1a9f5e70977eaf343ba7aee0855be Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 13 Oct 2016 08:31:11 -0700 Subject: [PATCH 375/418] Adds test for invoice becoming invalid over time. Tests for #99 --- registrasion/tests/test_invoice.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index a1769fe0..50790874 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -168,7 +168,7 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): self.assertTrue(invoice_1.invoice.is_paid) - def test_invoice_voids_self_if_cart_is_invalid(self): + 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 @@ -190,6 +190,31 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): 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) + inv2 = 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) From 232dc9e452227a5d6806cbc96d8a3e7e0fc5bf04 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 13 Oct 2016 09:19:18 -0700 Subject: [PATCH 376/418] Invoices are tested for cart validity before display. Fixes #99. --- registrasion/controllers/invoice.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 84af2a87..3b675e17 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -357,8 +357,18 @@ class InvoiceController(ForId, object): return cart.revision == self.invoice.cart_revision def update_validity(self): - ''' Voids this invoice if the cart it is attached to has updated. ''' - if not self._invoice_matches_cart(): + ''' 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() From 17cc088a6ee49e29daf35429060574de08b67720 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 13 Oct 2016 09:32:30 -0700 Subject: [PATCH 377/418] =?UTF-8?q?Adds=20an=20=E2=80=9Cinvoices=E2=80=9D?= =?UTF-8?q?=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/reporting/views.py | 15 +++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 16 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index c29d8cb9..a5784014 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -382,6 +382,21 @@ def credit_notes(request, form): ) +@report_view("Invoices") +def invoices(request,form): + ''' Shows all of the invoices in the system. ''' + + invoices = commerce.Invoice.objects.all().order_by("status") + + 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): diff --git a/registrasion/urls.py b/registrasion/urls.py index 05b10aab..0d21854c 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -49,6 +49,7 @@ reports = [ url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"), url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), 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, From c9c9d2a2b22092bfaa5f07325829e325f94b02ca Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 13 Oct 2016 10:50:48 -0700 Subject: [PATCH 378/418] Cancelled presentations no longer enable SpeakerCondition. Fixes #94 --- registrasion/controllers/conditions.py | 7 +++++- registrasion/tests/test_speaker.py | 30 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index 01f53e7e..87fb9cfb 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -310,7 +310,12 @@ 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 proposal. ''' + 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 diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py index e3b14ed0..5fe80d76 100644 --- a/registrasion/tests/test_speaker.py +++ b/registrasion/tests/test_speaker.py @@ -11,8 +11,10 @@ from registrasion.controllers.product import ProductController from symposion.conference import models as conference_models from symposion.proposals import models as proposal_models -from symposion.speakers import models as speaker_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 test_cart import RegistrationCartTestCase @@ -207,3 +209,29 @@ class SpeakerTestCase(RegistrationCartTestCase): 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) From 3f192c2626da9d53fc3a431f486935c80ba78fb2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 13 Oct 2016 11:23:41 -0700 Subject: [PATCH 379/418] Zeroed & paid invoices that are voided now release the cart. Fixes #95. --- registrasion/controllers/invoice.py | 14 ++++++++++---- registrasion/tests/test_invoice.py | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 3b675e17..7b35ea58 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -331,10 +331,7 @@ class InvoiceController(ForId, object): def _mark_refunded(self): ''' Marks the invoice as refunded, and updates the attached cart if necessary. ''' - cart = self.invoice.cart - if cart: - cart.status = commerce.Cart.STATUS_RELEASED - cart.save() + self._release_cart() self.invoice.status = commerce.Invoice.STATUS_REFUNDED self.invoice.save() @@ -356,6 +353,12 @@ class InvoiceController(ForId, object): 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. ''' @@ -381,6 +384,9 @@ class InvoiceController(ForId, object): 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 diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 50790874..61e1abc9 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -142,7 +142,7 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): self.PROD_1.price * Decimal("0.5"), invoice_1.invoice.value) - def test_zero_value_invoice_is_automatically_paid(self): + def _make_zero_value_invoice(self): voucher = inventory.Voucher.objects.create( recipient="Voucher recipient", code="VOUCHER", @@ -164,10 +164,21 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): # 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) + 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) From 320f6ab6eba0d21ed5c762571a7c9b4f765125d0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 10:27:22 -0700 Subject: [PATCH 380/418] First step refactoring ReportView into a class --- registrasion/reporting/reports.py | 37 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index ab8e924d..37ac4032 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -194,19 +194,39 @@ def report_view(title, form_type=None): ''' + # Consolidate form_type so it has format and section + # Create & return view + def _report(view): @wraps(view) @user_passes_test(views._staff_only) def inner_view(request, *a, **k): + return ReportView(request, view, title, form_type).render(*a, **k) - if form_type is not None: - form = form_type(request.GET) + # Add this report to the list of reports. + _all_report_views.append(inner_view) + + # Return the callable + return inner_view + return _report + +class ReportView(object): + + def __init__(self, request, view, title, form_type): + self.request = request + self.view = view + self.title = title + self.form_type = form_type + + def render(self, *a, **k): + if self.form_type is not None: + form = self.form_type(self.request.GET) form.is_valid() else: form = None - reports = view(request, form, *a, **k) + reports = self.view(self.request, form, *a, **k) if isinstance(reports, Report): reports = [reports] @@ -217,19 +237,12 @@ def report_view(title, form_type=None): ] ctx = { - "title": title, + "title": self.title, "form": form, "reports": reports, } - return render(request, "registrasion/report.html", ctx) - - # Add this report to the list of reports. - _all_report_views.append(inner_view) - - # Return the callable - return inner_view - return _report + return render(self.request, "registrasion/report.html", ctx) def get_all_reports(): From ea7a8d9ae77206f2347aaec5011dc713c248e738 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 10:28:38 -0700 Subject: [PATCH 381/418] Indentation --- registrasion/reporting/reports.py | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 37ac4032..a04e786b 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -220,29 +220,29 @@ class ReportView(object): self.form_type = form_type def render(self, *a, **k): - if self.form_type is not None: - form = self.form_type(self.request.GET) - form.is_valid() - else: - form = None + if self.form_type is not None: + form = self.form_type(self.request.GET) + form.is_valid() + else: + form = None - reports = self.view(self.request, form, *a, **k) + reports = self.view(self.request, form, *a, **k) - if isinstance(reports, Report): - reports = [reports] + if isinstance(reports, Report): + reports = [reports] - reports = [ - _ReportTemplateWrapper("text/html", report) - for report in reports - ] + reports = [ + _ReportTemplateWrapper("text/html", report) + for report in reports + ] - ctx = { - "title": self.title, - "form": form, - "reports": reports, - } + ctx = { + "title": self.title, + "form": form, + "reports": reports, + } - return render(self.request, "registrasion/report.html", ctx) + return render(self.request, "registrasion/report.html", ctx) def get_all_reports(): From 263793099627aa0bb9d76f6a5fad740931e6ba55 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 11:11:27 -0700 Subject: [PATCH 382/418] Adds CSV output support --- registrasion/reporting/forms.py | 13 ++++++ registrasion/reporting/reports.py | 71 +++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index e9f8cf0a..c275fc01 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -81,3 +81,16 @@ def model_fields_form_factory(model): ) return ModelFieldsForm + + +class SectionContentTypeForm(forms.Form): + section = forms.IntegerField( + required=False, + min_value=0, + widget=forms.HiddenInput(), + ) + + content_type = forms.CharField( + required=False, + widget=forms.HiddenInput(), + ) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index a04e786b..cf232f8a 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -1,6 +1,10 @@ +import csv +import forms + 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 @@ -195,6 +199,10 @@ def report_view(title, form_type=None): ''' # Consolidate form_type so it has format and section + bases = [forms.SectionContentTypeForm, form_type] + bases = [base for base in bases if base is not None] + form_type = forms.mix_form(*bases) + # Create & return view def _report(view): @@ -202,7 +210,7 @@ def report_view(title, form_type=None): @wraps(view) @user_passes_test(views._staff_only) def inner_view(request, *a, **k): - return ReportView(request, view, title, form_type).render(*a, **k) + return ReportView(request, view, title, form_type).view(*a, **k) # Add this report to the list of reports. _all_report_views.append(inner_view) @@ -213,37 +221,84 @@ def report_view(title, form_type=None): class ReportView(object): - def __init__(self, request, view, title, form_type): + def __init__(self, request, inner_view, title, form_type): self.request = request - self.view = view + self.inner_view = inner_view self.title = title self.form_type = form_type + self._prepare() - def render(self, *a, **k): + def view(self, *a, **k): + self._prepare_reports(*a, **k) + + return self._render() + + def _prepare(self): + + # Create a form instance if self.form_type is not None: form = self.form_type(self.request.GET) + + # Pre-validate it form.is_valid() else: form = None - reports = self.view(self.request, form, *a, **k) + self.form = form + self.content_type = form.cleaned_data["content_type"] + self.section = form.cleaned_data["section"] + + renderers = { + "text/csv": self._render_as_csv, + "text/html": self._render_as_html, + "": self._render_as_html, + } + self._render = renderers[self.content_type] + + def _prepare_reports(self, *a, **k): + reports = self.inner_view(self.request, self.form, *a, **k) if isinstance(reports, Report): reports = [reports] + self.reports = self._wrap_reports(reports) + + def _render(self): + ''' Replace with a specialist _render function ''' + + def _wrap_reports(self, reports): reports = [ - _ReportTemplateWrapper("text/html", report) + _ReportTemplateWrapper(self.content_type, report) for report in reports ] + return reports + + def _render_as_html(self): + ctx = { "title": self.title, - "form": form, - "reports": reports, + "form": self.form, + "reports": self.reports, } return render(self.request, "registrasion/report.html", ctx) + def _render_as_csv(self): + report = self.reports[self.section] + + # Create the HttpResponse object with the appropriate CSV header. + response = HttpResponse(content_type='text/csv') + #response['Content-Disposition'] = 'attachment; filename="somefilename.csv"' + + writer = csv.writer(response) + writer.writerow(list(report.headings())) + for row in report.rows(): + writer.writerow(list(row)) + + return response + + def get_all_reports(): ''' Returns all the views that have been registered with @report ''' From 517da705366cdd2084d6d9c18182472296ff3ace Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 11:19:10 -0700 Subject: [PATCH 383/418] CSV fixes --- registrasion/reporting/reports.py | 6 +++--- registrasion/reporting/views.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index cf232f8a..49df9fbd 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -289,12 +289,12 @@ class ReportView(object): # Create the HttpResponse object with the appropriate CSV header. response = HttpResponse(content_type='text/csv') - #response['Content-Disposition'] = 'attachment; filename="somefilename.csv"' writer = csv.writer(response) - writer.writerow(list(report.headings())) + encode = lambda i: i.encode("utf8") if isinstance(i, unicode) else i + writer.writerow(list(encode(i) for i in report.headings())) for row in report.rows(): - writer.writerow(list(row)) + writer.writerow(list(encode(i) for i in row)) return response diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index a5784014..2aaa3c31 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -408,7 +408,7 @@ 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 not form.has_changed(): + if user_id is None: return attendee_list(request) if form.cleaned_data["user"] is not None: From 67ac01e599d4e7c45410f083eb3001ae7e0fe1ab Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 11:36:31 -0700 Subject: [PATCH 384/418] Adds a tag to take the CSV version of a report --- registrasion/templatetags/registrasion_tags.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index fda5b2c0..7d2bb56e 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -4,6 +4,7 @@ from registrasion.controllers.item import ItemController from django import template from django.db.models import Sum +from urllib import urlencode register = template.Library() @@ -74,3 +75,14 @@ def items_purchased(context, category=None): return ItemController(context.request.user).items_purchased( category=category ) + + +@register.assignment_tag(takes_context=True) +def report_as_csv(context, section): + + query = dict(context.request.GET) + query["section"] = section + query["content_type"] = "text/csv" + querystring = urlencode(query) + + return context.request.path + "?" + querystring From ed2327beddb3ba476f718ca94d13962f22538f5b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 16:10:36 -0700 Subject: [PATCH 385/418] Cleans up the architecture for report views --- registrasion/reporting/reports.py | 113 ++++++++++++++++-------------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 49df9fbd..dce5358f 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -198,94 +198,78 @@ def report_view(title, form_type=None): ''' - # Consolidate form_type so it has format and section - bases = [forms.SectionContentTypeForm, form_type] - bases = [base for base in bases if base is not None] - form_type = forms.mix_form(*bases) - # Create & return view - def _report(view): - - @wraps(view) - @user_passes_test(views._staff_only) - def inner_view(request, *a, **k): - return ReportView(request, view, title, form_type).view(*a, **k) + 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(inner_view) + _all_report_views.append(report_view) + + return report_view - # Return the callable - return inner_view return _report + class ReportView(object): - def __init__(self, request, inner_view, title, form_type): - self.request = request + def __init__(self, inner_view, title, form_type): + # Consolidate form_type so it has content type and section + bases = [forms.SectionContentTypeForm, form_type] + bases = [base for base in bases if base is not None] + form_type = forms.mix_form(*bases) + self.inner_view = inner_view self.title = title self.form_type = form_type - self._prepare() - def view(self, *a, **k): - self._prepare_reports(*a, **k) + def __call__(self, request, *a, **k): + data = ReportViewRequestData(self, request, *a, **k) + return self.render(data) - return self._render() - - def _prepare(self): + def get_form(self, request): # Create a form instance if self.form_type is not None: - form = self.form_type(self.request.GET) + form = self.form_type(request.GET) # Pre-validate it form.is_valid() else: form = None - self.form = form - self.content_type = form.cleaned_data["content_type"] - self.section = form.cleaned_data["section"] + return form - renderers = { - "text/csv": self._render_as_csv, - "text/html": self._render_as_html, - "": self._render_as_html, - } - self._render = renderers[self.content_type] - - def _prepare_reports(self, *a, **k): - reports = self.inner_view(self.request, self.form, *a, **k) - - if isinstance(reports, Report): - reports = [reports] - - self.reports = self._wrap_reports(reports) - - def _render(self): - ''' Replace with a specialist _render function ''' - - def _wrap_reports(self, reports): + @classmethod + def wrap_reports(cls, reports, content_type): reports = [ - _ReportTemplateWrapper(self.content_type, report) + _ReportTemplateWrapper(content_type, report) for report in reports ] return reports - def _render_as_html(self): + def render(self, data): + renderers = { + "text/csv": self._render_as_csv, + "text/html": self._render_as_html, + "": self._render_as_html, + } + render = renderers[data.content_type] + return render(data) + def _render_as_html(self, data): ctx = { "title": self.title, - "form": self.form, - "reports": self.reports, + "form": data.form, + "reports": data.reports, } - return render(self.request, "registrasion/report.html", ctx) + return render(data.request, "registrasion/report.html", ctx) - def _render_as_csv(self): - report = self.reports[self.section] + 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') @@ -299,6 +283,31 @@ class ReportView(object): return response +class ReportViewRequestData(object): + + 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 the form + self.content_type = self.form.cleaned_data["content_type"] + self.section = self.form.cleaned_data["section"] + + # 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 ''' From 6a37134172c3814c651686d07b28b0876e4ea422 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 14 Oct 2016 16:26:36 -0700 Subject: [PATCH 386/418] Stops relying on a form --- registrasion/reporting/forms.py | 13 ------------- registrasion/reporting/reports.py | 13 +++++-------- registrasion/templatetags/registrasion_tags.py | 8 +++++--- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index c275fc01..e9f8cf0a 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -81,16 +81,3 @@ def model_fields_form_factory(model): ) return ModelFieldsForm - - -class SectionContentTypeForm(forms.Form): - section = forms.IntegerField( - required=False, - min_value=0, - widget=forms.HiddenInput(), - ) - - content_type = forms.CharField( - required=False, - widget=forms.HiddenInput(), - ) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index dce5358f..eb2c33a6 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -216,10 +216,6 @@ class ReportView(object): def __init__(self, inner_view, title, form_type): # Consolidate form_type so it has content type and section - bases = [forms.SectionContentTypeForm, form_type] - bases = [base for base in bases if base is not None] - form_type = forms.mix_form(*bases) - self.inner_view = inner_view self.title = title self.form_type = form_type @@ -254,7 +250,7 @@ class ReportView(object): renderers = { "text/csv": self._render_as_csv, "text/html": self._render_as_html, - "": self._render_as_html, + None: self._render_as_html, } render = renderers[data.content_type] return render(data) @@ -292,9 +288,10 @@ class ReportViewRequestData(object): # Calculate other data self.form = report_view.get_form(request) - # Content type and section come from the form - self.content_type = self.form.cleaned_data["content_type"] - self.section = self.form.cleaned_data["section"] + # 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 # Reports come from calling the inner view reports = report_view.inner_view(request, self.form, *a, **k) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 7d2bb56e..1f408ed1 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -80,9 +80,11 @@ def items_purchased(context, category=None): @register.assignment_tag(takes_context=True) def report_as_csv(context, section): - query = dict(context.request.GET) - query["section"] = section - query["content_type"] = "text/csv" + 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 From ea07469634eed423d01e3b623cc3bb6d18e42b25 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 10:18:48 +1100 Subject: [PATCH 387/418] Fixes individual attendee view, which had disappeared. --- registrasion/reporting/reports.py | 3 +++ registrasion/reporting/views.py | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index eb2c33a6..a48f1a35 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -293,6 +293,9 @@ class ReportViewRequestData(object): 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) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 2aaa3c31..5705d4f8 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -408,11 +408,13 @@ 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) - if form.cleaned_data["user"] is not None: - user_id = form.cleaned_data["user"] + print user_id attendee = people.Attendee.objects.get(user__id=user_id) name = attendee.attendeeprofilebase.attendee_name() From 37fbc2ee40dd99c24761fe64a0ab6ab54238efe0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 10:18:54 +1100 Subject: [PATCH 388/418] Adds some reporting documentation --- registrasion/reporting/reports.py | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index a48f1a35..00861072 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -213,8 +213,21 @@ def report_view(title, form_type=None): 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 @@ -226,6 +239,8 @@ class ReportView(object): 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) @@ -239,6 +254,10 @@ class ReportView(object): @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 @@ -247,6 +266,16 @@ class ReportView(object): 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, @@ -280,8 +309,20 @@ class ReportView(object): 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 From fcf4e5cffb54f98663e32596f8e3963c51eaf6d3 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 11:19:30 +1100 Subject: [PATCH 389/418] Adds forms for nag_unpaid --- registrasion/forms.py | 28 ++++++++++++++++++++++++++++ registrasion/urls.py | 2 ++ registrasion/views.py | 16 ++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 4839b733..150219f1 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -4,6 +4,7 @@ from registrasion.models import inventory from django import forms from django.core.exceptions import ValidationError +from django.db.models import Q class ApplyCreditNoteForm(forms.Form): @@ -394,3 +395,30 @@ 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 InvoiceNagForm(forms.Form): + 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 [] + + category = [int(i) for i in category] + product = [int(i) for i in product] + + super(InvoiceNagForm, self).__init__(*a, **k) + + print repr(category), repr(product) + + qs = commerce.Invoice.objects.filter( + status=commerce.Invoice.STATUS_UNPAID, + ).filter( + Q(lineitem__product__category__in=category) | + Q(lineitem__product__in=product) + ) + + self.fields['invoice'].queryset = qs diff --git a/registrasion/urls.py b/registrasion/urls.py index 0d21854c..2028d86b 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -13,6 +13,7 @@ from .views import ( invoice, invoice_access, manual_payment, + nag_unpaid, product_category, refund, review, @@ -34,6 +35,7 @@ public = [ refund, name="refund"), url(r"^invoice_access/([A-Z0-9]+)$", invoice_access, name="invoice_access"), + url(r"^nag_unpaid$", nag_unpaid, name="nag_unpaid"), url(r"^profile$", edit_profile, name="attendee_edit"), url(r"^register$", guided_registration, name="guided_registration"), url(r"^review$", review, name="review"), diff --git a/registrasion/views.py b/registrasion/views.py index 8b0d2b5d..0e62a649 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -916,3 +916,19 @@ def extend_reservation(request, user_id, days=7): cart.extend_reservation(datetime.timedelta(days=days)) return redirect(request.META["HTTP_REFERER"]) + + +@user_passes_test(_staff_only) +def nag_unpaid(request): + ''' Allows staff to nag users with unpaid invoices. ''' + + category = request.GET.getlist("category", []) + product = request.GET.getlist("product", []) + + form = forms.InvoiceNagForm( + request.POST or None, + category=category, + product=product, + ) + + print form.fields['invoice'].queryset From 051a942ffe6637046ff7b24fe86580c1f330aec0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 11:52:10 +1100 Subject: [PATCH 390/418] Forms for nag e-mail --- registrasion/forms.py | 11 +++++++++-- registrasion/models/commerce.py | 4 +++- registrasion/views.py | 6 +++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 150219f1..bf6549b7 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -402,6 +402,9 @@ class InvoiceNagForm(forms.Form): widget=forms.CheckboxSelectMultiple, queryset=commerce.Invoice.objects.all(), ) + message = forms.CharField( + widget=forms.Textarea, + ) def __init__(self, *a, **k): category = k.pop('category', None) or [] @@ -412,8 +415,6 @@ class InvoiceNagForm(forms.Form): super(InvoiceNagForm, self).__init__(*a, **k) - print repr(category), repr(product) - qs = commerce.Invoice.objects.filter( status=commerce.Invoice.STATUS_UNPAID, ).filter( @@ -421,4 +422,10 @@ class InvoiceNagForm(forms.Form): Q(lineitem__product__in=product) ) + # Uniqify + qs = commerce.Invoice.objects.filter( + id__in=qs, + ) + self.fields['invoice'].queryset = qs + self.fields['invoice'].initial = [i.id for i in qs] diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 8c8bcd9b..72ece2c8 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -152,7 +152,9 @@ class Invoice(models.Model): ] def __str__(self): - return "Invoice #%d" % self.id + 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: diff --git a/registrasion/views.py b/registrasion/views.py index 0e62a649..a65b4a7b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -931,4 +931,8 @@ def nag_unpaid(request): product=product, ) - print form.fields['invoice'].queryset + data = { + "form": form, + } + + return render(request, "registrasion/nag_unpaid.html", data) From 19b59d767671f4d0e25ed66fecc31700dfaeb67d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 17:31:42 +1100 Subject: [PATCH 391/418] Adds functionality for sending nag e-mails --- registrasion/forms.py | 4 +++- registrasion/views.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index bf6549b7..27f9cc14 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -402,7 +402,9 @@ class InvoiceNagForm(forms.Form): widget=forms.CheckboxSelectMultiple, queryset=commerce.Invoice.objects.all(), ) - message = forms.CharField( + from_email = forms.CharField() + subject = forms.CharField() + body = forms.CharField( widget=forms.Textarea, ) diff --git a/registrasion/views.py b/registrasion/views.py index a65b4a7b..5f57eb20 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -26,9 +26,11 @@ from django.contrib.auth.models import User from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError +from django.core.mail import send_mass_mail from django.http import Http404 from django.shortcuts import redirect from django.shortcuts import render +from django.template import Context, Template _GuidedRegistrationSection = namedtuple( @@ -931,6 +933,22 @@ def nag_unpaid(request): product=product, ) + if form.is_valid(): + emails = [] + for invoice in form.cleaned_data["invoice"]: + # datatuple = (subject, message, from_email, recipient_list) + from_email = form.cleaned_data["from_email"] + subject = form.cleaned_data["subject"] + body = Template(form.cleaned_data["body"]).render( + Context({ + "invoice" : invoice, + }) + ) + recipient_list = [invoice.user.email] + emails.append((subject, body, from_email, recipient_list)) + send_mass_mail(emails) + messages.info(request, "The e-mails have been sent.") + data = { "form": form, } From 52376dff592a535dc4ad6be0402b3b36ca0fffe0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 17:38:37 +1100 Subject: [PATCH 392/418] Adds nag mails to the UI. Fixes #50 --- registrasion/reporting/views.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 5705d4f8..a350cb3d 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -591,6 +591,16 @@ def attendee_data(request, form, user_id=None): "cart", "cart__user", "product", "product__category", ).order_by("cart__status") + # Add invoice nag link + links = [] + links.append(( + reverse(views.nag_unpaid, args=[]) + "?" + request.META["QUERY_STRING"], + "Send invoice reminders", + )) + + 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 From 056008c6e7a177c349a86007331afba0897a82a1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 18:04:50 +1100 Subject: [PATCH 393/418] Credit notes can be applied to any invoice. Fixes #85 --- registrasion/forms.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 27f9cc14..d852a0d1 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -15,22 +15,39 @@ class ApplyCreditNoteForm(forms.Form): self.user = user super(ApplyCreditNoteForm, self).__init__(*a, **k) - self.fields["invoice"].choices = self._unpaid_invoices_for_user + self.fields["invoice"].choices = self._unpaid_invoices - def _unpaid_invoices_for_user(self): + def _unpaid_invoices(self): invoices = commerce.Invoice.objects.filter( status=commerce.Invoice.STATUS_UNPAID, - user=self.user, - ) + ).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, + }) + print invoice + + + key = lambda inv: (0 - (inv["user_id"] == self.user.id), inv["id"]) + invoices_annotated.sort(key=key) + + template = "Invoice %(id)d - user: %(user_email)s (%(user_id)d) - $%(value)d" return [ - (invoice.id, "Invoice %(id)d - $%(value)d" % invoice.__dict__) - for invoice in invoices + (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): From 221b4d6a22a279390663c6a2c0f901215cbd5eb7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 7 Dec 2016 21:00:47 +1100 Subject: [PATCH 394/418] quiet please --- registrasion/forms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index d852a0d1..7770e9be 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -29,7 +29,6 @@ class ApplyCreditNoteForm(forms.Form): "user_id": users[invoice["user_id"]].id, "user_email": users[invoice["user_id"]].email, }) - print invoice key = lambda inv: (0 - (inv["user_id"] == self.user.id), inv["id"]) From 06fe8a8ffa65b5e668e450bbaf987f34cc519d86 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 09:42:40 +1100 Subject: [PATCH 395/418] Adds preview function to nag_unpaid --- registrasion/forms.py | 15 +++++++++++++++ registrasion/views.py | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 7770e9be..ecb889fa 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -414,6 +414,15 @@ def staff_products_formset_factory(user): class InvoiceNagForm(forms.Form): + + ACTION_PREVIEW = 1 + ACTION_SEND = 2 + + ACTION_CHOICES = ( + (ACTION_PREVIEW, "Preview"), + (ACTION_SEND, "Send emails"), + ) + invoice = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, queryset=commerce.Invoice.objects.all(), @@ -423,6 +432,12 @@ class InvoiceNagForm(forms.Form): body = forms.CharField( widget=forms.Textarea, ) + action = forms.TypedChoiceField( + widget=forms.RadioSelect, + coerce=int, + choices=ACTION_CHOICES, + initial=ACTION_PREVIEW, + ) def __init__(self, *a, **k): category = k.pop('category', None) or [] diff --git a/registrasion/views.py b/registrasion/views.py index 5f57eb20..b3aae73d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -920,6 +920,11 @@ def extend_reservation(request, user_id, days=7): return redirect(request.META["HTTP_REFERER"]) +Email = namedtuple( + "Email", + ("subject", "body", "from_email", "recipient_list"), +) + @user_passes_test(_staff_only) def nag_unpaid(request): ''' Allows staff to nag users with unpaid invoices. ''' @@ -933,6 +938,8 @@ def nag_unpaid(request): product=product, ) + emails = [] + if form.is_valid(): emails = [] for invoice in form.cleaned_data["invoice"]: @@ -945,12 +952,16 @@ def nag_unpaid(request): }) ) recipient_list = [invoice.user.email] - emails.append((subject, body, from_email, recipient_list)) - send_mass_mail(emails) - messages.info(request, "The e-mails have been sent.") + emails.append(Email(subject, body, from_email, recipient_list)) + + if form.cleaned_data["action"] == forms.InvoiceNagForm.ACTION_SEND: + # Send e-mails *ONLY* if we're sending. + send_mass_mail(emails) + messages.info(request, "The e-mails have been sent.") data = { "form": form, + "emails": emails, } return render(request, "registrasion/nag_unpaid.html", data) From 479bdd36a3a2a118091b1f5faafd1adab1d3954d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 09:43:20 +1100 Subject: [PATCH 396/418] s/InvoiceNagForm/InvoiceEmailForm/ --- registrasion/forms.py | 2 +- registrasion/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index ecb889fa..1874e448 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -413,7 +413,7 @@ def staff_products_formset_factory(user): return forms.formset_factory(form_type) -class InvoiceNagForm(forms.Form): +class InvoiceEmailForm(forms.Form): ACTION_PREVIEW = 1 ACTION_SEND = 2 diff --git a/registrasion/views.py b/registrasion/views.py index b3aae73d..e30472db 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -932,7 +932,7 @@ def nag_unpaid(request): category = request.GET.getlist("category", []) product = request.GET.getlist("product", []) - form = forms.InvoiceNagForm( + form = forms.InvoiceEmailForm( request.POST or None, category=category, product=product, @@ -954,7 +954,7 @@ def nag_unpaid(request): recipient_list = [invoice.user.email] emails.append(Email(subject, body, from_email, recipient_list)) - if form.cleaned_data["action"] == forms.InvoiceNagForm.ACTION_SEND: + if form.cleaned_data["action"] == forms.InvoiceEmailForm.ACTION_SEND: # Send e-mails *ONLY* if we're sending. send_mass_mail(emails) messages.info(request, "The e-mails have been sent.") From 3b985d40ac0b9ab552e135765f6800c5e10fb715 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 09:47:54 +1100 Subject: [PATCH 397/418] Missing line --- registrasion/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 1874e448..5dee2da8 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -446,7 +446,7 @@ class InvoiceEmailForm(forms.Form): category = [int(i) for i in category] product = [int(i) for i in product] - super(InvoiceNagForm, self).__init__(*a, **k) + super(InvoiceEmailForm, self).__init__(*a, **k) qs = commerce.Invoice.objects.filter( status=commerce.Invoice.STATUS_UNPAID, From de902a213d3e5e7f83513b2ea334cc069f587713 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 09:48:48 +1100 Subject: [PATCH 398/418] Adds invoice status to nag_unpaid --- registrasion/forms.py | 3 ++- registrasion/views.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 5dee2da8..837c8e9f 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -442,6 +442,7 @@ class InvoiceEmailForm(forms.Form): 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] @@ -449,7 +450,7 @@ class InvoiceEmailForm(forms.Form): super(InvoiceEmailForm, self).__init__(*a, **k) qs = commerce.Invoice.objects.filter( - status=commerce.Invoice.STATUS_UNPAID, + status=status or commerce.Invoice.STATUS_UNPAID, ).filter( Q(lineitem__product__category__in=category) | Q(lineitem__product__in=product) diff --git a/registrasion/views.py b/registrasion/views.py index e30472db..815aa2e3 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -931,11 +931,13 @@ def nag_unpaid(request): category = request.GET.getlist("category", []) product = request.GET.getlist("product", []) + status = request.GET.get("status") form = forms.InvoiceEmailForm( request.POST or None, category=category, product=product, + status=status, ) emails = [] From 274187b8bf7e630ae752801173cc0535ab4bf16a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 09:52:49 +1100 Subject: [PATCH 399/418] =?UTF-8?q?Renames=20=E2=80=9Cnag=5Funpaid?= =?UTF-8?q?=E2=80=9D=20to=20=E2=80=9Cinvoice=5Fmailout=E2=80=9D,=20better?= =?UTF-8?q?=20matches=20current=20intent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/forms.py | 1 + registrasion/reporting/views.py | 9 +++++---- registrasion/urls.py | 4 ++-- registrasion/views.py | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 837c8e9f..25a54768 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -448,6 +448,7 @@ class InvoiceEmailForm(forms.Form): product = [int(i) for i in product] super(InvoiceEmailForm, self).__init__(*a, **k) + print status qs = commerce.Invoice.objects.filter( status=status or commerce.Invoice.STATUS_UNPAID, diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index a350cb3d..eac9fd80 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -593,10 +593,11 @@ def attendee_data(request, form, user_id=None): # Add invoice nag link links = [] - links.append(( - reverse(views.nag_unpaid, args=[]) + "?" + request.META["QUERY_STRING"], - "Send invoice reminders", - )) + invoice_mailout = reverse(views.invoice_mailout, args=[]) + "?" + 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)) diff --git a/registrasion/urls.py b/registrasion/urls.py index 2028d86b..8f2e1663 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -12,8 +12,8 @@ from .views import ( guided_registration, invoice, invoice_access, + invoice_mailout, manual_payment, - nag_unpaid, product_category, refund, review, @@ -35,7 +35,7 @@ public = [ refund, name="refund"), url(r"^invoice_access/([A-Z0-9]+)$", invoice_access, name="invoice_access"), - url(r"^nag_unpaid$", nag_unpaid, name="nag_unpaid"), + 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"), diff --git a/registrasion/views.py b/registrasion/views.py index 815aa2e3..f4a59eb0 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -926,8 +926,8 @@ Email = namedtuple( ) @user_passes_test(_staff_only) -def nag_unpaid(request): - ''' Allows staff to nag users with unpaid invoices. ''' +def invoice_mailout(request): + ''' Allows staff to send emails to users based on their invoice status. ''' category = request.GET.getlist("category", []) product = request.GET.getlist("product", []) From fd7fff7879b311b65d413af397dc10467fcda845 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 10:44:19 +1100 Subject: [PATCH 400/418] Allows contexts to directly supply a user (so we can access registration data when e-mailing people.) --- .../templatetags/registrasion_tags.py | 34 ++++++++++++++----- registrasion/views.py | 1 + 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 1f408ed1..02cda54e 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -9,6 +9,15 @@ from urllib 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. @@ -18,13 +27,13 @@ def available_categories(context): have Products that the current user can reserve. ''' - return CategoryController.available_categories(context.request.user) + 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 = context.request.user + user = user_for_context(context) categories_available = set(CategoryController.available_categories(user)) items = ItemController(user).items_pending_or_purchased() @@ -47,7 +56,7 @@ def available_credit(context): ''' notes = commerce.CreditNote.unclaimed().filter( - invoice__user=context.request.user, + invoice__user=user_for_context(context), ) ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0 return 0 - ret @@ -59,20 +68,29 @@ def invoices(context): Returns: [models.commerce.Invoice, ...]: All of the current user's invoices. ''' - return commerce.Invoice.objects.filter(user=context.request.user) + 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.''' - return ItemController(context.request.user).items_pending() + ''' 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. ''' + ''' Returns the items purchased for this user. - return ItemController(context.request.user).items_purchased( + 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 ) diff --git a/registrasion/views.py b/registrasion/views.py index f4a59eb0..e5ea9218 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -951,6 +951,7 @@ def invoice_mailout(request): body = Template(form.cleaned_data["body"]).render( Context({ "invoice" : invoice, + "user" : invoice.user, }) ) recipient_list = [invoice.user.email] From 42711dde6996d6757fe9a526fbab57840db1497c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 8 Jan 2017 10:45:08 +1100 Subject: [PATCH 401/418] Renames a tempalte. --- registrasion/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/views.py b/registrasion/views.py index e5ea9218..24e5ebf1 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -967,4 +967,4 @@ def invoice_mailout(request): "emails": emails, } - return render(request, "registrasion/nag_unpaid.html", data) + return render(request, "registrasion/invoice_mailout.html", data) From 9da41c06de54dc2dfd7ef41b44005b41432f774e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 9 Jan 2017 18:57:49 +1100 Subject: [PATCH 402/418] Adds first badge support --- registrasion/templatetags/registrasion_tags.py | 11 +++++++++++ registrasion/urls.py | 2 ++ registrasion/views.py | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 02cda54e..62315919 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -95,6 +95,17 @@ def items_purchased(context, category=None): ) +@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): diff --git a/registrasion/urls.py b/registrasion/urls.py index 8f2e1663..322ee62c 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -5,6 +5,7 @@ from django.conf.urls import url from .views import ( amend_registration, + badge, checkout, credit_note, edit_profile, @@ -22,6 +23,7 @@ from .views import ( public = [ url(r"^amend/([0-9]+)$", amend_registration, name="amend_registration"), + url(r"^badge/([0-9]+)$", badge, name="badge"), url(r"^category/([0-9]+)$", product_category, name="product_category"), url(r"^checkout$", checkout, name="checkout"), url(r"^checkout/([0-9]+)$", checkout, name="checkout"), diff --git a/registrasion/views.py b/registrasion/views.py index 24e5ebf1..83e5ed97 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -968,3 +968,21 @@ def invoice_mailout(request): } return render(request, "registrasion/invoice_mailout.html", data) + + +@user_passes_test(_staff_only) +def badge(request, user_id): + ''' Renders a single user's badge (SVG). ''' + + user_id = int(user_id) + + data = { + "user": User.objects.get(pk=user_id), + } + + print User.objects.get(pk=user_id) + + response = render(request, "registrasion/badge.svg", data) + response["Content-Type"] = "image/svg+xml" + response["Content-Disposition"] = 'inline; filename="badge.svg"' + return response From c949a87b8a0ee3d2ebdc7f687a085d0962235d67 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 9 Jan 2017 19:13:49 +1100 Subject: [PATCH 403/418] uses template.render rather than the render shortcut --- registrasion/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 83e5ed97..9e22668e 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -27,10 +27,10 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.core.mail import send_mass_mail -from django.http import Http404 +from django.http import Http404, HttpResponse from django.shortcuts import redirect from django.shortcuts import render -from django.template import Context, Template +from django.template import Context, Template, loader _GuidedRegistrationSection = namedtuple( @@ -980,9 +980,9 @@ def badge(request, user_id): "user": User.objects.get(pk=user_id), } - print User.objects.get(pk=user_id) + t = loader.get_template('registrasion/badge.svg') + response = HttpResponse(t.render(data, request)) - response = render(request, "registrasion/badge.svg", data) response["Content-Type"] = "image/svg+xml" response["Content-Disposition"] = 'inline; filename="badge.svg"' return response From 78714600bf70666b368066890f02085489912731 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 9 Jan 2017 19:16:30 +1100 Subject: [PATCH 404/418] Refactors badge to have render_badge too. --- registrasion/views.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 9e22668e..096c3f35 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -975,14 +975,22 @@ def badge(request, user_id): ''' Renders a single user's badge (SVG). ''' user_id = int(user_id) + user = User.objects.get(pk=user_id) - data = { - "user": User.objects.get(pk=user_id), - } - - t = loader.get_template('registrasion/badge.svg') - response = HttpResponse(t.render(data, request)) + rendered = render_badge(user) + response = HttpResponse(rendered) response["Content-Type"] = "image/svg+xml" response["Content-Disposition"] = 'inline; filename="badge.svg"' return response + + +def render_badge(user): + ''' Renders a single user's badge. ''' + + data = { + "user": user, + } + + t = loader.get_template('registrasion/badge.svg') + return t.render(data) From 4fc494783dd178bda6c912c0771480b8bff154e6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 9 Jan 2017 19:27:47 +1100 Subject: [PATCH 405/418] Refactors the email form into an InvoicesWithProductAndStatus form --- registrasion/forms.py | 47 +++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 25a54768..684f7550 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -413,31 +413,11 @@ def staff_products_formset_factory(user): return forms.formset_factory(form_type) -class InvoiceEmailForm(forms.Form): - - ACTION_PREVIEW = 1 - ACTION_SEND = 2 - - ACTION_CHOICES = ( - (ACTION_PREVIEW, "Preview"), - (ACTION_SEND, "Send emails"), - ) - +class InvoicesWithProductAndStatusForm(forms.Form): invoice = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, queryset=commerce.Invoice.objects.all(), ) - 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, - ) def __init__(self, *a, **k): category = k.pop('category', None) or [] @@ -447,7 +427,7 @@ class InvoiceEmailForm(forms.Form): category = [int(i) for i in category] product = [int(i) for i in product] - super(InvoiceEmailForm, self).__init__(*a, **k) + super(InvoicesWithProductAndStatusForm, self).__init__(*a, **k) print status qs = commerce.Invoice.objects.filter( @@ -464,3 +444,26 @@ class InvoiceEmailForm(forms.Form): self.fields['invoice'].queryset = qs self.fields['invoice'].initial = [i.id for i in qs] + + +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, + ) From 66dedfc101b1a2cc8f7424c0dddfbf7d3479e249 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 9 Jan 2017 19:39:27 +1100 Subject: [PATCH 406/418] =?UTF-8?q?Adds=20=E2=80=9Cbadges=E2=80=9D=20view,?= =?UTF-8?q?=20which=20lets=20us=20render=20multiple=20users=E2=80=99=20bad?= =?UTF-8?q?ges=20into=20a=20zipfile.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/forms.py | 2 ++ registrasion/urls.py | 2 ++ registrasion/views.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 684f7550..fb429b55 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -442,6 +442,8 @@ class InvoicesWithProductAndStatusForm(forms.Form): id__in=qs, ) + qs = qs.select_related("user__attendee__attendeeprofilebase") + self.fields['invoice'].queryset = qs self.fields['invoice'].initial = [i.id for i in qs] diff --git a/registrasion/urls.py b/registrasion/urls.py index 322ee62c..91195864 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -6,6 +6,7 @@ from django.conf.urls import url from .views import ( amend_registration, badge, + badges, checkout, credit_note, edit_profile, @@ -24,6 +25,7 @@ from .views import ( 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"), diff --git a/registrasion/views.py b/registrasion/views.py index 096c3f35..40fde1c9 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,6 +1,7 @@ import datetime import sys import util +import zipfile from registrasion import forms from registrasion import util @@ -985,6 +986,42 @@ def badge(request, user_id): return response +def badges(request): + ''' Either displays a form containing a list of users with badges to + render, or returns a .zip file containing their badges. ''' + + category = request.GET.getlist("category", []) + product = request.GET.getlist("product", []) + status = request.GET.get("status") + + form = forms.InvoicesWithProductAndStatusForm( + request.POST or None, + category=category, + product=product, + status=status, + ) + + if form.is_valid(): + response = HttpResponse() + response["Content-Type"] = "application.zip" + response["Content-Disposition"] = 'attachment; filename="badges.zip"' + + z = zipfile.ZipFile(response, "w") + + for invoice in form.cleaned_data["invoice"]: + user = invoice.user + badge = render_badge(user) + z.writestr("badge_%d.svg" % user.id, badge.encode("utf-8")) + + return response + + data = { + "form": form, + } + + return render(request, "registrasion/badges.html", data) + + def render_badge(user): ''' Renders a single user's badge. ''' From e956c4c6a027bac1b9601ec5719983a26add55e0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 15 Jan 2017 07:48:43 +1100 Subject: [PATCH 407/418] Adds manifests --- registrasion/reporting/views.py | 85 +++++++++++++++++++++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 86 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index eac9fd80..826f9b3e 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -765,3 +765,88 @@ def speaker_registrations(request, form): ) 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 = [ + "%d x %s" % (item.quantity, str(item.product)) for item in item_list + ] + 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() diff --git a/registrasion/urls.py b/registrasion/urls.py index 91195864..221a272a 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -54,6 +54,7 @@ reports = [ 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( From cb42289f4ccd95795fdbce948f139f24b04a258e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 15 Jan 2017 07:55:52 +1100 Subject: [PATCH 408/418] =?UTF-8?q?Adds=20=E2=80=9Cview=20badge=E2=80=9D?= =?UTF-8?q?=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/reporting/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 826f9b3e..b6f9fc32 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -451,6 +451,10 @@ def attendee(request, form, user_id=None): 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", From 8ebb371a67ee49981c88568ad45803ff9eff8d82 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 15 Jan 2017 12:16:03 +1100 Subject: [PATCH 409/418] Correct ordering of invoices --- registrasion/reporting/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index b6f9fc32..1c664927 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -386,7 +386,7 @@ def credit_notes(request, form): def invoices(request,form): ''' Shows all of the invoices in the system. ''' - invoices = commerce.Invoice.objects.all().order_by("status") + invoices = commerce.Invoice.objects.all().order_by("status", "id") return QuerysetReport( "Invoices", From be0d04c9c4b3535f79e4cb827548b84cc664d2d6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 15 Jan 2017 12:20:14 +1100 Subject: [PATCH 410/418] Correct ordering of badges --- registrasion/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index fb429b55..f10f4204 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -443,9 +443,10 @@ class InvoicesWithProductAndStatusForm(forms.Form): ) 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] + #self.fields['invoice'].initial = [i.id for i in qs] # UNDO THIS LATER class InvoiceEmailForm(InvoicesWithProductAndStatusForm): From 4456398735e14ab60d4959385db9c108a431cdef Mon Sep 17 00:00:00 2001 From: Sachi King Date: Wed, 5 Apr 2017 21:07:59 +1000 Subject: [PATCH 411/418] Price is not a relation and cannot select_related This field is ignored in 1.9, however in 1.10+ it is an error. As this is a no-op in 1.9, removal keeps functionality while extending compatability going forward. For full details please see Django Ticket 10414 at: https://code.djangoproject.com/ticket/10414 --- registrasion/controllers/cart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index c2249d77..8d67e5f0 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -445,7 +445,7 @@ class CartController(object): # Order the products such that the most expensive ones are # processed first. product_items = self.cart.productitem_set.all().select_related( - "product", "product__category", "product__price" + "product", "product__category" ).order_by("-product__price") products = [i.product for i in product_items] From bcb63fd1cd121e7e11c47f7dff94c56ea65794bf Mon Sep 17 00:00:00 2001 From: Sachi King Date: Mon, 17 Apr 2017 22:55:48 +1000 Subject: [PATCH 412/418] Import fixups - not relative --- registrasion/controllers/category.py | 2 +- registrasion/controllers/credit_note.py | 2 +- registrasion/controllers/invoice.py | 6 +++--- registrasion/controllers/item.py | 1 + registrasion/forms.py | 2 +- registrasion/models/__init__.py | 8 ++++---- registrasion/models/commerce.py | 2 +- registrasion/reporting/reports.py | 4 ++-- registrasion/reporting/views.py | 16 ++++++++-------- registrasion/templatetags/registrasion_tags.py | 2 +- registrasion/urls.py | 2 +- registrasion/views.py | 2 +- 12 files changed, 25 insertions(+), 24 deletions(-) diff --git a/registrasion/controllers/category.py b/registrasion/controllers/category.py index e1865404..2b2b18d1 100644 --- a/registrasion/controllers/category.py +++ b/registrasion/controllers/category.py @@ -26,7 +26,7 @@ class CategoryController(object): products, otherwise it'll do all. ''' # STOPGAP -- this needs to be elsewhere tbqh - from product import ProductController + from registrasion.controllers.product import ProductController if products is AllProducts: products = inventory.Product.objects.all().select_related( diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index e4784c8b..dea46127 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -4,7 +4,7 @@ from django.db import transaction from registrasion.models import commerce -from for_id import ForId +from registrasion.controllers.for_id import ForId class CreditNoteController(ForId, object): diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 7b35ea58..1c4b34a4 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -11,9 +11,9 @@ from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import people -from cart import CartController -from credit_note import CreditNoteController -from for_id import ForId +from registrasion.controllers.cart import CartController +from registrasion.controllers.credit_note import CreditNoteController +from registrasion.controllers.for_id import ForId class InvoiceController(ForId, object): diff --git a/registrasion/controllers/item.py b/registrasion/controllers/item.py index bff4aa3b..29345831 100644 --- a/registrasion/controllers/item.py +++ b/registrasion/controllers/item.py @@ -1,6 +1,7 @@ ''' NEEDS TESTS ''' import operator +from functools import reduce from registrasion.models import commerce from registrasion.models import inventory diff --git a/registrasion/forms.py b/registrasion/forms.py index f10f4204..7706ee78 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -428,7 +428,7 @@ class InvoicesWithProductAndStatusForm(forms.Form): product = [int(i) for i in product] super(InvoicesWithProductAndStatusForm, self).__init__(*a, **k) - print status + print(status) qs = commerce.Invoice.objects.filter( status=status or commerce.Invoice.STATUS_UNPAID, diff --git a/registrasion/models/__init__.py b/registrasion/models/__init__.py index 944229f4..47efa127 100644 --- a/registrasion/models/__init__.py +++ b/registrasion/models/__init__.py @@ -1,4 +1,4 @@ -from commerce import * # NOQA -from conditions import * # NOQA -from inventory import * # NOQA -from people import * # NOQA +from registrasion.models.commerce import * # NOQA +from registrasion.models.conditions import * # NOQA +from registrasion.models.inventory import * # NOQA +from registrasion.models.people import * # NOQA diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 72ece2c8..a392c96b 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -324,7 +324,7 @@ class CreditNote(PaymentBase): elif hasattr(self, 'creditnoterefund'): reference = self.creditnoterefund.reference - print reference + print(reference) return "Refunded with reference: %s" % reference raise ValueError("This should never happen.") diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 00861072..fe8fbdc7 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -1,5 +1,5 @@ import csv -import forms +import registrasion.reporting.forms from django.contrib.auth.decorators import user_passes_test from django.shortcuts import render @@ -178,7 +178,7 @@ class Links(Report): return [] def rows(self, content_type): - print self._links + print(self._links) for url, link_text in self._links: yield [ self._linked_text(content_type, url, link_text) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 1c664927..d3a491e1 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -1,4 +1,4 @@ -import forms +from registrasion.reporting import forms import collections import datetime @@ -24,11 +24,11 @@ from registrasion import views from symposion.schedule import models as schedule_models -from reports import get_all_reports -from reports import Links -from reports import ListReport -from reports import QuerysetReport -from reports import report_view +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(): @@ -95,7 +95,7 @@ def items_sold(): total_quantity=Sum("quantity"), ) - print line_items + print(line_items) headings = ["Description", "Quantity", "Price", "Total"] @@ -414,7 +414,7 @@ def attendee(request, form, user_id=None): if user_id is None: return attendee_list(request) - print user_id + print(user_id) attendee = people.Attendee.objects.get(user__id=user_id) name = attendee.attendeeprofilebase.attendee_name() diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index 62315919..7108ae95 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -4,7 +4,7 @@ from registrasion.controllers.item import ItemController from django import template from django.db.models import Sum -from urllib import urlencode +from urllib.parse import urlencode register = template.Library() diff --git a/registrasion/urls.py b/registrasion/urls.py index 221a272a..0789912d 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,4 @@ -from reporting import views as rv +from registrasion.reporting import views as rv from django.conf.urls import include from django.conf.urls import url diff --git a/registrasion/views.py b/registrasion/views.py index 40fde1c9..77c438fd 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,6 +1,6 @@ import datetime import sys -import util +from registrasion import util import zipfile from registrasion import forms From 17693754de695152dc674ca68c8a1467e16aa3e4 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 22 Apr 2017 18:39:07 +1000 Subject: [PATCH 413/418] Huge batch of pep8 fixes --- registrasion/admin.py | 1 + registrasion/controllers/cart.py | 1 - registrasion/controllers/credit_note.py | 6 ++- registrasion/controllers/invoice.py | 1 - registrasion/forms.py | 10 +++-- registrasion/reporting/forms.py | 1 - registrasion/reporting/reports.py | 1 - registrasion/reporting/views.py | 51 +++++++++++++++---------- registrasion/tests/test_batch.py | 2 +- registrasion/tests/test_cart.py | 9 ++--- registrasion/tests/test_ceilings.py | 26 ++++++------- registrasion/tests/test_credit_note.py | 28 ++++++-------- registrasion/tests/test_discount.py | 4 +- registrasion/tests/test_flag.py | 6 +-- registrasion/tests/test_group_member.py | 7 +--- registrasion/tests/test_helpers.py | 9 +++-- registrasion/tests/test_invoice.py | 14 +++---- registrasion/tests/test_refund.py | 6 +-- registrasion/tests/test_speaker.py | 10 +---- registrasion/tests/test_voucher.py | 8 ++-- registrasion/views.py | 15 ++++---- 21 files changed, 105 insertions(+), 111 deletions(-) diff --git a/registrasion/admin.py b/registrasion/admin.py index 5fc62067..3967b096 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -82,6 +82,7 @@ class IncludedProductDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): DiscountForCategoryInline, ] + @admin.register(conditions.SpeakerDiscount) class SpeakerDiscountAdmin(admin.ModelAdmin, EffectsDisplayMixin): diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 8d67e5f0..992a57ac 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -165,7 +165,6 @@ class CartController(object): 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( diff --git a/registrasion/controllers/credit_note.py b/registrasion/controllers/credit_note.py index dea46127..500d0ad7 100644 --- a/registrasion/controllers/credit_note.py +++ b/registrasion/controllers/credit_note.py @@ -40,7 +40,8 @@ class CreditNoteController(ForId, object): paid. ''' - from invoice import InvoiceController # Circular imports bleh. + # Circular Import + from registrasion.controllers.invoice import InvoiceController inv = InvoiceController(invoice) inv.validate_allowed_to_pay() @@ -64,7 +65,8 @@ class CreditNoteController(ForId, object): a cancellation fee. Must be 0 <= percentage <= 100. ''' - from invoice import InvoiceController # Circular imports bleh. + # Circular Import + from registrasion.controllers.invoice import InvoiceController assert(percentage >= 0 and percentage <= 100) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 1c4b34a4..706db7f9 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -2,7 +2,6 @@ from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.db import transaction -from django.db.models import Sum from django.utils import timezone from registrasion.contrib.mail import send_email diff --git a/registrasion/forms.py b/registrasion/forms.py index 7706ee78..60bab359 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,7 +3,6 @@ from registrasion.models import commerce from registrasion.models import inventory from django import forms -from django.core.exceptions import ValidationError from django.db.models import Q @@ -31,10 +30,11 @@ class ApplyCreditNoteForm(forms.Form): }) - key = lambda inv: (0 - (inv["user_id"] == self.user.id), inv["id"]) + 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" + template = ('Invoice %(id)d - user: %(user_email)s (%(user_id)d) ' + '- $%(value)d') return [ (invoice["id"], template % invoice) for invoice in invoices_annotated @@ -57,6 +57,7 @@ class CancellationFeeForm(forms.Form): max_value=100, ) + class ManualCreditNoteRefundForm(forms.ModelForm): class Meta: @@ -407,6 +408,7 @@ def staff_products_form_factory(user): return StaffProductsForm + def staff_products_formset_factory(user): ''' Creates a formset of StaffProductsForm for the given user. ''' form_type = staff_products_form_factory(user) @@ -446,7 +448,7 @@ class InvoicesWithProductAndStatusForm(forms.Form): qs = qs.order_by("id") self.fields['invoice'].queryset = qs - #self.fields['invoice'].initial = [i.id for i in qs] # UNDO THIS LATER + # self.fields['invoice'].initial = [i.id for i in qs] # UNDO THIS LATER class InvoiceEmailForm(InvoicesWithProductAndStatusForm): diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index e9f8cf0a..1254ee76 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -63,7 +63,6 @@ class GroupByForm(forms.Form): ) - def model_fields_form_factory(model): ''' Creates a form for specifying fields from a model to display. ''' diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index fe8fbdc7..eabc4250 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -1,5 +1,4 @@ import csv -import registrasion.reporting.forms from django.contrib.auth.decorators import user_passes_test from django.shortcuts import render diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index d3a491e1..239c174a 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -163,8 +163,8 @@ def sales_payment_summary(): data.append([ "Credit notes - (claimed credit notes + unclaimed credit notes)", all_credit_notes - claimed_credit_notes - - refunded_credit_notes - unclaimed_credit_notes, - ]) + refunded_credit_notes - unclaimed_credit_notes + ]) return ListReport("Sales and Payments Summary", headings, data) @@ -291,8 +291,8 @@ def discount_status(request, form): items = group_by_cart_status( items, - ["discount",], - ["discount", "discount__description",], + ["discount"], + ["discount", "discount__description"], ) headings = [ @@ -362,6 +362,7 @@ def paid_invoices_by_date(request, form): data, ) + @report_view("Credit notes") def credit_notes(request, form): ''' Shows all of the credit notes in the system. ''' @@ -375,7 +376,9 @@ def credit_notes(request, form): return QuerysetReport( "Credit Notes", - ["id", "invoice__user__attendee__attendeeprofilebase__invoice_recipient", "status", "value"], # NOQA + ["id", + "invoice__user__attendee__attendeeprofilebase__invoice_recipient", + "status", "value"], notes, headings=["id", "Owner", "Status", "Value"], link_view=views.credit_note, @@ -383,7 +386,7 @@ def credit_notes(request, form): @report_view("Invoices") -def invoices(request,form): +def invoices(request, form): ''' Shows all of the invoices in the system. ''' invoices = commerce.Invoice.objects.all().order_by("status", "id") @@ -562,6 +565,7 @@ def attendee_list(request): ProfileForm = forms.model_fields_form_factory(AttendeeProfile) + @report_view( "Attendees By Product/Category", form_type=forms.mix_form( @@ -580,7 +584,8 @@ def attendee_data(request, form, user_id=None): output = [] - by_category = form.cleaned_data["group_by"] == forms.GroupByForm.GROUP_BY_CATEGORY + by_category = ( + form.cleaned_data["group_by"] == forms.GroupByForm.GROUP_BY_CATEGORY) products = form.cleaned_data["product"] categories = form.cleaned_data["category"] @@ -597,7 +602,8 @@ def attendee_data(request, form, user_id=None): # Add invoice nag link links = [] - invoice_mailout = reverse(views.invoice_mailout, args=[]) + "?" + request.META["QUERY_STRING"] + 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",), @@ -621,7 +627,7 @@ def attendee_data(request, form, user_id=None): by_user[profile.attendee.user] = profile cart = "attendee__user__cart" - cart_status = cart + "__status" + cart_status = cart + "__status" # noqa product = cart + "__productitem__product" product_name = product + "__name" category = product + "__category" @@ -631,12 +637,12 @@ def attendee_data(request, form, user_id=None): grouping_fields = (category, category_name) order_by = (category, ) first_column = "Category" - group_name = lambda i: "%s" % (i[category_name], ) + 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]) + group_name = lambda i: "%s - %s" % (i[category_name], i[product_name]) # noqa # Group the responses per-field. for field in fields: @@ -663,7 +669,7 @@ def attendee_data(request, form, user_id=None): def display_field(value): return value - status_count = lambda status: Case(When( + status_count = lambda status: Case(When( # noqa attendee__user__cart__status=status, then=Value(1), ), @@ -710,7 +716,8 @@ def attendee_data(request, form, user_id=None): else: return attr - headings = ["User ID", "Name", "Email", "Product", "Item Status"] + field_names + headings = ["User ID", "Name", "Email", "Product", "Item Status"] + headings.extend(field_names) data = [] for item in items: profile = by_user[item.cart.user] @@ -726,7 +733,8 @@ def attendee_data(request, form, user_id=None): data.append(line) output.append(AttendeeListReport( - "Attendees by item with profile data", headings, data, link_view=attendee + "Attendees by item with profile data", headings, data, + link_view=attendee )) return output @@ -763,7 +771,7 @@ def speaker_registrations(request, form): return QuerysetReport( "Speaker Registration Status", - ["id", "speaker_profile__name", "email", "paid_carts",], + ["id", "speaker_profile__name", "email", "paid_carts"], users, link_view=attendee, ) @@ -776,7 +784,10 @@ def speaker_registrations(request, form): forms.ProductAndCategoryForm, ) def manifest(request, form): - ''' Produces the registration manifest for people with the given product type.''' + ''' + Produces the registration manifest for people with the given product + type. + ''' products = form.cleaned_data["product"] categories = form.cleaned_data["category"] @@ -835,9 +846,9 @@ def manifest(request, form): headings = ["User ID", "Name", "Paid", "Unpaid", "Refunded"] def format_items(item_list): - strings = [ - "%d x %s" % (item.quantity, str(item.product)) for item in item_list - ] + strings = [] + for item in item_list: + strings.append('%d x %s' % (item.quantity, str(item.product))) return ", \n".join(strings) output = [] @@ -853,4 +864,4 @@ def manifest(request, form): return ListReport("Manifest", headings, output) - #attendeeprofilebase.attendee_name() + # attendeeprofilebase.attendee_name() diff --git a/registrasion/tests/test_batch.py b/registrasion/tests/test_batch.py index aa11f113..331f8838 100644 --- a/registrasion/tests/test_batch.py +++ b/registrasion/tests/test_batch.py @@ -1,6 +1,6 @@ import pytz -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase from registrasion.controllers.batch import BatchController diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index ecd0e72d..825ff675 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -15,8 +15,8 @@ from registrasion.models import people from registrasion.controllers.batch import BatchController from registrasion.controllers.product import ProductController -from controller_helpers import TestingCartController -from patches import MixInPatches +from registrasion.tests.controller_helpers import TestingCartController +from registrasion.tests.patches import MixInPatches UTC = pytz.timezone('UTC') @@ -67,7 +67,7 @@ class RegistrationCartTestCase(MixInPatches, TestCase): cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] - for i in xrange(2): + for i in range(2): cat = inventory.Category.objects.create( name="Category " + str(i + 1), description="This is a test category", @@ -81,7 +81,7 @@ class RegistrationCartTestCase(MixInPatches, TestCase): cls.CAT_2 = cls.categories[1] cls.products = [] - for i in xrange(4): + for i in range(4): prod = inventory.Product.objects.create( name="Product " + str(i + 1), description="This is a test product.", @@ -460,7 +460,6 @@ class BasicCartTests(RegistrationCartTestCase): 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 ''' diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index 87b54946..7fc69a41 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -3,8 +3,8 @@ import pytz from django.core.exceptions import ValidationError -from controller_helpers import TestingCartController -from test_cart import RegistrationCartTestCase +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 @@ -47,36 +47,36 @@ class CeilingsTestCases(RegistrationCartTestCase): def test_add_to_cart_ceiling_date_range(self): self.make_ceiling( "date range ceiling", - start_time=datetime.datetime(2015, 01, 01, tzinfo=UTC), - end_time=datetime.datetime(2015, 02, 01, tzinfo=UTC)) + 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, 01, 01, tzinfo=UTC)) + 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, 01, 01, tzinfo=UTC)) + 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, 01, 15, tzinfo=UTC)) + 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, 02, 01, tzinfo=UTC)) + 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, 01, 01, minute=01, tzinfo=UTC)) + 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, 01, 01, tzinfo=UTC)) + 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) @@ -113,7 +113,7 @@ class CeilingsTestCases(RegistrationCartTestCase): self.__validation_test() def __validation_test(self): - self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + 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) @@ -144,7 +144,7 @@ class CeilingsTestCases(RegistrationCartTestCase): "Multi-product limit discount ceiling", limit=2, ) - for i in xrange(2): + for i in range(2): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.next_cart() @@ -162,7 +162,7 @@ class CeilingsTestCases(RegistrationCartTestCase): # after second. self.make_ceiling("Multi-product limit ceiling", limit=2) - for i in xrange(2): + for i in range(2): cart = TestingCartController.for_user(self.USER_1) cart.add_to_cart(self.PROD_1, 1) cart.next_cart() diff --git a/registrasion/tests/test_credit_note.py b/registrasion/tests/test_credit_note.py index 5cd43af2..76d0fc38 100644 --- a/registrasion/tests/test_credit_note.py +++ b/registrasion/tests/test_credit_note.py @@ -1,18 +1,15 @@ 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 controller_helpers import TestingCartController -from controller_helpers import TestingCreditNoteController -from controller_helpers import TestingInvoiceController -from test_helpers import TestHelperMixin +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 -from test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') @@ -142,7 +139,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): invoice.refund() # There should be one credit note generated out of the invoice. - cn = self._credit_note_for_invoice(invoice.invoice) + cn = self._credit_note_for_invoice(invoice.invoice) # noqa self.assertEquals(1, commerce.CreditNote.unclaimed().count()) @@ -324,7 +321,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): # 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) + invoice2 = TestingInvoiceController.for_cart(cart.cart) # noqa cn2 = self._credit_note_for_invoice(invoice.invoice) invoice._refresh() @@ -387,7 +384,6 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): 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) @@ -403,14 +399,14 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): ''' Tests that excess credit notes are untouched if they're not needed ''' - notes_value = self._generate_multiple_credit_notes() + 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) + invoice = self._manual_invoice(1) # noqa notes_new = commerce.CreditNote.unclaimed().filter( invoice__user=self.USER_1 ) @@ -422,7 +418,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): 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) + invoice1 = self._invoice_containing_prod_1(1) # noqa # Create some credit notes. self._generate_multiple_credit_notes() @@ -435,7 +431,7 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): def test_credit_notes_are_applied_even_if_some_notes_are_claimed(self): - for i in xrange(10): + for i in range(10): # Generate credit note invoice1 = self._manual_invoice(1) invoice1.pay("Pay", invoice1.invoice.value) @@ -467,5 +463,5 @@ class CreditNoteTestCase(TestHelperMixin, RegistrationCartTestCase): def test_cancellation_fee_is_applied_when_another_invoice_is_unpaid(self): - extra_invoice = self._manual_invoice(23) + extra_invoice = self._manual_invoice(23) # noqa self.test_cancellation_fee_is_applied() diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index 2696535b..06d5c63d 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -5,9 +5,9 @@ from decimal import Decimal from registrasion.models import commerce from registrasion.models import conditions from registrasion.controllers.discount import DiscountController -from controller_helpers import TestingCartController +from registrasion.tests.controller_helpers import TestingCartController -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') diff --git a/registrasion/tests/test_flag.py b/registrasion/tests/test_flag.py index 0b446c26..f9b22029 100644 --- a/registrasion/tests/test_flag.py +++ b/registrasion/tests/test_flag.py @@ -5,11 +5,11 @@ from django.core.exceptions import ValidationError from registrasion.models import commerce from registrasion.models import conditions from registrasion.controllers.category import CategoryController -from controller_helpers import TestingCartController -from controller_helpers import TestingInvoiceController +from registrasion.tests.controller_helpers import TestingCartController +from registrasion.tests.controller_helpers import TestingInvoiceController from registrasion.controllers.product import ProductController -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') diff --git a/registrasion/tests/test_group_member.py b/registrasion/tests/test_group_member.py index 96e1d58a..ee47797b 100644 --- a/registrasion/tests/test_group_member.py +++ b/registrasion/tests/test_group_member.py @@ -1,16 +1,11 @@ import pytz from django.contrib.auth.models import Group -from django.core.exceptions import ValidationError -from registrasion.models import commerce from registrasion.models import conditions -from registrasion.controllers.category import CategoryController -from controller_helpers import TestingCartController -from controller_helpers import TestingInvoiceController from registrasion.controllers.product import ProductController -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') diff --git a/registrasion/tests/test_helpers.py b/registrasion/tests/test_helpers.py index c656a2d0..4dad009e 100644 --- a/registrasion/tests/test_helpers.py +++ b/registrasion/tests/test_helpers.py @@ -2,9 +2,10 @@ import datetime from registrasion.models import commerce -from controller_helpers import TestingCartController -from controller_helpers import TestingCreditNoteController -from controller_helpers import TestingInvoiceController +from registrasion.tests.controller_helpers import TestingCartController +from registrasion.tests.controller_helpers import TestingCreditNoteController +from registrasion.tests.controller_helpers import TestingInvoiceController + class TestHelperMixin(object): @@ -18,7 +19,7 @@ class TestHelperMixin(object): 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): diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 61e1abc9..b77fa223 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -7,12 +7,11 @@ from django.core.exceptions import ValidationError from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import inventory -from controller_helpers import TestingCartController -from controller_helpers import TestingCreditNoteController -from controller_helpers import TestingInvoiceController -from test_helpers import TestHelperMixin +from registrasion.tests.controller_helpers import TestingCartController +from registrasion.tests.controller_helpers import TestingInvoiceController +from registrasion.tests.test_helpers import TestHelperMixin -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') @@ -67,7 +66,7 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): def test_create_invoice_fails_if_cart_invalid(self): self.make_ceiling("Limit ceiling", limit=1) - self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + 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) @@ -166,7 +165,6 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): 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) @@ -220,7 +218,7 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): # generate an invoice self.add_timedelta(self.RESERVATION * 2) cart2.add_to_cart(self.PROD_2, 1) - inv2 = TestingInvoiceController.for_cart(cart2.cart) + TestingInvoiceController.for_cart(cart2.cart) # Re-get inv1's invoice; it should void itself on loading. inv1 = TestingInvoiceController(inv1.invoice) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index dde1fa30..762fb3cd 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,9 +1,9 @@ import pytz -from controller_helpers import TestingCartController -from controller_helpers import TestingInvoiceController +from registrasion.tests.controller_helpers import TestingCartController +from registrasion.tests.controller_helpers import TestingInvoiceController -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase from registrasion.models import commerce diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py index 5fe80d76..cf64074e 100644 --- a/registrasion/tests/test_speaker.py +++ b/registrasion/tests/test_speaker.py @@ -1,12 +1,6 @@ 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 controller_helpers import TestingCartController -from controller_helpers import TestingInvoiceController from registrasion.controllers.product import ProductController from symposion.conference import models as conference_models @@ -16,7 +10,7 @@ from symposion.schedule import models as schedule_models from symposion.speakers import models as speaker_models -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') @@ -41,7 +35,7 @@ class SpeakerTestCase(RegistrationCartTestCase): name="TEST_SECTION", slug="testsection", ) - proposal_section = proposal_models.ProposalSection.objects.create( + proposal_section = proposal_models.ProposalSection.objects.create( # noqa section=section, closed=False, published=False, diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index f837a480..9db2d96f 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -8,10 +8,10 @@ from django.db import transaction from registrasion.models import conditions from registrasion.models import inventory -from controller_helpers import TestingCartController -from controller_helpers import TestingInvoiceController +from registrasion.tests.controller_helpers import TestingCartController +from registrasion.tests.controller_helpers import TestingInvoiceController -from test_cart import RegistrationCartTestCase +from registrasion.tests.test_cart import RegistrationCartTestCase UTC = pytz.timezone('UTC') @@ -21,7 +21,7 @@ class VoucherTestCases(RegistrationCartTestCase): def test_apply_voucher(self): voucher = self.new_voucher() - self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) + self.set_time(datetime.datetime(2015, 1, 1, tzinfo=UTC)) cart_1 = TestingCartController.for_user(self.USER_1) cart_1.apply_voucher(voucher.code) diff --git a/registrasion/views.py b/registrasion/views.py index 77c438fd..239e924b 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,6 +1,4 @@ import datetime -import sys -from registrasion import util import zipfile from registrasion import forms @@ -926,13 +924,14 @@ Email = namedtuple( ("subject", "body", "from_email", "recipient_list"), ) + @user_passes_test(_staff_only) def invoice_mailout(request): ''' Allows staff to send emails to users based on their invoice status. ''' category = request.GET.getlist("category", []) - product = request.GET.getlist("product", []) - status = request.GET.get("status") + product = request.GET.getlist("product", []) + status = request.GET.get("status") form = forms.InvoiceEmailForm( request.POST or None, @@ -951,8 +950,8 @@ def invoice_mailout(request): subject = form.cleaned_data["subject"] body = Template(form.cleaned_data["body"]).render( Context({ - "invoice" : invoice, - "user" : invoice.user, + "invoice": invoice, + "user": invoice.user, }) ) recipient_list = [invoice.user.email] @@ -991,8 +990,8 @@ def badges(request): render, or returns a .zip file containing their badges. ''' category = request.GET.getlist("category", []) - product = request.GET.getlist("product", []) - status = request.GET.get("status") + product = request.GET.getlist("product", []) + status = request.GET.get("status") form = forms.InvoicesWithProductAndStatusForm( request.POST or None, From a2464bd95ea851b5b7e8f4832aad172e2099295e Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 22 Apr 2017 18:40:40 +1000 Subject: [PATCH 414/418] ve is scoped to the except block. We probably want to see a whole bunch of errors collected in errors anyways. That should get converted to a string uppon being raised, so pass errors directly. --- registrasion/controllers/cart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 992a57ac..bb772ce0 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -318,7 +318,7 @@ class CartController(object): errors.append(ve) if errors: - raise(ValidationError(ve)) + raise(ValidationError(errors)) def _test_required_categories(self): ''' Makes sure that the owner of this cart has satisfied all of the From b156be1e7ed458d81589f560d37cdc00064f9b4c Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 22 Apr 2017 18:43:13 +1000 Subject: [PATCH 415/418] Python 3 fixes --- registrasion/reporting/reports.py | 5 ++--- registrasion/util.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index eabc4250..a9684802 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -299,10 +299,9 @@ class ReportView(object): response = HttpResponse(content_type='text/csv') writer = csv.writer(response) - encode = lambda i: i.encode("utf8") if isinstance(i, unicode) else i - writer.writerow(list(encode(i) for i in report.headings())) + writer.writerow(report.headings()) for row in report.rows(): - writer.writerow(list(encode(i) for i in row)) + writer.writerow(row) return response diff --git a/registrasion/util.py b/registrasion/util.py index b5fa0620..98079c7e 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -12,7 +12,7 @@ def generate_access_code(): length = 6 # all upper-case letters + digits 1-9 (no 0 vs O confusion) - chars = string.uppercase + string.digits[1:] + 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) From 03c76331692b250081f71ede712cd19afdcfc4d8 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 22 Apr 2017 18:43:50 +1000 Subject: [PATCH 416/418] Test fixes There are a number of attempts to use Deci in ints, which won't work in 2.7 or 3.x, we fix that. Description doesn't exist in symposion. So that fails our tests pretty hard. Switch that to Private Abstract. It's clear these tests have not been run in a very long time. So both failures and especially passes need to be taken with salt. --- registrasion/tests/test_cart.py | 2 +- registrasion/tests/test_invoice.py | 6 +++++- registrasion/tests/test_speaker.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 825ff675..d8f5b4c0 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -85,7 +85,7 @@ class RegistrationCartTestCase(MixInPatches, TestCase): prod = inventory.Product.objects.create( name="Product " + str(i + 1), description="This is a test product.", - category=cls.categories[i / 2], # 2 products per category + category=cls.categories[int(i / 2)], # 2 products per category price=Decimal("10.00"), reservation_duration=cls.RESERVATION, limit_per_user=10, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index b77fa223..0c650dd4 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -98,7 +98,11 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): def test_total_payments_balance_due(self): invoice = self._invoice_containing_prod_1(2) - for i in xrange(0, invoice.invoice.value): + # 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() ) diff --git a/registrasion/tests/test_speaker.py b/registrasion/tests/test_speaker.py index cf64074e..f31266b6 100644 --- a/registrasion/tests/test_speaker.py +++ b/registrasion/tests/test_speaker.py @@ -67,7 +67,7 @@ class SpeakerTestCase(RegistrationCartTestCase): kind=kind_1, title="Proposal 1", abstract="Abstract", - description="Description", + private_abstract="Private Abstract", speaker=speaker_1, ) proposal_models.AdditionalSpeaker.objects.create( @@ -80,7 +80,7 @@ class SpeakerTestCase(RegistrationCartTestCase): kind=kind_2, title="Proposal 2", abstract="Abstract", - description="Description", + private_abstract="Private Abstract", speaker=speaker_1, ) proposal_models.AdditionalSpeaker.objects.create( From 189abf3e23bf086ef84a36f5f4230878bf6e0856 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Mon, 24 Apr 2017 23:05:45 +1000 Subject: [PATCH 417/418] Add a CSS class on required fields labels This makes it possible to add a ' *' required notifier to labels without needing a bunch of custom form code in templates. --- registrasion/forms.py | 19 +++++++++++++++++++ registrasion/reporting/forms.py | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index 60bab359..fec2ec79 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -8,6 +8,8 @@ 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. ''' @@ -51,6 +53,8 @@ class ApplyCreditNoteForm(forms.Form): class CancellationFeeForm(forms.Form): + required_css_class = 'label-required' + percentage = forms.DecimalField( required=True, min_value=0, @@ -60,6 +64,8 @@ class CancellationFeeForm(forms.Form): class ManualCreditNoteRefundForm(forms.ModelForm): + required_css_class = 'label-required' + class Meta: model = commerce.ManualCreditNoteRefund fields = ["reference"] @@ -67,6 +73,8 @@ class ManualCreditNoteRefundForm(forms.ModelForm): class ManualPaymentForm(forms.ModelForm): + required_css_class = 'label-required' + class Meta: model = commerce.ManualPayment fields = ["reference", "amount"] @@ -150,6 +158,9 @@ class _HasProductsFields(object): class _ProductsForm(_HasProductsFields, forms.Form): + + required_css_class = 'label-required' + pass @@ -312,6 +323,8 @@ class _ItemQuantityProductsForm(_ProductsForm): 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") @@ -377,6 +390,9 @@ class _ItemQuantityProductsFormSet(_HasProductsFields, forms.BaseFormSet): 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", @@ -416,6 +432,9 @@ def staff_products_formset_factory(user): class InvoicesWithProductAndStatusForm(forms.Form): + + required_css_class = 'label-required' + invoice = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, queryset=commerce.Invoice.objects.all(), diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 1254ee76..53bc1956 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -16,6 +16,9 @@ def mix_form(*a): class DiscountForm(forms.Form): + + required_css_class = 'label-required' + discount = forms.ModelMultipleChoiceField( queryset=conditions.DiscountBase.objects.all(), required=False, @@ -23,6 +26,9 @@ class DiscountForm(forms.Form): class ProductAndCategoryForm(forms.Form): + + required_css_class = 'label-required' + product = forms.ModelMultipleChoiceField( queryset=inventory.Product.objects.select_related("category"), required=False, @@ -34,6 +40,9 @@ class ProductAndCategoryForm(forms.Form): class UserIdForm(forms.Form): + + required_css_class = 'label-required' + user = forms.IntegerField( label="User ID", required=False, @@ -41,6 +50,9 @@ class UserIdForm(forms.Form): class ProposalKindForm(forms.Form): + + required_css_class = 'label-required' + kind = forms.ModelMultipleChoiceField( queryset=proposals_models.ProposalKind.objects.all(), required=False, @@ -48,6 +60,9 @@ class ProposalKindForm(forms.Form): class GroupByForm(forms.Form): + + required_css_class = 'label-required' + GROUP_BY_CATEGORY = "category" GROUP_BY_PRODUCT = "product" From ed6c666cba3d259c8f8f23d82c2b2450ef4d1b15 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 27 May 2017 20:56:21 +1000 Subject: [PATCH 418/418] Prepare to Vendor --- .gitignore | 64 ------- CONTRIBUTING.rst | 39 ----- README.rst | 32 ---- design/design.md | 298 -------------------------------- design/goals.md | 55 ------ docs/Makefile | 231 ------------------------- docs/conf.py | 297 ------------------------------- docs/django_settings.py | 122 ------------- docs/for-zookeepr-users.rst | 22 --- docs/index.rst | 35 ---- docs/integration.rst | 67 ------- docs/inventory.rst | 158 ----------------- docs/make.bat | 281 ------------------------------ docs/overview.rst | 51 ------ docs/payments.rst | 165 ------------------ docs/views.rst | 41 ----- LICENSE => registrasion/LICENSE | 0 requirements/base.txt | 2 - requirements/dependencies.txt | 1 - requirements/docs.txt | 4 - requirements/extern.txt | 4 - setup.cfg | 2 - setup.py | 37 ---- 23 files changed, 2008 deletions(-) delete mode 100644 .gitignore delete mode 100644 CONTRIBUTING.rst delete mode 100644 README.rst delete mode 100644 design/design.md delete mode 100644 design/goals.md delete mode 100644 docs/Makefile delete mode 100644 docs/conf.py delete mode 100644 docs/django_settings.py delete mode 100644 docs/for-zookeepr-users.rst delete mode 100644 docs/index.rst delete mode 100644 docs/integration.rst delete mode 100644 docs/inventory.rst delete mode 100644 docs/make.bat delete mode 100644 docs/overview.rst delete mode 100644 docs/payments.rst delete mode 100644 docs/views.rst rename LICENSE => registrasion/LICENSE (100%) delete mode 100644 requirements/base.txt delete mode 100644 requirements/dependencies.txt delete mode 100644 requirements/docs.txt delete mode 100644 requirements/extern.txt delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 629c0e69..00000000 --- a/.gitignore +++ /dev/null @@ -1,64 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Grumble OSX Grumble - -.DS_Store -*/.DS_Store diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index dc3621bd..00000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,39 +0,0 @@ -Contributing to Registrasion -============================ - -I'm glad that you're interested in helping make Registrasion better! Thanks! This guide is meant to help make sure your contributions to the project fit in well. - -Making a contribution ---------------------- - -This project makes use of GitHub issues to track pending work, so new features that need implementation can be found there. If you think a new feature needs to be added, raise an issue for discussion before submitting a Pull Request. - - -Code Style ----------- - -We use PEP8. Your code should pass checks by ``flake8`` with no errors or warnings before it will be merged. - -We use `Google-style docstrings `_, primarily because they're far far more readable than ReST docstrings. New functions should have complete docstrings, so that new contributors have a chance of understanding how the API works. - - -Structure ---------- - -Django Models live in ``registrasion/models``; we separate our models out into separate files, because there's a lot of them. Models are grouped by logical functionality. - -Actions that operate on Models live in ``registrasion/controllers``. - - -Testing -------- - -Functionality that lives in ``regsistrasion/controllers`` was developed in a test-driven fashion, which is sensible, given it's where most of the business logic for registrasion lives. If you're changing behaviour of a controller, either submit a test with your pull request, or modify an existing test. - - -Documentation -------------- - -Registrasion aims towards high-quality documentation, so that conference registration managers can understand how the system works, and so that webmasters working for conferences understand how the system fits together. Make sure that you have docstrings :) - -The documentation is written in Australian English: *-ise* and not *-ize*, *-our* and not *-or*; *vegemite* and not *peanut butter*, etc etc etc. diff --git a/README.rst b/README.rst deleted file mode 100644 index 1a18a7c0..00000000 --- a/README.rst +++ /dev/null @@ -1,32 +0,0 @@ -Registrasion -============ - -**Registra** (tion for Sympo) **sion**. A conference registration app for Django, -letting conferences big and small sell tickets from within Symposion. - -Symposion ---------- -``symposion`` is an Open Source conference management solution built with Pinax -apps for Django. For more information, see https://github.com/pinax/symposion. - -registrasion ------------- -``registrasion`` is a registration package that you use alongside Symposion. It -handles inventory management, as well as complex product inclusions, automatic -calculation of discounts, and invoicing. Payment of invoices can be faciliated -by manual filings of payments by staff, or through plugging in a payment app. - -Initial development of ``registrasion`` was funded with the generous support of -the Python Software Foundation. - -Quickstart ----------- -``registrasion`` is a Django app. You will need to create a Django project to -customize and manage your Registrasion and Symposion installation. A -demonstration app project with templates is available at -https://github.com/chrisjrn/registrasion-demo - -Documentation -------------- -The documentation for ``registrasion`` is available at -http://registrasion.readthedocs.org/ diff --git a/design/design.md b/design/design.md deleted file mode 100644 index 116ed2ca..00000000 --- a/design/design.md +++ /dev/null @@ -1,298 +0,0 @@ -# Logic - -## Definitions -- User has one 'active Cart' at a time. The Cart remains active until a paid Invoice is attached to it. -- A 'paid Cart' is a Cart with a paid Invoice attached to it, where the Invoice has not been voided. -- An unpaid Cart is 'reserved' if - - CURRENT_TIME - "Time last updated" <= max(reservation duration of Products in Cart), - - A Voucher was added and CURRENT_TIME - "Time last updated" < VOUCHER_RESERVATION_TIME (15 minutes?) -- An Item is 'reserved' if: - - it belongs to a reserved Cart - - it belongs to a paid Cart -- A Cart can have any number of Items added to it, subject to limits. - - -## Entering Vouchers -- Vouchers are attached to Carts -- A user can enter codes for as many different Vouchers as they like. -- A Voucher is added to the Cart if the number of paid or reserved Carts containing the Voucher is less than the "total available" for the voucher. -- A cart is invalid if it contains a voucher that has been overused - - -## Are products available? - -- Availability is determined by the number of items we want to add to the cart: items_to_add - -- If items_to_add + count(Product in their active and paid Carts) > "Limit per user" for the Product, the Product is "unavailable". -- If the Product belongs to an exhausted Ceiling, the Product is "unavailable". -- Otherwise, the product is available - - -## Displaying Products: - -- If there is at least one mandatory EnablingCondition attached to the Product, display it only if all EnablingConditions are met -- If there is at least one EnablingCondition attached to the Product, display it only if at least one EnablingCondition is met -- If there are zero EnablingConditions attached to the Product, display it -- If the product is not available for items_to_add=0, mark it as "unavailable" - -- If the Product is displayed and available, its price is the price for the Product, minus the greatest Discount available to this Cart and Product - -- The product is displayed per the rendering characteristics of the Category it belongs to - - -## Displaying Categories - -- If the Category contains only "unavailable" Products, mark it as "unavailable" -- If the Category contains no displayed Products, do not display the Category -- If the Category contains at least one EnablingCondition, display it only if at least one EnablingCondition is met -- If the Category contains no EnablingConditions, display it - - -## Exhausting Ceilings - -- Exhaustion is determined by the number of items we want to add to the cart: items_to_add - -- A ceiling is exhausted if: - - Its start date has not yet been reached - - Its end date has been exceeded - - items_to_add + sum(paid and reserved Items for each Product in the ceiling) > Total available - - -## Applying Discounts - -- Discounts only apply to the current cart -- Discounts can be applied to multiple carts until the user has exhausted the quantity for each product attached to the discount. -- Only one discount discount can be applied to each single item. Discounts are applied as follows: - - All non-exhausted discounts for the product or its category are ordered by value - - The highest discount is applied for the lower of the quantity of the product in the cart, or the remaining quantity from this discount - - If the quantity remaining is non-zero, apply the next available discount - -- Individual discount objects should not contain more than one DiscountForProduct for the same product -- Individual discount objects should not contain more than one DiscountForCategory for the same category -- Individual discount objects should not contain a discount for both a product and its category - - -## Adding Items to the Cart - -- Products that are not displayed may not be added to a Cart -- The requested number of items must be available for those items to be added to a Cart -- If a different price applies to a Product when it is added to a cart, add at the new price, and display an alert to the user -- If a discount is used when adding a Product to the cart, add the discount as well -- Adding an item resets the "Time last updated" for the cart -- Each time carts have items added or removed, the revision number is updated - - -## Generating an invoice - -- User can ask to 'check out' the active Cart. Doing so generates an Invoice. The invoice corresponds to a revision number of the cart. -- Checking out the active Cart resets the "Time last updated" for the cart. -- The invoice represents the current state of the cart. -- If the revision number for the cart is different to the cart's revision number for the invoice, the invoice is void. -- The invoice is void if - - -## Paying an invoice - -- A payment can only be attached to an invoice if all of the items in it are available at the time payment is processed - -### One-Shot -- Update the "Time last updated" for the cart based on the expected time it takes for a payment to complete -- Verify that all items are available, and if so: -- Proceed to make payment -- Apply payment record from amount received - - -### Authorization-based approach: -- Capture an authorization on the card -- Verify that all items are available, and if so: -- Apply payment record -- Take payment - - -# Registration workflow: - -## User has not taken a guided registration yet: - -User is shown two options: - -1. Undertake guided registration ("for current user") -1. Purchase vouchers - - -## User has not purchased a ticket, and wishes to: - -This gives the user a guided registration process. - -1. Take list of categories, sorted by display order, and display the next lowest enabled & available category -1. Take user to category page -1. User can click "back" to go to previous screen, or "next" to go the next lowest enabled & available category - -Once all categories have been seen: -1. Ask for badge information -- badge information is *not* the same as the invoicee. -1. User is taken to the "user has purchased a ticket" workflow - - -## User is buying vouchers -TODO: Consider separate workflow for purchasing ticket vouchers. - - -## User has completed a guided registration or purchased vouchers - -1. Show list of products that are pending purchase. -1. Show list of categories + badge information, as well as 'checkout' button if the user has items in their current cart - - -## Category page - -- User can enter a voucher at any time -- User is shown the list of products that have been paid for -- User has the option to add/remove products that are in the current cart - - -## Checkout - -1. Ask for invoicing details (pre-fill from previous invoice?) -1. Ask for payment - - -# User Models - -- Profile: - - User - - Has done guided registration? - - Badge - - - -## Transaction Models - -- Cart: - - User - - {Items} - - {Voucher} - - {DiscountItems} - - Time last updated - - Revision Number - - Active? - -- Item - - Product - - Quantity - -- DiscountItem - - Product - - Discount - - Quantity - -- Invoice: - - Invoice number - - User - - Cart - - Cart Revision - - {Line Items} - - (Invoice Details) - - {Payments} - - Voided? - -- LineItem - - Description - - Quantity - - Price - -- Payment - - Time - - Amount - - Reference - - -## Inventory Model - -- Product: - - Name - - Description - - Category - - Price - - Limit per user - - Reservation duration - - Display order - - {Ceilings} - - -- Voucher - - Description - - Code - - Total available - - -- Category? - - Name - - Description - - Display Order - - Rendering Style - - -## Product Modifiers - -- Discount: - - Description - - {DiscountForProduct} - - {DiscountForCategory} - - - Discount Types: - - TimeOrStockLimitDiscount: - * A discount that is available for a limited amount of time, e.g. Early Bird sales * - - Start date - - End date - - Total available - - - VoucherDiscount: - * A discount that is available to a specific voucher * - - Voucher - - - RoleDiscount - * A discount that is available to a specific role * - - Role - - - IncludedProductDiscount: - * A discount that is available because another product has been purchased * - - {Parent Product} - -- DiscountForProduct - - Product - - Amount - - Percentage - - Quantity - -- DiscountForCategory - - Category - - Percentage - - Quantity - - -- EnablingCondition: - - Description - - Mandatory? - - {Products} - - {Categories} - - - EnablingCondition Types: - - ProductEnablingCondition: - * Enabling because the user has purchased a specific product * - - {Products that enable} - - - CategoryEnablingCondition: - * Enabling because the user has purchased a product in a specific category * - - {Categories that enable} - - - VoucherEnablingCondition: - * Enabling because the user has entered a voucher code * - - Voucher - - - RoleEnablingCondition: - * Enabling because the user has a specific role * - - Role - - - TimeOrStockLimitEnablingCondition: - * Enabling because a time condition has been met, or a number of items underneath it have not been sold * - - Start date - - End date - - Total available diff --git a/design/goals.md b/design/goals.md deleted file mode 100644 index 776220df..00000000 --- a/design/goals.md +++ /dev/null @@ -1,55 +0,0 @@ -# Registrasion - -## What - -A registration package that sits on top of the Symposion conference management system. It aims to be able to model complex events, such as those used by [Linux Australia events](http://lca2016.linux.org.au/register/info?_code=301). - - -## Planned features - -### KEY: -- _(MODEL)_: these have model/controller functionality, and tests, and needs UI -- _(ADMIN)_: these have admin functionality - -### Inventory -- Allow conferences to manage complex inventories of products, including tickets, t-shirts, dinner tickets, and accommodation _(MODEL)_ _(ADMIN)_ -- Reports of available inventory and progressive sales for conference staff -- Restrict sales of products to specific classes of users -- Restrict sales of products based to users who've purchased specific products _(MODEL)_ _(ADMIN)_ -- Restrict sales of products based on time/inventory limits _(MODEL)_ _(ADMIN)_ -- Restrict sales of products to users with a voucher _(MODEL)_ _(ADMIN)_ - -### Tickets -- Sell multiple types of tickets, each with different included products _(MODEL)_ _(ADMIN)_ -- Allow for early bird-style discounts _(MODEL)_ _(ADMIN)_ -- Allow attendees to purchase products after initial registration is complete _(MODEL)_ - - Offer included products if they have not yet been claimed _(MODEL)_ -- Automatically offer free tickets to speakers and team -- Offer free tickets for sponsor attendees by voucher _(MODEL)_ _(ADMIN)_ - -### Vouchers -- Vouchers for arbitrary discounts off visible products _(MODEL)_ _(ADMIN)_ -- Vouchers that enable secret products _(MODEL)_ _(ADMIN)_ - -### Invoicing -- Automatic invoicing including discount calculation _(MODEL)_ -- Manual invoicing for arbitrary products by organisers _(MODEL)_ -- Refunds - -### Payments -- Allow multiple payment gateways (so that conferences are not locked into specific payment providers) -- Allow payment of registrations by unauthenticated users (allow business admins to pay for registrations) -- Allow payment of multiple registrations at once - -### Attendee profiles -- Attendees can enter information to be shown on their badge/dietary requirements etc -- Profile can be changed until check-in, allowing for badge/company updates - -### At the conference -- Badge generation, in batches, or on-demand during check-in -- Registration manifests for each attendee including purchased products -- Check-in process at registration desk allowing manifested items to be claimed - -### Tooling -- Generate simple registration cases (ones that don't have complex inventory requirements) -- Generate complex registration cases from spreadsheets diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 88006e7a..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,231 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) - $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " epub3 to make an epub3" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - @echo " dummy to check syntax errors of document sources" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -.PHONY: html -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Registrasion.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Registrasion.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Registrasion" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Registrasion" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: epub3 -epub3: - $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 - @echo - @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." - -.PHONY: dummy -dummy: - $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy - @echo - @echo "Build finished. Dummy builder generates no files." diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 99d8902e..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,297 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Registrasion documentation build configuration file, created by -# sphinx-quickstart on Thu Apr 21 11:29:51 2016. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.napoleon", -] - -# Autodoc requires django to be ready to go, otherwise we can't import rego's -# things... -sys.path.insert(0, ".") -os.environ["DJANGO_SETTINGS_MODULE"] = "django_settings" - -import django -django.setup() - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Registrasion' -copyright = u'2016, Christopher Neugebauer' -author = u'Christopher Neugebauer' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = u'0.1a1' -# The full version, including alpha/beta/rc tags. -release = u'0.1a1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. - -# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' - -if not on_rtd: # only import and set the theme if we're building docs locally - import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - - -# The name for this set of Sphinx documents. -# " v documentation" by default. -#html_title = u'Registrasion v0.1a1' - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -#html_last_updated_fmt = None - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'Registrasiondoc' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'Registrasion.tex', u'Registrasion Documentation', - u'Christopher Neugebauer', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'registrasion', u'Registrasion Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'Registrasion', u'Registrasion Documentation', - author, 'Registrasion', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False diff --git a/docs/django_settings.py b/docs/django_settings.py deleted file mode 100644 index 56a07eca..00000000 --- a/docs/django_settings.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -Django settings for djangoenv project. - -Generated by 'django-admin startproject' using Django 1.9.5. - -For more information on this file, see -https://docs.djangoproject.com/en/1.9/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.9/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'z$cu8&jcnm#qa=xbrkss-4w8do+(pp16j*usmp9j&bg=)&1@-a' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'registrasion', -] - -MIDDLEWARE_CLASSES = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'djangoenv.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'djangoenv.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/1.9/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - - -# Password validation -# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/1.9/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.9/howto/static-files/ - -STATIC_URL = '/static/' diff --git a/docs/for-zookeepr-users.rst b/docs/for-zookeepr-users.rst deleted file mode 100644 index 28cd1932..00000000 --- a/docs/for-zookeepr-users.rst +++ /dev/null @@ -1,22 +0,0 @@ -Registrasion for Zookeepr Keeprs -================================ - -Things that are the same ------------------------- -* You have an inventory of products -* Complete registrations are made up of multiple products -* Products are split into categories -* Products can be listed under ceilings -* Products can be included for free by purchasing other items - * e.g. a Professional Ticket includes a dinner ticket -* Products can be enabled by user roles - * e.g. Speakers Dinner tickets are visible to speakers -* Vouchers can be used to discount products - -Things that are different -------------------------- -* Ceilings can be used to apply discounts, so Early Bird ticket rates can be implemented by applying a ceiling-type discount, rather than duplicating the ticket type. -* Vouchers can be used to enable products - * e.g. Sponsor tickets do not appear until you supply a sponsor's voucher -* Items may be enabled by having other specific items present - * e.g. Extra accommodation nights do not appear until you purchase the main week worth of accommodation. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 75d0c4fa..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,35 +0,0 @@ -.. Registrasion documentation master file, created by - sphinx-quickstart on Thu Apr 21 11:29:51 2016. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Registrasion -============ - -Registra(tion for Sympo)sion. - -Registrasion is a conference registration package that goes well with the Symposion suite of conference management apps for Django. It's designed to manage the sorts of inventories that large conferences need to manage, build up complex tickets with multiple items, and handle payments using whatever payment gateway you happen to have access to - -Development of registrasion was commenced by Christopher Neugebauer in 2016, with the generous support of the Python Software Foundation. - - -Contents: ---------- -.. toctree:: - :maxdepth: 2 - - overview - integration - inventory - payments - for-zookeepr-users - views - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`search` - -.. * :ref:`modindex` diff --git a/docs/integration.rst b/docs/integration.rst deleted file mode 100644 index dc012359..00000000 --- a/docs/integration.rst +++ /dev/null @@ -1,67 +0,0 @@ -Installing and integrating Registrasion -======================================= - -Registrasion is a Django app. It does not provide any templates -- you'll need to develop these yourself. You can use the `registrasion-demo `_ project as a starting point. - -To use Registrasion for your own conference, you'll need to do a small amount of configuration and development work, in your own Django App. - -The configuration that you'll need to do is minimal. The first piece of development work is to define a model and form for your attendee profile, and the second is to implement a payment app. - - -Installing Registrasion ------------------------ - -Registrasion depends on an in-development version of Symposion. You'll need to add the following line to your ``requirements.txt`` files:: - - registrasion==0.1.0 - https://github.com/pinax/symposion/tarball/ad81810#egg=symposion - -And also to enable dependency links in pip:: - - pip install --process-dependency-links -r requirements.txt - -Symposion currently specifies Django version 1.9.2. Note that ``pip`` version 1.6 does not support ``--process-dependency-links``, so you'll need to use an earlier, or later version of ``pip``. - - -Configuring your Django App ---------------------------- - -In your Django ``settings.py`` file, you'll need to add the following to your ``INSTALLED_APPS``:: - - "registrasion", - "nested_admin", - -You will also need to configure ``symposion`` appropriately. - - -Attendee profile ----------------- - -.. automodule:: registrasion.models.people - -Attendee profiles are where you ask for information such as what your attendee wants on their badge, and what the attendee's dietary and accessibility requirements are. - -Because every conference is different, Registrasion lets you define your own attendee profile model, and your own form for requesting this information. The only requirement is that you derive your model from ``AttendeeProfileBase``. - -.. autoclass :: AttendeeProfileBase - :members: name_field, invoice_recipient - -You specify how to find that model in your Django ``settings.py`` file:: - - ATTENDEE_PROFILE_MODEL = "democon.models.AttendeeProfile" - -When Registrasion asks the to edit their profile, a default form will be generated, showing all of the fields on the profile model. - -If you want to customise the profile editing form, you need to specify the location of that form in your ``settings.py`` file as well. - - ATTENDEE_PROFILE_FORM = "democon.forms.AttendeeProfileForm" - -The only contract is that this form creates an instance of ``AttendeeProfileBase`` when saved, and that it can take an instance of your subclass on creation (so that your attendees can edit their profile). - - -Payments --------- - -Registrasion does not implement its own credit card processing. You'll need to do that yourself. Registrasion *does* provide a mechanism for recording cheques and direct deposits, if you do end up taking registrations that way. - -See :ref:`payments_and_refunds` for a guide on how to correctly implement payments. diff --git a/docs/inventory.rst b/docs/inventory.rst deleted file mode 100644 index c9d6dec1..00000000 --- a/docs/inventory.rst +++ /dev/null @@ -1,158 +0,0 @@ - -Inventory Management -==================== - -Registrasion uses an inventory model to keep track of tickets, and the other various products that attendees of your conference might want to have, such as t-shirts and dinner tickets. - -All of the classes described herein are available through the Django Admin interface. - -Overview --------- - -The inventory model is split up into Categories and Products. Categories are used to group Products. - -Registrasion uses conditionals to build up complex tickets, or enable/disable specific items to specific users: - -Often, you will want to offer free items, such as t-shirts or dinner tickets to your attendees. Registrasion has a Discounts facility that lets you automatically offer free items to your attendees as part of their tickets. You can also automatically offer promotional discounts, such as Early Bird discounts. - -Sometimes, you may want to restrict parts of the conference to specific attendees, for example, you might have a Speakers Dinner to only speakers. Or you might want to restrict certain Products to attendees who have purchased other items, for example, you might want to sell Comfy Chairs to people who've bought VIP tickets. You can control showing and hiding specific products using Flags. - - -.. automodule:: registrasion.models.inventory - -Categories ----------- - -Categories are logical groups of Products. Generally, you should keep like products in the same category, and use as many categories as you need. - -You will need at least one Category to be able to sell tickets to your attendees. - -Each category has the following attributes: - -.. autoclass :: Category - - -Products --------- - -Products represent the different items that comprise a user's conference ticket. - -Each product has the following attributes: - -.. autoclass :: Product - - -Vouchers --------- - -Vouchers are used to enable Discounts or Flags for people who enter a voucher -code. - -.. autoclass :: Voucher - -If an attendee enters a voucher code, they have at least an hour to finalise -their registration before the voucher becomes unreserved. Only as many people -as allowed by ``limit`` are allowed to have a voucher reserved. - - -.. automodule:: registrasion.models.conditions - -Discounts ---------- - -Discounts serve multiple purposes: they can be used to build up complex tickets by automatically waiving the costs for sub-products; they can be used to offer freebie tickets to specific people, or people who hold voucher codes; or they can be used to enable short-term promotional discounts. - -Registrasion has several types of discounts, which enable themselves under specific conditions. We'll explain how these work later on, but first: - -Common features -~~~~~~~~~~~~~~~ -Each discount type has the following common attributes: - -.. autoclass :: DiscountBase - -You can apply a discount to individual products, or to whole categories, or both. All of the products and categories affected by the discount are displayed on the discount's admin page. - -If you choose to specify individual products, you have these options: - -.. autoclass :: DiscountForProduct - -If you choose to specify whole categories, you have these options: - -.. autoclass :: DiscountForCategory - -Note that you cannot have a discount apply to both a category, and a product within that category. - -Product Inclusions -~~~~~~~~~~~~~~~~~~ -Product inclusion discounts allow you to enable a discount when an attendee has selected a specific enabling Product. - -For example, if you want to give everyone with a ticket a free t-shirt, you can use a product inclusion to offer a 100% discount on the t-shirt category, if the attendee has selected one of your ticket Products. - -Once a discount has been enabled in one Invoice, it is available until the quantities are exhausted for that attendee. - -.. autoclass :: IncludedProductDiscount - -Time/stock limit discounts -~~~~~~~~~~~~~~~~~~~~~~~~~~ -These discounts allow you to offer a limited promotion that is automatically offered to all attendees. You can specify a time range for when the discount should be enabled, you can also specify a stock limit. - -.. autoclass :: TimeOrStockLimitDiscount - -Voucher discounts -~~~~~~~~~~~~~~~~~ -Vouchers can be used to enable discounts. - -.. autoclass :: VoucherDiscount - -How discounts get applied -~~~~~~~~~~~~~~~~~~~~~~~~~ -It's possible for multiple discounts to be available on any given Product. This means there need to be rules for how discounts get applied. It works like so: - -#. Take all of the Products that the user currently has selected, and sort them so that the most expensive comes first. -#. Apply the highest-value discount line for the first Product, until either all such products have a discount applied, or the discount's Quantity has been exhausted for that user for that Product. -#. Repeat until all products have been processed. - -In summary, the system greedily applies the highest-value discounts for each product. This may not provide a global optimum, but it'll do. - -As an example: say a user has a voucher available for a 100% discount of tickets, and there's a promotional discount for 15% off tickets. In this case, the 100% discount will apply, and the 15% discount will not be disturbed. - - -Flags ------ - -Flags are conditions that can be used to enable or disable Products or Categories, depending on whether conditions are met. They can be used to restrict specific products to specific people, or to place time limits on availability for products. - -Common Features -~~~~~~~~~~~~~~~ - -.. autoclass :: FlagBase - - -Dependencies on products from category -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Category Dependency flags have their condition met if a product from the enabling category has been selected by the attendee. For example, if there is an *Accommodation* Category, this flag could be used to enable an *Accommodation Breakfast* category, allowing only attendees with accommodation to purchase breakfast. - -.. autoclass :: CategoryFlag - - -Dependencies on products -~~~~~~~~~~~~~~~~~~~~~~~~ -Product dependency flags have their condition met if one of the enabling products have been selected by the attendee. - -.. autoclass :: ProductFlag - -Time/stock limit flags -~~~~~~~~~~~~~~~~~~~~~~ -These flags allow the products that they cover to be made available for a limited time, or to set a global ceiling on the products covered. - -These can be used to remove items from sale once a sales deadline has been met, or if a venue for a specific event has reached capacity. If there are items that fall under multiple such groupings, it makes sense to set all of these flags to be ``DISABLE_IF_FALSE``. - -.. autoclass :: TimeOrStockLimitFlag - -If any of the attributes are omitted, then only the remaining attributes affect the availablility of the products covered. If there's no attributes set at all, then the grouping has no effect, but it can be used to group products for reporting purposes. - -Voucher flags -~~~~~~~~~~~~~ -Vouchers can be used to enable flags. - -.. autoclass :: VoucherFlag diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index da9a651f..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,281 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Registrasion.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Registrasion.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end diff --git a/docs/overview.rst b/docs/overview.rst deleted file mode 100644 index 5f6b25fa..00000000 --- a/docs/overview.rst +++ /dev/null @@ -1,51 +0,0 @@ -Overview -======== - -Registrasion's approach to handling conference registrations is to use a cart and inventory model, where the various things sold by the conference to attendees are handled as Products, which can be added to a Cart. Carts can be used to generate Invoices, and Invoices can then be paid. - - -Guided registration -------------------- - -Unlike a generic e-commerce platform, Registrasion is designed for building up conference tickets. - -When they first attempt registration, attendees start off in a process called *guided mode*. Guided mode is multi-step form that takes users through a complete registration process: - -#. The attendee fills out their profile -#. The attendee selects a ticket type -#. The attendee selects additional products such as t-shirts and dinner tickets, which may be sold at a cost, or have waivers applied. -#. The attendee is offered the opportunity to check out their cart, generating an invoice; or to enter amendments mode. - -For specifics on how guided mode works, see *code guide to be written*. - - -Amendments mode ---------------- - -Once attendees have reached the end of guided registration, they are permanently added to *amendments mode*. Amendments mode allows attendees to change their product selections in a given category, with one rule: once an invoice has been paid, product selections cannot be changed without voiding that invoice (and optionally releasing a Credit Note). - -Users can check out their current selections at any time, and generate an Invoice for their selections. That invoice can then be paid, and the attendee will then be making selections on a new cart. - - -Invoices --------- - -When an attendee checks out their Cart, an Invoice is generated for their cart. - -An invoice is valid for as long as the items in the cart do not change, and remain generally available. If a user amends their cart after generating an invoice, the user will need to check out their cart again, and generate a new invoice. - -Once an invoice is paid, it is no longer possible for an invoice to be void, instead, it needs to have a refund generated. - - -User-Attendee Model -------------------- - -Registrasion uses a User-Attendee model. This means that Registrasion expects each user account on the system to represent a single attendee at the conference. It also expects that the attendee themselves fill out the registration form. - -This means that each attendee has a confirmed e-mail address for conference-related communications. It's usually a good idea for the conference to make sure that their account sign-up page points this out, so that administrative assistants at companies don't end up being the ones getting communicated at. - -How do people get their employers to pay for their tickets? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Registrasion provides a semi-private URL that allows anyone in possession of this URL to view that attendee's most recent invoice, and make payments against that invoice. - -A future release will add the ability to bulk-pay multiple invoices at once. diff --git a/docs/payments.rst b/docs/payments.rst deleted file mode 100644 index d0105afb..00000000 --- a/docs/payments.rst +++ /dev/null @@ -1,165 +0,0 @@ -.. automodule:: registrasion.models.commerce -.. _payments_and_refunds: - -Payments and Refunds -==================== - -Registrasion aims to support whatever payment platform you have available to use. Therefore, Registrasion uses a bare minimum payments model to track money within the system. It's the role of you, as a deployer of Registrasion, to implement a payment application that communicates with your own payment platform. - -Invoices may have multiple ``PaymentBase`` objects attached to them; each of these represent a payment against the invoice. Payments can be negative (and this represents a refund against the Invoice), however, this approach is not recommended for use by implementers. - -Registrasion also keeps track of money that is not currently attached to invoices through `credit notes`_. Credit notes may be applied to unpaid invoices *in full*, if there is an amount left over from the credit note, a new credit note will be created from that amount. Credit notes may also be released, at which point they're the responsibility of the payment application to create a refund. - -Finally, Registrasion provides a `manual payments`_ feature, which allows for staff members to manually report payments into the system. This is the only payment facility built into Registrasion, but it's not intended as a reference implementation. - - -Invoice and payment access control ----------------------------------- - -Conferences are interesting: usually you want attendees to fill in their own registration so that they get their catering options right, so that they can personally agree to codes of conduct, and so that you can make sure that you're communicating key information directly with them. - -On the other hand, employees at companies often need for their employers to directly pay for their registration. - -Registrasion solves this problem by having attendees complete their own registration, and then providing an access URL that allows anyone who holds that URL to view their invoice and make payment. - -You can call ``InvoiceController.can_view`` to determine whether or not you're allowed to show the invoice. It returns true if the user is allowed to view the invoice:: - - InvoiceController.can_view(self, user=request.user, access_code="CODE") - -As a rule, you should call ``can_view`` before doing any operations that amend the status of an invoice. This includes taking payments or requesting refunds. - -The access code is unique for each attendee -- this means that every invoice that an attendee generates can be viewed with the same access code. This is useful if the user amends their registration between giving the URL to their employer, and their employer making payment. - - - - -Making payments ---------------- - -Making payments is a three-step process: - -#. Validate that the invoice is ready to be paid, -#. Create a payment object for the amount that you are paying towards an invoice, -#. Ask the invoice to calculate its status now that the payment has been made. - -Pre-validation -~~~~~~~~~~~~~~ -Registrasion's ``InvoiceController`` has a ``validate_allowed_to_pay`` method, which performs all of the pre-payment checks (is the invoice still unpaid and non-void? has the registration been amended?). - -If the pre-payment check fails, ``InvoiceController`` will raise a Django ``ValidationError``. - -Our the ``demopay`` view from the ``registrasion-demo`` project implements pre-validation like so:: - - from registrasion.controllers.invoice import InvoiceController - from django.core.exceptions import ValidationError - - invoice = InvoiceController.for_id_or_404(invoice.id) - - try: - invoice.validate_allowed_to_pay() # Verify that we're allowed to do this. - except ValidationError as ve: - messages.error(request, ve.message) - return REDIRECT_TO_INVOICE # And display the validation message. - -In most cases, you don't engage your actual payment application until after pre-validation is finished, as this gives you an opportunity to bail out if the invoice isn't able to have funds applied to it. - -Applying payments -~~~~~~~~~~~~~~~~~ -Payments in Registrasion are represented as subclasses of the ``PaymentBase`` model. ``PaymentBase`` looks like this: - -.. autoclass :: PaymentBase - -When you implement your own payment application, you'll need to subclass ``PaymentBase``, as this will allow you to add metadata that lets you link the Registrasion payment object with your payment platform's object. - -Generally, the ``reference`` field should be something that lets your end-users identify the payment on their credit card statement, and to provide to your team's tech support in case something goes wrong. - -Once you've subclassed ``PaymentBase``, applying a payment is really quite simple. In the ``demopay`` view, we have a subclass called ``DemoPayment``:: - - invoice = InvoiceController(some_invoice_model) - - # Create the payment object - models.DemoPayment.objects.create( - invoice=invoice.invoice, - reference="Demo payment by user: " + request.user.username, - amount=invoice.invoice.value, - ) - -Note that multiple payments can be provided against an ``Invoice``, however, payments that exceed the total value of the invoice will have credit notes generated. - -Updating an invoice's status -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``InvoiceController`` has a method called ``update_status``. You should call ``update_status`` immediately after you create a ``PaymentBase`` object, as this keeps invoice and its payments synchronised:: - - invoice = InvoiceController(some_invoice_model) - invoice.update_status() - -Calling ``update_status`` collects the ``PaymentBase`` objects that are attached to the ``Invoice``, and will update the status of the invoice: - -* If an invoice is ``VOID``, it will remain void. -* If an invoice is ``UNPAID`` and it now has ``PaymentBase`` objects whose value meets or exceed's the invoice's value, the invoice becomes ``PAID``. -* If an invoice is ``UNPAID`` and it now has ``PaymentBase`` objects whose values sum to zero, the invoice becomes ``VOID``. -* If an invoice is ``PAID`` and it now has ``PaymentBase`` objects of less than the invoice's value, the invoice becomes ``REFUNDED``. - -When your invoice becomes ``PAID`` for the first time, if there's a cart of inventory items attached to it, that cart becomes permanently reserved -- that is, all of the items within it are no longer available for other users to purchase. If an invoice becomes ``REFUNDED``, the items in the cart are released, which means that they are available for anyone to purchase again. - -If you overpay an invoice, or pay into an invoice that should not have funds attached, a credit note for the residual payments will also be issued. - -In general, although this means you *can* use negative payments to take an invoice into a *REFUNDED* state, it's still much more sensible to use the credit notes facility, as this makes sure that any leftover funds remain tracked in the system. - - -Credit Notes ------------- - -When you refund an invoice, often you're doing so in order to make a minor amendment to items that the attendee has purchased. In order to make it easy to transfer funds from a refunded invoice to a new invoice, Registrasion provides an automatic credit note facility. - -Credit notes are created when you mark an invoice as refunded, but they're also created if you overpay an invoice, or if you direct money into an invoice that can no longer take payment. - -Once created, Credit Notes act as a payment that can be put towards other invoices, or that can be cashed out, back to the original payment platform. Credits can only be applied or cashed out in full. - -This means that it's easy to track funds that aren't accounted for by invoices -- it's just the sum of the credit notes that haven't been applied to new invoices, or haven't been cashed out. - -We recommend using credit notes to track all of your refunds for consistency; it also allows you to invoice for cancellation fees and the like. - -Creating credit notes -~~~~~~~~~~~~~~~~~~~~~ -In Registrasion, credit notes originate against invoices, and are represented as negative payments to an invoice. - -Credit notes are usually created automatically. In most cases, Credit Notes come about from asking to refund an invoice:: - - InvoiceController(invoice).refund() - -Calling ``refund()`` will generate a refund of all of the payments applied to that invoice. - -Otherwise, credit notes come about when invoices are overpaid, in this case, a credit for the overpay amount will be generated. - -Applying credits to new invoices -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Credits can be applied to invoices:: - - CreditNoteController(credit_not).apply_to_invoice(invoice) - -This will result in an instance of ``CreditNoteApplication`` being applied as a payment to ``invoice``. ``CreditNoteApplication`` will always be a payment of the full amount of its parent credit note. If this payment overpays the invoice it's being applied to, a credit note for the residual will be generated. - -Refunding credit notes -~~~~~~~~~~~~~~~~~~~~~~ -It is possible to release a credit note back to the original payment platform. To do so, you attach an instance of ``CreditNoteRefund`` to the original ``CreditNote``: - -.. autoclass :: CreditNoteRefund - -You'll usually want to make a subclass of ``CreditNoteRefund`` for your own purposes, usually so that you can tie Registrasion's internal representation of the refund to a concrete refund on the side of your payment platform. - -Note that you can only release a credit back to the payment platform for the full amount of the credit. - - -Manual payments ---------------- - -Registrasion provides a *manual payments* feature, which allows for staff members to manually report payments into the system. This is the only payment facility built into Registrasion, but it's not intended as a reference implementation. - -The main use case for manual payments is to record the receipt of funds from bank transfers or cheques sent on behalf of attendees. - -It's not intended as a reference implementation is because it does not perform validation of the cart before the payment is applied to the invoice. - -This means that it's possible for a staff member to apply a payment with a specific invoice reference into the invoice matching that reference. Registrasion will generate a credit note if the invoice is not able to receive payment (e.g. because it has since been voided), you can use that credit note to pay into a valid invoice if necessary. - -It is possible for staff to enter a negative amount on a manual payment. This will be treated as a refund. Generally, it's preferred to issue a credit note to an invoice rather than enter a negative amount manually. diff --git a/docs/views.rst b/docs/views.rst deleted file mode 100644 index 313337ec..00000000 --- a/docs/views.rst +++ /dev/null @@ -1,41 +0,0 @@ -User-facing views -================= - - -View functions --------------- - -Here's all of the views that Registrasion exposes to the public. - -.. automodule:: registrasion.views - :members: - -Data types -~~~~~~~~~~ - -.. automodule:: registrasion.controllers.discount - -.. autoclass:: DiscountAndQuantity - - -Template tags -------------- - -Registrasion makes template tags available: - -.. automodule:: registrasion.templatetags.registrasion_tags - :members: - - -Rendering invoices ------------------- - -You'll need to render the following Django models in order to view invoices. - -.. automodule:: registrasion.models.commerce - -.. autoclass:: Invoice - -.. autoclass:: LineItem - -See also: :class:`PaymentBase` diff --git a/LICENSE b/registrasion/LICENSE similarity index 100% rename from LICENSE rename to registrasion/LICENSE diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index a87391f9..00000000 --- a/requirements/base.txt +++ /dev/null @@ -1,2 +0,0 @@ -django-nested-admin==2.2.6 -#symposion==1.0b2.dev3 diff --git a/requirements/dependencies.txt b/requirements/dependencies.txt deleted file mode 100644 index 42ef60c8..00000000 --- a/requirements/dependencies.txt +++ /dev/null @@ -1 +0,0 @@ -https://github.com/pinax/symposion/tarball/ad81810#egg=symposion diff --git a/requirements/docs.txt b/requirements/docs.txt deleted file mode 100644 index f070a205..00000000 --- a/requirements/docs.txt +++ /dev/null @@ -1,4 +0,0 @@ -# requirements needed to build the docs - -Sphinx==1.4.1 -sphinx-rtd-theme==0.1.9 diff --git a/requirements/extern.txt b/requirements/extern.txt deleted file mode 100644 index f9f15786..00000000 --- a/requirements/extern.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Requirements that currently live in git land, so are necessary to make the -# project build, but can't live in setup.py - --e git+https://github.com/pinax/symposion.git@ad81810#egg=SymposionMaster-1.0.0b3-dev # Symposion lives on git at the moment diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9b05640c..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -exclude = registrasion/migrations/*, build/*, docs/*, dist/* diff --git a/setup.py b/setup.py deleted file mode 100644 index f3bda01f..00000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python -import os -from setuptools import setup, find_packages - -import registrasion - - -def read_file(filename): - """Read a file into a string.""" - path = os.path.abspath(os.path.dirname(__file__)) - filepath = os.path.join(path, filename) - try: - return open(filepath).read() - except IOError: - return '' - -setup( - name="registrasion", - author="Christopher Neugebauer", - author_email="_@chrisjrn.com", - version=registrasion.__version__, - description="A registration app for the Symposion conference management " - "system.", - url="http://github.com/chrisjrn/registrasion/", - packages=find_packages(), - include_package_data=True, - classifiers=( - "Development Status :: 3 - Alpha", - "Programming Language :: Python", - "Framework :: Django", - "Intended Audience :: Developers", - "Natural Language :: English", - "License :: OSI Approved :: Apache Software License", - ), - install_requires=read_file("requirements/base.txt").splitlines(), - dependency_links=read_file("requirements/dependencies.txt").splitlines(), -)