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. diff --git a/docs/index.rst b/docs/index.rst index 30fde1cc..75d0c4fa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,14 +19,17 @@ Contents: :maxdepth: 2 overview + integration inventory payments for-zookeepr-users + views Indices and tables ================== * :ref:`genindex` -.. * :ref:`modindex` * :ref:`search` + +.. * :ref:`modindex` 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..d0105afb 100644 --- a/docs/payments.rst +++ b/docs/payments.rst @@ -1,4 +1,5 @@ .. automodule:: registrasion.models.commerce +.. _payments_and_refunds: Payments and Refunds ==================== @@ -140,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 diff --git a/docs/views.rst b/docs/views.rst new file mode 100644 index 00000000..ea30f748 --- /dev/null +++ b/docs/views.rst @@ -0,0 +1,16 @@ +Public-facing views +=================== + +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/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) 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/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) 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 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 diff --git a/registrasion/views.py b/registrasion/views.py index 6a67a44b..c71cdc9d 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" @@ -90,8 +120,8 @@ def guided_registration(request, page_id=0): # 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", @@ -158,7 +188,7 @@ def guided_registration(request, page_id=0): ] 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( @@ -201,7 +231,23 @@ def guided_registration(request, page_id=0): @login_required def edit_profile(request): - form, handled = handle_profile(request, "profile") + ''' 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: messages.success( @@ -216,7 +262,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) @@ -262,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" @@ -270,7 +337,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]+ @@ -288,7 +355,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: @@ -310,7 +377,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. ''' @@ -343,7 +410,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 @@ -365,7 +432,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()) @@ -395,7 +462,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. ''' @@ -426,8 +493,25 @@ def handle_voucher(request, prefix): @login_required def checkout(request): - ''' Runs checkout for the current cart of items, ideally generating an - invoice. ''' + ''' 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. + } + + ''' current_cart = CartController.for_user(request.user) @@ -437,12 +521,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: @@ -459,7 +543,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, @@ -475,10 +572,33 @@ 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) @@ -498,7 +618,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" @@ -528,8 +669,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() @@ -545,9 +700,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: @@ -584,7 +766,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,