diff --git a/vendor/registrasion/.gitrepo b/vendor/registrasion/.gitrepo index cc64f22f..c14eed14 100644 --- a/vendor/registrasion/.gitrepo +++ b/vendor/registrasion/.gitrepo @@ -6,6 +6,6 @@ [subrepo] remote = git@gitlab.com:tchaypo/registrasion.git branch = lca2018 - commit = c1e194aef92e4c06a8855fad22ca819c08736dad - parent = dd8a42e9f67cfebc39347cb87bacf79046bfbfec + commit = 7cf314adae9f8de1519db63828f55a10aa09f0ee + parent = 7c5c6c02f20ddcbc6c54b3065311880fce586192 cmdver = 0.3.1 diff --git a/vendor/registrasion/registrasion/controllers/category.py b/vendor/registrasion/registrasion/controllers/category.py index 2b2b18d1..77875946 100644 --- a/vendor/registrasion/registrasion/controllers/category.py +++ b/vendor/registrasion/registrasion/controllers/category.py @@ -9,6 +9,8 @@ from django.db.models import Value from .batch import BatchController +from operator import attrgetter + class AllProducts(object): pass @@ -26,7 +28,7 @@ class CategoryController(object): products, otherwise it'll do all. ''' # STOPGAP -- this needs to be elsewhere tbqh - from registrasion.controllers.product import ProductController + from .product import ProductController if products is AllProducts: products = inventory.Product.objects.all().select_related( @@ -38,7 +40,7 @@ class CategoryController(object): products=products, ) - return set(i.category for i in available) + return sorted(set(i.category for i in available), key=attrgetter("order")) @classmethod @BatchController.memoise diff --git a/vendor/registrasion/registrasion/controllers/credit_note.py b/vendor/registrasion/registrasion/controllers/credit_note.py index 500d0ad7..6c2f7102 100644 --- a/vendor/registrasion/registrasion/controllers/credit_note.py +++ b/vendor/registrasion/registrasion/controllers/credit_note.py @@ -4,7 +4,7 @@ from django.db import transaction from registrasion.models import commerce -from registrasion.controllers.for_id import ForId +from .for_id import ForId class CreditNoteController(ForId, object): @@ -40,8 +40,8 @@ class CreditNoteController(ForId, object): paid. ''' - # Circular Import - from registrasion.controllers.invoice import InvoiceController + # Local import to fix import cycles. Can we do better? + from .invoice import InvoiceController inv = InvoiceController(invoice) inv.validate_allowed_to_pay() @@ -65,8 +65,8 @@ class CreditNoteController(ForId, object): a cancellation fee. Must be 0 <= percentage <= 100. ''' - # Circular Import - from registrasion.controllers.invoice import InvoiceController + # Local import to fix import cycles. Can we do better? + from .invoice import InvoiceController assert(percentage >= 0 and percentage <= 100) diff --git a/vendor/registrasion/registrasion/controllers/flag.py b/vendor/registrasion/registrasion/controllers/flag.py index b8d1b4e3..b64713e7 100644 --- a/vendor/registrasion/registrasion/controllers/flag.py +++ b/vendor/registrasion/registrasion/controllers/flag.py @@ -45,6 +45,28 @@ class FlagController(object): else: all_conditions = [] + all_conditions = conditions.FlagBase.objects.filter( + id__in=set(i.id for i in all_conditions) + ).select_subclasses() + + # Prefetch all of the products and categories (Saves a LOT of queries) + all_conditions = all_conditions.prefetch_related( + "products", "categories", "products__category", + ) + + # Now pre-select all of the products attached to those categories + all_categories = set( + cat for condition in all_conditions + for cat in condition.categories.all() + ) + all_category_ids = (i.id for i in all_categories) + all_category_products = inventory.Product.objects.filter( + category__in=all_category_ids + ).select_related("category") + + products_by_category_ = itertools.groupby(all_category_products, lambda prod: prod.category) + products_by_category = dict((k.id, list(v)) for (k, v) in products_by_category_) + # 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 @@ -64,17 +86,19 @@ class FlagController(object): # Get all products covered by this condition, and the products # from the categories covered by this condition - ids = [product.id for product in products] - - # TODO: This is re-evaluated a lot. - all_products = inventory.Product.objects.filter(id__in=ids) - cond = ( - Q(flagbase_set=condition) | - Q(category__in=condition.categories.all()) + condition_products = condition.products.all() + category_products = ( + product for cat in condition.categories.all() for product in products_by_category[cat.id] ) - all_products = all_products.filter(cond) - all_products = all_products.select_related("category") + all_products = itertools.chain( + condition_products, category_products + ) + all_products = set(all_products) + + # Filter out the products from this condition that + # are not part of this query. + all_products = set(i for i in all_products if i in products) if quantities: consumed = sum(quantities[i] for i in all_products) diff --git a/vendor/registrasion/registrasion/controllers/invoice.py b/vendor/registrasion/registrasion/controllers/invoice.py index 706db7f9..8937843b 100644 --- a/vendor/registrasion/registrasion/controllers/invoice.py +++ b/vendor/registrasion/registrasion/controllers/invoice.py @@ -10,9 +10,9 @@ from registrasion.models import commerce from registrasion.models import conditions from registrasion.models import people -from registrasion.controllers.cart import CartController -from registrasion.controllers.credit_note import CreditNoteController -from registrasion.controllers.for_id import ForId +from .cart import CartController +from .credit_note import CreditNoteController +from .for_id import ForId class InvoiceController(ForId, object): diff --git a/vendor/registrasion/registrasion/forms.py b/vendor/registrasion/registrasion/forms.py index fec2ec79..3e0beaaa 100644 --- a/vendor/registrasion/registrasion/forms.py +++ b/vendor/registrasion/registrasion/forms.py @@ -1,6 +1,6 @@ -from registrasion.controllers.product import ProductController -from registrasion.models import commerce -from registrasion.models import inventory +from .controllers.product import ProductController +from .models import commerce +from .models import inventory from django import forms from django.db.models import Q @@ -31,12 +31,13 @@ class ApplyCreditNoteForm(forms.Form): "user_email": users[invoice["user_id"]].email, }) - key = lambda inv: (0 - (inv["user_id"] == self.user.id), inv["id"]) # noqa invoices_annotated.sort(key=key) - template = ('Invoice %(id)d - user: %(user_email)s (%(user_id)d) ' - '- $%(value)d') + template = ( + 'Invoice %(id)d - user: %(user_email)s (%(user_id)d) ' + '- $%(value)d' + ) return [ (invoice["id"], template % invoice) for invoice in invoices_annotated @@ -94,6 +95,7 @@ def ProductsForm(category, products): cat.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, cat.RENDER_TYPE_RADIO: _RadioButtonProductsForm, cat.RENDER_TYPE_ITEM_QUANTITY: _ItemQuantityProductsForm, + cat.RENDER_TYPE_CHECKBOX: _CheckboxProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on @@ -252,6 +254,35 @@ class _RadioButtonProductsForm(_ProductsForm): self.add_error(self.FIELD, error) +class _CheckboxProductsForm(_ProductsForm): + ''' Products entry form that allows users to say yes or no + to desired products. Basically, it's a quantity form, but the quantity + is either zero or one.''' + + @classmethod + def set_fields(cls, category, products): + for product in products: + field = forms.BooleanField( + label='%s -- %s' % (product.name, product.price), + required=False, + ) + 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)] = bool(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, int(value)) + + 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 @@ -449,7 +480,6 @@ class InvoicesWithProductAndStatusForm(forms.Form): product = [int(i) for i in product] super(InvoicesWithProductAndStatusForm, self).__init__(*a, **k) - print(status) qs = commerce.Invoice.objects.filter( status=status or commerce.Invoice.STATUS_UNPAID, diff --git a/vendor/registrasion/registrasion/migrations/0007_merge_20170929_2259.py b/vendor/registrasion/registrasion/migrations/0007_merge_20170929_2259.py deleted file mode 100644 index bcd52f24..00000000 --- a/vendor/registrasion/registrasion/migrations/0007_merge_20170929_2259.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.5 on 2017-09-29 12:59 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('registrasion', '0006_auto_20170526_1624'), - ('registrasion', '0006_auto_20170702_2233'), - ] - - operations = [ - ] diff --git a/vendor/registrasion/registrasion/models/__init__.py b/vendor/registrasion/registrasion/models/__init__.py index 47efa127..3d247559 100644 --- a/vendor/registrasion/registrasion/models/__init__.py +++ b/vendor/registrasion/registrasion/models/__init__.py @@ -1,4 +1,4 @@ -from registrasion.models.commerce import * # NOQA -from registrasion.models.conditions import * # NOQA -from registrasion.models.inventory import * # NOQA -from registrasion.models.people import * # NOQA +from .commerce import * # NOQA +from .conditions import * # NOQA +from .inventory import * # NOQA +from .people import * # NOQA diff --git a/vendor/registrasion/registrasion/models/commerce.py b/vendor/registrasion/registrasion/models/commerce.py index a392c96b..4791ec3d 100644 --- a/vendor/registrasion/registrasion/models/commerce.py +++ b/vendor/registrasion/registrasion/models/commerce.py @@ -324,7 +324,6 @@ class CreditNote(PaymentBase): elif hasattr(self, 'creditnoterefund'): reference = self.creditnoterefund.reference - print(reference) return "Refunded with reference: %s" % reference raise ValueError("This should never happen.") diff --git a/vendor/registrasion/registrasion/models/inventory.py b/vendor/registrasion/registrasion/models/inventory.py index 575566e2..6dfc6cd4 100644 --- a/vendor/registrasion/registrasion/models/inventory.py +++ b/vendor/registrasion/registrasion/models/inventory.py @@ -42,6 +42,8 @@ class Category(models.Model): have a lot of options, from which the user is not going to select all of the options. + ``RENDER_TYPE_CHECKBOX`` shows a checkbox beside each product. + limit_per_user (Optional[int]): This restricts the number of items from this Category that each attendee may claim. This extends across multiple Invoices. @@ -63,11 +65,13 @@ class Category(models.Model): RENDER_TYPE_RADIO = 1 RENDER_TYPE_QUANTITY = 2 RENDER_TYPE_ITEM_QUANTITY = 3 + RENDER_TYPE_CHECKBOX = 4 CATEGORY_RENDER_TYPES = [ (RENDER_TYPE_RADIO, _("Radio button")), (RENDER_TYPE_QUANTITY, _("Quantity boxes")), (RENDER_TYPE_ITEM_QUANTITY, _("Product selector and quantity box")), + (RENDER_TYPE_CHECKBOX, _("Checkbox button")), ] name = models.CharField( diff --git a/vendor/registrasion/registrasion/reporting/reports.py b/vendor/registrasion/registrasion/reporting/reports.py index a9684802..d58219f9 100644 --- a/vendor/registrasion/registrasion/reporting/reports.py +++ b/vendor/registrasion/registrasion/reporting/reports.py @@ -177,7 +177,6 @@ class Links(Report): 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) @@ -299,9 +298,10 @@ class ReportView(object): response = HttpResponse(content_type='text/csv') writer = csv.writer(response) - writer.writerow(report.headings()) + encode = lambda i: i.encode("utf8") if isinstance(i, unicode) else i # NOQA + writer.writerow(list(encode(i) for i in report.headings())) for row in report.rows(): - writer.writerow(row) + writer.writerow(list(encode(i) for i in row)) return response diff --git a/vendor/registrasion/registrasion/reporting/views.py b/vendor/registrasion/registrasion/reporting/views.py index 239c174a..fbae7906 100644 --- a/vendor/registrasion/registrasion/reporting/views.py +++ b/vendor/registrasion/registrasion/reporting/views.py @@ -1,4 +1,4 @@ -from registrasion.reporting import forms +from . import forms import collections import datetime @@ -24,11 +24,11 @@ from registrasion import views from symposion.schedule import models as schedule_models -from registrasion.reporting.reports import get_all_reports -from registrasion.reporting.reports import Links -from registrasion.reporting.reports import ListReport -from registrasion.reporting.reports import QuerysetReport -from registrasion.reporting.reports import report_view +from .reports import get_all_reports +from .reports import Links +from .reports import ListReport +from .reports import QuerysetReport +from .reports import report_view def CURRENCY(): @@ -95,8 +95,6 @@ def items_sold(): total_quantity=Sum("quantity"), ) - print(line_items) - headings = ["Description", "Quantity", "Price", "Total"] data = [] @@ -312,6 +310,55 @@ def discount_status(request, form): return ListReport("Usage by item", headings, data) +@report_view("Product Line Items By Date & Customer", form_type=forms.ProductAndCategoryForm) +def product_line_items(request, form): + ''' Shows each product line item from invoices, including their date and + purchashing customer. ''' + + 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, + ).select_related( + "cart", + "user", + "user__attendee", + "user__attendee__attendeeprofilebase" + ).order_by("issue_time") + + headings = [ + 'Invoice', 'Invoice Date', 'Attendee', 'Qty', 'Product', 'Status' + ] + + data = [] + for invoice in invoices: + for item in invoice.cart.productitem_set.all(): + if item.product in products or item.product.category in categories: + output = [] + output.append(invoice.id) + output.append(invoice.issue_time.strftime('%Y-%m-%d %H:%M:%S')) + output.append( + invoice.user.attendee.attendeeprofilebase.attendee_name() + ) + output.append(item.quantity) + output.append(item.product) + cart = invoice.cart + if cart.status == commerce.Cart.STATUS_PAID: + output.append('PAID') + elif cart.status == commerce.Cart.STATUS_ACTIVE: + output.append('UNPAID') + elif cart.status == commerce.Cart.STATUS_RELEASED: + output.append('REFUNDED') + data.append(output) + + return ListReport("Line Items", 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 @@ -353,8 +400,8 @@ def paid_invoices_by_date(request, form): ) 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] + 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", @@ -417,8 +464,6 @@ def attendee(request, form, user_id=None): if user_id is None: return attendee_list(request) - print(user_id) - attendee = people.Attendee.objects.get(user__id=user_id) name = attendee.attendeeprofilebase.attendee_name() @@ -846,9 +891,10 @@ def manifest(request, form): headings = ["User ID", "Name", "Paid", "Unpaid", "Refunded"] def format_items(item_list): - strings = [] - for item in item_list: - strings.append('%d x %s' % (item.quantity, str(item.product))) + strings = [ + '%d x %s' % (item.quantity, str(item.product)) + for item in item_list + ] return ", \n".join(strings) output = [] diff --git a/vendor/registrasion/registrasion/templatetags/registrasion_tags.py b/vendor/registrasion/registrasion/templatetags/registrasion_tags.py index 7108ae95..4c72431b 100644 --- a/vendor/registrasion/registrasion/templatetags/registrasion_tags.py +++ b/vendor/registrasion/registrasion/templatetags/registrasion_tags.py @@ -3,8 +3,9 @@ from registrasion.controllers.category import CategoryController from registrasion.controllers.item import ItemController from django import template +from django.conf import settings from django.db.models import Sum -from urllib.parse import urlencode +from urllib import urlencode # TODO: s/urllib/six.moves.urllib/ register = template.Library() @@ -117,3 +118,69 @@ def report_as_csv(context, section): querystring = old_query + "&" + querystring return context.request.path + "?" + querystring + + +@register.assignment_tag(takes_context=True) +def sold_out_and_unregistered(context): + ''' If the current user is unregistered, returns True if there are no + products in the TICKET_PRODUCT_CATEGORY that are available to that user. + + If there *are* products available, the return False. + + If the current user *is* registered, then return None (it's not a + pertinent question for people who already have a ticket). + + ''' + + user = user_for_context(context) + if hasattr(user, "attendee") and user.attendee.completed_registration: + # This user has completed registration, and so we don't need to answer + # whether they have sold out yet. + + # TODO: what if a user has got to the review phase? + # currently that user will hit the review page, click "Check out and + # pay", and that will fail. Probably good enough for now. + + return None + + ticket_category = settings.TICKET_PRODUCT_CATEGORY + categories = available_categories(context) + + return ticket_category not in [cat.id for cat in categories] + + +class IncludeNode(template.Node): + ''' https://djangosnippets.org/snippets/2058/ ''' + + + def __init__(self, template_name): + # template_name as passed in includes quotmarks? + # strip them from the start and end + self.template_name = template_name[1:-1] + + def render(self, context): + try: + # Loading the template and rendering it + return template.loader.render_to_string( + self.template_name, context=context, + ) + except template.TemplateDoesNotExist: + return "" + + +@register.tag +def include_if_exists(parser, token): + """Usage: {% include_if_exists "head.html" %} + + This will fail silently if the template doesn't exist. If it does, it will + be rendered with the current context. + + From: https://djangosnippets.org/snippets/2058/ + """ + try: + tag_name, template_name = token.split_contents() + except ValueError: + raise (template.TemplateSyntaxError, + "%r tag requires a single argument" % token.contents.split()[0]) + + return IncludeNode(template_name) diff --git a/vendor/registrasion/registrasion/tests/test_cart.py b/vendor/registrasion/registrasion/tests/test_cart.py index d8f5b4c0..825ff675 100644 --- a/vendor/registrasion/registrasion/tests/test_cart.py +++ b/vendor/registrasion/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[int(i / 2)], # 2 products per category + category=cls.categories[i / 2], # 2 products per category price=Decimal("10.00"), reservation_duration=cls.RESERVATION, limit_per_user=10, diff --git a/vendor/registrasion/registrasion/tests/test_invoice.py b/vendor/registrasion/registrasion/tests/test_invoice.py index 0c650dd4..b77fa223 100644 --- a/vendor/registrasion/registrasion/tests/test_invoice.py +++ b/vendor/registrasion/registrasion/tests/test_invoice.py @@ -98,11 +98,7 @@ class InvoiceTestCase(TestHelperMixin, RegistrationCartTestCase): def test_total_payments_balance_due(self): invoice = self._invoice_containing_prod_1(2) - # range only takes int, and the following logic fails if not a round - # number. So fail if we are not a round number so developer may fix - # this test or the product. - self.assertTrue((invoice.invoice.value % 1).is_zero()) - for i in range(0, int(invoice.invoice.value)): + for i in xrange(0, invoice.invoice.value): self.assertTrue( i + 1, invoice.invoice.total_payments() ) diff --git a/vendor/registrasion/registrasion/tests/test_speaker.py b/vendor/registrasion/registrasion/tests/test_speaker.py index f31266b6..cf64074e 100644 --- a/vendor/registrasion/registrasion/tests/test_speaker.py +++ b/vendor/registrasion/registrasion/tests/test_speaker.py @@ -67,7 +67,7 @@ class SpeakerTestCase(RegistrationCartTestCase): kind=kind_1, title="Proposal 1", abstract="Abstract", - private_abstract="Private Abstract", + description="Description", speaker=speaker_1, ) proposal_models.AdditionalSpeaker.objects.create( @@ -80,7 +80,7 @@ class SpeakerTestCase(RegistrationCartTestCase): kind=kind_2, title="Proposal 2", abstract="Abstract", - private_abstract="Private Abstract", + description="Description", speaker=speaker_1, ) proposal_models.AdditionalSpeaker.objects.create( diff --git a/vendor/registrasion/registrasion/urls.py b/vendor/registrasion/registrasion/urls.py index 0789912d..6afdd5f0 100644 --- a/vendor/registrasion/registrasion/urls.py +++ b/vendor/registrasion/registrasion/urls.py @@ -1,4 +1,4 @@ -from registrasion.reporting import views as rv +from .reporting import views as rv from django.conf.urls import include from django.conf.urls import url @@ -19,6 +19,7 @@ from .views import ( product_category, refund, review, + voucher_code, ) @@ -43,6 +44,7 @@ public = [ url(r"^profile$", edit_profile, name="attendee_edit"), url(r"^register$", guided_registration, name="guided_registration"), url(r"^review$", review, name="review"), + url(r"^voucher$", voucher_code, name="voucher_code"), url(r"^register/([0-9]+)$", guided_registration, name="guided_registration"), ] @@ -55,6 +57,11 @@ reports = [ 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"^product_line_items/?$", + rv.product_line_items, + name="product_line_items", + ), url(r"^discount_status/?$", rv.discount_status, name="discount_status"), url(r"^invoices/?$", rv.invoices, name="invoices"), url( diff --git a/vendor/registrasion/registrasion/util.py b/vendor/registrasion/registrasion/util.py index 98079c7e..b5fa0620 100644 --- a/vendor/registrasion/registrasion/util.py +++ b/vendor/registrasion/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.ascii_uppercase + string.digits[1:] + chars = string.uppercase + string.digits[1:] # 6 chars => 35 ** 6 = 1838265625 (should be enough for anyone) return get_random_string(length=length, allowed_chars=chars) diff --git a/vendor/registrasion/registrasion/views.py b/vendor/registrasion/registrasion/views.py index 239e924b..e16313f2 100644 --- a/vendor/registrasion/registrasion/views.py +++ b/vendor/registrasion/registrasion/views.py @@ -1,19 +1,20 @@ import datetime import zipfile -from registrasion import forms -from registrasion import util -from registrasion.models import commerce -from registrasion.models import inventory -from registrasion.models import people -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.item import ItemController -from registrasion.controllers.product import ProductController -from registrasion.exceptions import CartValidationError +from . import forms +from . import util +from .models import commerce +from .models import inventory +from .models import people +from .controllers.batch import BatchController +from .controllers.cart import CartController +from .controllers.category import CategoryController +from .controllers.credit_note import CreditNoteController +from .controllers.discount import DiscountController +from .controllers.invoice import InvoiceController +from .controllers.item import ItemController +from .controllers.product import ProductController +from .exceptions import CartValidationError from collections import namedtuple @@ -64,12 +65,19 @@ class GuidedRegistrationSection(_GuidedRegistrationSection): @login_required -def guided_registration(request): +def guided_registration(request, page_number=None): ''' Goes through the registration process in order, making sure user sees all valid categories. The user must be logged in to see this view. + Parameter: + page_number: + 1) Profile form (and e-mail address?) + 2) Ticket type + 3) Remaining products + 4) Mark registration as complete + Returns: render: Renders ``registrasion/guided_registration.html``, with the following data:: @@ -85,148 +93,225 @@ def guided_registration(request): ''' - SESSION_KEY = "guided_registration_categories" - ASK_FOR_PROFILE = 777 # Magic number. Meh. + PAGE_PROFILE = 1 + PAGE_TICKET = 2 + PAGE_PRODUCTS = 3 + PAGE_PRODUCTS_MAX = 4 + TOTAL_PAGES = 4 - next_step = redirect("guided_registration") - - sections = [] + ticket_category = inventory.Category.objects.get( + id=settings.TICKET_PRODUCT_CATEGORY + ) + cart = CartController.for_user(request.user) attendee = people.Attendee.get_instance(request.user) + # This guided registration process is only for people who have + # not completed registration (and has confusing behaviour if you go + # back to it.) if attendee.completed_registration: return redirect(review) - # Step 1: Fill in a badge and collect a voucher code - try: - profile = attendee.attendeeprofilebase - except ObjectDoesNotExist: - profile = None - - # 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 + # Calculate the current maximum page number for this user. + has_profile = hasattr(attendee, "attendeeprofilebase") + if not has_profile: + # If there's no profile, they have to go to the profile page. + max_page = PAGE_PROFILE + redirect_page = PAGE_PROFILE 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") - - voucher_section = GuidedRegistrationSection( - title="Voucher Code", - form=voucher_form, + # We have a profile. + # Do they have a ticket? + products = inventory.Product.objects.filter( + productitem__cart=cart.cart ) + products = products.filter(category=ticket_category) - 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 - - starting = attendee.guided_categories_complete.count() == 0 - - # Get the next category - cats = inventory.Category.objects - if SESSION_KEY in request.session: - _cats = request.session[SESSION_KEY] - cats = cats.filter(id__in=_cats) + if products.count() == 0: + # If no ticket, they can only see the profile or ticket page. + max_page = PAGE_TICKET + redirect_page = PAGE_TICKET else: - cats = cats.exclude( - id__in=attendee.guided_categories_complete.all(), + # If there's a ticket, they should *see* the general products page# + # but be able to go to the overflow page if needs be. + max_page = PAGE_PRODUCTS_MAX + redirect_page = PAGE_PRODUCTS + + if page_number is None or int(page_number) > max_page: + return redirect("guided_registration", redirect_page) + + page_number = int(page_number) + + next_step = redirect("guided_registration", page_number + 1) + + with BatchController.batch(request.user): + + # This view doesn't work if the conference has sold out. + available = ProductController.available_products( + request.user, category=ticket_category + ) + if not available: + messages.error(request, "There are no more tickets available.") + return redirect("dashboard") + + sections = [] + + # Build up the list of sections + if page_number == PAGE_PROFILE: + # Profile bit + title = "Attendee information" + sections = _guided_registration_profile_and_voucher(request) + elif page_number == PAGE_TICKET: + # Select ticket + title = "Select ticket type" + sections = _guided_registration_products( + request, GUIDED_MODE_TICKETS_ONLY + ) + elif page_number == PAGE_PRODUCTS: + # Select additional items + title = "Additional items" + sections = _guided_registration_products( + request, GUIDED_MODE_ALL_ADDITIONAL + ) + elif page_number == PAGE_PRODUCTS_MAX: + # Items enabled by things on page 3 -- only shows things + # that have not been marked as complete. + title = "More additional items" + sections = _guided_registration_products( + request, GUIDED_MODE_EXCLUDE_COMPLETE ) - cats = cats.order_by("order") + if not sections: + # We've filled in every category + attendee.completed_registration = True + attendee.save() + return redirect("review") - request.session[SESSION_KEY] = [] - - if starting: - # 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" - - all_products = inventory.Product.objects.filter( - category__in=cats, - ).select_related("category") - - 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() + if sections and request.method == "POST": + for section in sections: + if section.form.errors: + break + else: + # We've successfully processed everything return next_step - 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 - - 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 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: - if section.form.errors: - break - else: - attendee.save() - if SESSION_KEY in request.session: - del request.session[SESSION_KEY] - # We've successfully processed everything - return next_step - data = { - "current_step": current_step, + "current_step": page_number, "sections": sections, "title": title, - "total_steps": 3, + "total_steps": TOTAL_PAGES, } return render(request, "registrasion/guided_registration.html", data) +GUIDED_MODE_TICKETS_ONLY = 2 +GUIDED_MODE_ALL_ADDITIONAL = 3 +GUIDED_MODE_EXCLUDE_COMPLETE = 4 + + +@login_required +def _guided_registration_products(request, mode): + sections = [] + + SESSION_KEY = "guided_registration" + MODE_KEY = "mode" + CATS_KEY = "cats" + + attendee = people.Attendee.get_instance(request.user) + + # Get the next category + cats = inventory.Category.objects.order_by("order") # TODO: default order? + + # Fun story: If _any_ of the category forms result in an error, but other + # new products get enabled with a flag, those new products will appear. + # We need to make sure that we only display the products that were valid + # in the first place. So we track them in a session, and refresh only if + # the page number does not change. Cheap! + + if SESSION_KEY in request.session: + session_struct = request.session[SESSION_KEY] + old_mode = session_struct[MODE_KEY] + old_cats = session_struct[CATS_KEY] + else: + old_mode = None + old_cats = [] + + if mode == old_mode: + cats = cats.filter(id__in=old_cats) + elif mode == GUIDED_MODE_TICKETS_ONLY: + cats = cats.filter(id=settings.TICKET_PRODUCT_CATEGORY) + elif mode == GUIDED_MODE_ALL_ADDITIONAL: + cats = cats.exclude(id=settings.TICKET_PRODUCT_CATEGORY) + elif mode == GUIDED_MODE_EXCLUDE_COMPLETE: + cats = cats.exclude(id=settings.TICKET_PRODUCT_CATEGORY) + cats = cats.exclude(id__in=old_cats) + + # We update the session key at the end of this method + # once we've found all the categories that have available products + + all_products = inventory.Product.objects.filter( + category__in=cats, + ).select_related("category") + + seen_categories = [] + + with BatchController.batch(request.user): + available_products = set(ProductController.available_products( + request.user, + products=all_products, + )) + + if len(available_products) == 0: + return [] + + has_errors = False + + 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 + + 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) + seen_categories.append(category) + + # Update the cache with the newly calculated values + cat_ids = [cat.id for cat in seen_categories] + request.session[SESSION_KEY] = {MODE_KEY: mode, CATS_KEY: cat_ids} + + return sections + + +@login_required +def _guided_registration_profile_and_voucher(request): + 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, + ) + + return [voucher_section, profile_section] + + @login_required def review(request): ''' View for the review page. ''' @@ -399,6 +484,28 @@ def product_category(request, category_id): return render(request, "registrasion/product_category.html", data) +def voucher_code(request): + ''' A view *just* for entering a voucher form. ''' + + 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 + + if voucher_handled: + messages.success(request, "Your voucher code was accepted.") + return redirect("dashboard") + + data = { + "voucher_form": voucher_form, + } + + return render(request, "registrasion/voucher_code.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