Merge branch 'documentation'

This commit is contained in:
Christopher Neugebauer 2016-04-25 13:25:11 +10:00
commit 203a4a2212
11 changed files with 373 additions and 62 deletions

View file

@ -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.

View file

@ -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`

37
docs/integration.rst Normal file
View file

@ -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.

View file

@ -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

16
docs/views.rst Normal file
View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,