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/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/forms.py b/registrasion/forms.py index 54b3115a..df363d7a 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: @@ -47,9 +57,18 @@ 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", help_text="If you have a voucher code, enter it here", - required=True, + required=False, ) 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/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 96ecf219..a1101fd9 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 @@ -15,29 +16,100 @@ 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 + @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 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) + @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 - name = models.CharField(max_length=256) - company = models.CharField(max_length=256) + 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 @@ -60,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/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 @@ +
Payment time | +Reference | +Amount | +
---|---|---|
{{payment.time}} | +{{payment.reference}} | +{{payment.amount}} | +
Something something fill in your attendee details here!
+ + + + +{% endblock %} 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() 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)) diff --git a/registrasion/urls.py b/registrasion/urls.py index 746d163d..01946839 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -6,4 +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"^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 3957da7d..73d04150 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -12,9 +12,81 @@ 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, it just goes + through each category one by one + ''' + + dashboard = redirect("dashboard") + next_step = redirect("guided_registration") + + 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: + 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 + + # 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=category).order_by("order") + + 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) + 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 + + +@login_required +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() + + data = { + "form": form, + } + return render(request, "profile_form.html", data) + @login_required def product_category(request, category_id): - ''' Registration selections form for a specific category of items ''' + ''' Registration selections form for a specific category of items. + ''' PRODUCTS_FORM_PREFIX = "products" VOUCHERS_FORM_PREFIX = "vouchers" @@ -25,14 +97,17 @@ def product_category(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") 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(): + if voucher_form.is_valid() and voucher_form.cleaned_data["voucher"].strip(): # Apply voucher # leave voucher = voucher_form.cleaned_data["voucher"] @@ -40,23 +115,35 @@ 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) 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") + else: # Create initial data for each of products in category items = rego.ProductItem.objects.filter( @@ -75,15 +162,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, @@ -93,6 +175,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): @@ -129,7 +222,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)