Merge branch 'chrisjrn/reports_20160919'

This commit is contained in:
Christopher Neugebauer 2016-09-20 13:48:34 +10:00
commit bcd7043862
7 changed files with 362 additions and 62 deletions

View file

@ -46,9 +46,13 @@ Because every conference is different, Registrasion lets you define your own att
.. autoclass :: AttendeeProfileBase .. autoclass :: AttendeeProfileBase
:members: name_field, invoice_recipient :members: name_field, invoice_recipient
Once you've subclassed ``AttendeeProfileBase``, you'll need to implement a form that lets attendees fill out their profile. You specify how to find that model in your Django ``settings.py`` file::
You specify how to find that form in your Django ``settings.py`` file:: ATTENDEE_PROFILE_MODEL = "democon.models.AttendeeProfile"
When Registrasion asks the to edit their profile, a default form will be generated, showing all of the fields on the profile model.
If you want to customise the profile editing form, you need to specify the location of that form in your ``settings.py`` file as well.
ATTENDEE_PROFILE_FORM = "democon.forms.AttendeeProfileForm" ATTENDEE_PROFILE_FORM = "democon.forms.AttendeeProfileForm"

View file

@ -307,6 +307,10 @@ class CreditNote(PaymentBase):
creditnoterefund=None, creditnoterefund=None,
) )
@classmethod
def refunded(cls):
return cls.objects.exclude(creditnoterefund=None)
@property @property
def status(self): def status(self):
if self.is_unclaimed: if self.is_unclaimed:

View file

@ -1,8 +1,16 @@
from registrasion.models import conditions
from registrasion.models import inventory from registrasion.models import inventory
from django import forms from django import forms
# Staff-facing forms. # Reporting forms.
class DiscountForm(forms.Form):
discount = forms.ModelMultipleChoiceField(
queryset=conditions.DiscountBase.objects.all(),
required=False,
)
class ProductAndCategoryForm(forms.Form): class ProductAndCategoryForm(forms.Form):
@ -21,3 +29,22 @@ class UserIdForm(forms.Form):
label="User ID", label="User ID",
required=False, required=False,
) )
def model_fields_form_factory(model):
''' Creates a form for specifying fields from a model to display. '''
fields = model._meta.get_fields()
choices = []
for field in fields:
if hasattr(field, "verbose_name"):
choices.append((field.name, field.verbose_name))
class ModelFieldsForm(forms.Form):
fields = forms.MultipleChoiceField(
choices=choices,
required=False,
)
return ModelFieldsForm

View file

@ -1,16 +1,21 @@
import forms import forms
import collections
import datetime
from django.conf import settings
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models import Count, Sum from django.db.models import Count, Max, Sum
from django.db.models import Case, When, Value from django.db.models import Case, When, Value
from django.shortcuts import render from django.shortcuts import render
from registrasion.controllers.item import ItemController from registrasion.controllers.item import ItemController
from registrasion.models import commerce from registrasion.models import commerce
from registrasion.models import people from registrasion.models import people
from registrasion import util
from registrasion import views from registrasion import views
from reports import get_all_reports from reports import get_all_reports
@ -24,6 +29,9 @@ def CURRENCY():
return models.DecimalField(decimal_places=2) return models.DecimalField(decimal_places=2)
AttendeeProfile = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL)
@user_passes_test(views._staff_only) @user_passes_test(views._staff_only)
def reports_list(request): def reports_list(request):
''' Lists all of the reports currently available. ''' ''' Lists all of the reports currently available. '''
@ -48,20 +56,27 @@ def reports_list(request):
# Report functions # Report functions
@report_view("Reconcilitation")
def reconciliation(request, form):
''' Shows the summary of sales, and the full history of payments and
refunds into the system. '''
@report_view("Paid items", form_type=forms.ProductAndCategoryForm) return [
def items_sold(request, form): sales_payment_summary(),
items_sold(),
payments(),
credit_note_refunds(),
]
def items_sold():
''' Summarises the items sold and discounts granted for a given set of ''' Summarises the items sold and discounts granted for a given set of
products, or products from categories. ''' products, or products from categories. '''
data = None data = None
headings = None headings = None
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
line_items = commerce.LineItem.objects.filter( line_items = commerce.LineItem.objects.filter(
Q(product__in=products) | Q(product__category__in=categories),
invoice__status=commerce.Invoice.STATUS_PAID, invoice__status=commerce.Invoice.STATUS_PAID,
).select_related("invoice") ).select_related("invoice")
@ -92,17 +107,23 @@ def items_sold(request, form):
"(TOTAL)", "--", "--", total_income, "(TOTAL)", "--", "--", total_income,
]) ])
return ListReport("Paid items", headings, data) return ListReport("Items sold", headings, data)
@report_view("Reconcilitation") def sales_payment_summary():
def reconciliation(request, form): ''' Summarises paid items and payments. '''
''' Reconciles all sales in the system with the payments in the
system. '''
headings = ["Thing", "Total"] def value_or_zero(aggregate, key):
return aggregate[key] or 0
def sum_amount(payment_set):
a = payment_set.values("amount").aggregate(total=Sum("amount"))
return value_or_zero(a, "total")
headings = ["Category", "Total"]
data = [] data = []
# Summarise all sales made (= income.)
sales = commerce.LineItem.objects.filter( sales = commerce.LineItem.objects.filter(
invoice__status=commerce.Invoice.STATUS_PAID, invoice__status=commerce.Invoice.STATUS_PAID,
).values( ).values(
@ -110,42 +131,63 @@ def reconciliation(request, form):
).aggregate( ).aggregate(
total=Sum(F("price") * F("quantity"), output_field=CURRENCY()), total=Sum(F("price") * F("quantity"), output_field=CURRENCY()),
) )
sales = value_or_zero(sales, "total")
data.append(["Paid items", sales["total"]]) all_payments = sum_amount(commerce.PaymentBase.objects.all())
payments = commerce.PaymentBase.objects.values( # Manual payments
"amount", # Credit notes generated (total)
).aggregate(total=Sum("amount")) # Payments made by credit note
# Claimed credit notes
data.append(["Payments", payments["total"]]) all_credit_notes = 0 - sum_amount(commerce.CreditNote.objects.all())
unclaimed_credit_notes = 0 - sum_amount(commerce.CreditNote.unclaimed())
ucn = commerce.CreditNote.unclaimed().values( claimed_credit_notes = sum_amount(
"amount" commerce.CreditNoteApplication.objects.all()
).aggregate(total=Sum("amount")) )
refunded_credit_notes = 0 - sum_amount(commerce.CreditNote.refunded())
data.append(["Unclaimed credit notes", 0 - ucn["total"]])
data.append(["Items on paid invoices", sales])
data.append(["All payments", all_payments])
data.append(["Sales - Payments ", sales - all_payments])
data.append(["All credit notes", all_credit_notes])
data.append(["Credit notes paid on invoices", claimed_credit_notes])
data.append(["Credit notes refunded", refunded_credit_notes])
data.append(["Unclaimed credit notes", unclaimed_credit_notes])
data.append([ data.append([
"(Money not on invoices)", "Credit notes - (claimed credit notes + unclaimed credit notes)",
sales["total"] - payments["total"] - ucn["total"], all_credit_notes - claimed_credit_notes -
refunded_credit_notes - unclaimed_credit_notes,
]) ])
return ListReport("Sales and Payments", headings, data) return ListReport("Sales and Payments Summary", headings, data)
@report_view("Product status", form_type=forms.ProductAndCategoryForm) def payments():
def product_status(request, form): ''' Shows the history of payments into the system '''
''' Summarises the inventory status of the given items, grouping by
invoice status. '''
products = form.cleaned_data["product"] payments = commerce.PaymentBase.objects.all()
categories = form.cleaned_data["category"] return QuerysetReport(
"Payments",
["invoice__id", "id", "reference", "amount"],
payments,
link_view=views.invoice,
)
items = commerce.ProductItem.objects.filter(
Q(product__in=products) | Q(product__category__in=categories),
).select_related("cart", "product")
items = items.annotate( def credit_note_refunds():
''' Shows all of the credit notes that have been generated. '''
notes_refunded = commerce.CreditNote.refunded()
return QuerysetReport(
"Credit note refunds",
["id", "creditnoterefund__reference", "amount"],
notes_refunded,
link_view=views.credit_note,
)
def group_by_cart_status(queryset, order, values):
queryset = queryset.annotate(
is_reserved=Case( is_reserved=Case(
When(cart__in=commerce.Cart.reserved_carts(), then=Value(True)), When(cart__in=commerce.Cart.reserved_carts(), then=Value(True)),
default=Value(False), default=Value(False),
@ -153,14 +195,8 @@ def product_status(request, form):
), ),
) )
items = items.order_by( values = queryset.order_by(*order).values(*values)
"product__category__order", values = values.annotate(
"product__order",
).values(
"product",
"product__category__name",
"product__name",
).annotate(
total_paid=Sum(Case( total_paid=Sum(Case(
When( When(
cart__status=commerce.Cart.STATUS_PAID, cart__status=commerce.Cart.STATUS_PAID,
@ -197,6 +233,27 @@ def product_status(request, form):
)), )),
) )
return values
@report_view("Product status", form_type=forms.ProductAndCategoryForm)
def product_status(request, form):
''' Summarises the inventory status of the given items, grouping by
invoice status. '''
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
items = commerce.ProductItem.objects.filter(
Q(product__in=products) | Q(product__category__in=categories),
).select_related("cart", "product")
items = group_by_cart_status(
items,
["product__category__order", "product__order"],
["product", "product__category__name", "product__name"],
)
headings = [ headings = [
"Product", "Paid", "Reserved", "Unreserved", "Refunded", "Product", "Paid", "Reserved", "Unreserved", "Refunded",
] ]
@ -216,6 +273,77 @@ def product_status(request, form):
return ListReport("Inventory", headings, data) return ListReport("Inventory", headings, data)
@report_view("Product status", form_type=forms.DiscountForm)
def discount_status(request, form):
''' Summarises the usage of a given discount. '''
discounts = form.cleaned_data["discount"]
items = commerce.DiscountItem.objects.filter(
Q(discount__in=discounts),
).select_related("cart", "product", "product__category")
items = group_by_cart_status(
items,
["discount",],
["discount", "discount__description",],
)
headings = [
"Discount", "Paid", "Reserved", "Unreserved", "Refunded",
]
data = []
for item in items:
data.append([
item["discount__description"],
item["total_paid"],
item["total_reserved"],
item["total_unreserved"],
item["total_refunded"],
])
return ListReport("Usage by item", headings, data)
@report_view("Paid invoices by date", form_type=forms.ProductAndCategoryForm)
def paid_invoices_by_date(request, form):
''' Shows the number of paid invoices containing given products or
categories per day. '''
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
invoices = commerce.Invoice.objects.filter(
Q(lineitem__product__in=products) | Q(lineitem__product__category__in=categories),
status=commerce.Invoice.STATUS_PAID,
)
payments = commerce.PaymentBase.objects.all()
payments = payments.filter(
invoice__in=invoices,
)
payments = payments.order_by("invoice")
invoice_max_time = payments.values("invoice").annotate(max_time=Max("time"))
by_date = collections.defaultdict(int)
for line in invoice_max_time:
time = line["max_time"]
date = datetime.datetime(
year=time.year, month=time.month, day=time.day
)
by_date[date] += 1
data = [(date, count) for date, count in sorted(by_date.items())]
data = [(date.strftime("%Y-%m-%d"), count) for date, count in data]
return ListReport(
"Paid Invoices By Date",
["date", "count"],
data,
)
@report_view("Credit notes") @report_view("Credit notes")
def credit_notes(request, form): def credit_notes(request, form):
''' Shows all of the credit notes in the system. ''' ''' Shows all of the credit notes in the system. '''
@ -236,6 +364,12 @@ def credit_notes(request, form):
) )
class AttendeeListReport(ListReport):
def get_link(self, argument):
return reverse(self._link_view) + "?user=%d" % int(argument)
@report_view("Attendee", form_type=forms.UserIdForm) @report_view("Attendee", form_type=forms.UserIdForm)
def attendee(request, form, user_id=None): def attendee(request, form, user_id=None):
''' Returns a list of all manifested attendees if no attendee is specified, ''' Returns a list of all manifested attendees if no attendee is specified,
@ -252,6 +386,22 @@ def attendee(request, form, user_id=None):
reports = [] reports = []
profile_data = []
profile = people.AttendeeProfileBase.objects.get_subclass(
attendee=attendee
)
exclude = set(["attendeeprofilebase_ptr", "id"])
for field in profile._meta.get_fields():
if field.name in exclude:
# Not actually important
continue
if not hasattr(field, "verbose_name"):
continue # Not a publicly visible field
value = getattr(profile, field.name)
profile_data.append((field.verbose_name, value))
reports.append(ListReport("Profile", ["", ""], profile_data))
links = [] links = []
links.append(( links.append((
reverse(views.amend_registration, args=[user_id]), reverse(views.amend_registration, args=[user_id]),
@ -340,9 +490,98 @@ def attendee_list(request):
# Sort by whether they've registered, then ID. # Sort by whether they've registered, then ID.
data.sort(key=lambda a: (-a[3], a[0])) data.sort(key=lambda a: (-a[3], a[0]))
class Report(ListReport): return AttendeeListReport("Attendees", headings, data, link_view=attendee)
def get_link(self, argument):
return reverse(self._link_view) + "?user=%d" % int(argument)
return Report("Attendees", headings, data, link_view=attendee) ProfileForm = forms.model_fields_form_factory(AttendeeProfile)
class ProductCategoryProfileForm(forms.ProductAndCategoryForm, ProfileForm):
pass
@report_view(
"Attendees By Product/Category",
form_type=ProductCategoryProfileForm,
)
def attendee_data(request, form, user_id=None):
''' Lists attendees for a given product/category selection along with
profile data.'''
status_display = {
commerce.Cart.STATUS_ACTIVE: "Unpaid",
commerce.Cart.STATUS_PAID: "Paid",
commerce.Cart.STATUS_RELEASED: "Refunded",
}
output = []
products = form.cleaned_data["product"]
categories = form.cleaned_data["category"]
fields = form.cleaned_data["fields"]
name_field = AttendeeProfile.name_field()
items = commerce.ProductItem.objects.filter(
Q(product__in=products) | Q(product__category__in=categories),
).exclude(
cart__status=commerce.Cart.STATUS_RELEASED
).select_related(
"cart", "product"
).order_by("cart__status")
# Get all of the relevant attendee profiles in one hit.
profiles = AttendeeProfile.objects.filter(
attendee__user__cart__productitem__in=items
).select_related("attendee__user")
by_user = {}
for profile in profiles:
by_user[profile.attendee.user] = profile
for field in fields:
field_verbose = AttendeeProfile._meta.get_field(field).verbose_name
cart = "attendee__user__cart"
cart_status = cart + "__status"
product = cart + "__productitem__product"
product_name = product + "__name"
category_name = product + "__category__name"
p = profiles.order_by(product, field).values(
cart_status, product, product_name, category_name, field
).annotate(count=Count("id"))
output.append(ListReport(
"Grouped by %s" % field_verbose,
["Product", "Status", field_verbose, "count"],
[
(
"%s - %s" % (i[category_name], i[product_name]),
status_display[i[cart_status]],
i[field],
i["count"] or 0,
)
for i in p
],
))
# DO the report for individual attendees
field_names = [
AttendeeProfile._meta.get_field(field).verbose_name for field in fields
]
headings = ["User ID", "Name", "Product", "Item Status"] + field_names
data = []
for item in items:
profile = by_user[item.cart.user]
line = [
item.cart.user.id,
getattr(profile, name_field),
item.product,
status_display[item.cart.status],
] + [
getattr(profile, field) for field in fields
]
data.append(line)
output.append(AttendeeListReport(
"Attendees by item with profile data", headings, data, link_view=attendee
))
return output

View file

@ -43,9 +43,15 @@ public = [
reports = [ reports = [
url(r"^$", rv.reports_list, name="reports_list"), url(r"^$", rv.reports_list, name="reports_list"),
url(r"^attendee/?$", rv.attendee, name="attendee"), url(r"^attendee/?$", rv.attendee, name="attendee"),
url(r"^attendee_data/?$", rv.attendee_data, name="attendee_data"),
url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"), url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"),
url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"),
url(r"^items_sold/?$", rv.items_sold, name="items_sold"), url(r"^discount_status/?$", rv.discount_status, name="discount_status"),
url(
r"^paid_invoices_by_date/?$",
rv.paid_invoices_by_date,
name="paid_invoices_by_date"
),
url(r"^product_status/?$", rv.product_status, name="product_status"), url(r"^product_status/?$", rv.product_status, name="product_status"),
url(r"^reconciliation/?$", rv.reconciliation, name="reconciliation"), url(r"^reconciliation/?$", rv.reconciliation, name="reconciliation"),
] ]

View file

@ -1,4 +1,5 @@
import string import string
import sys
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@ -55,3 +56,19 @@ def lazy(function, *args, **kwargs):
return retval[0] return retval[0]
return evaluate return evaluate
def get_object_from_name(name):
''' Returns the named object.
Arguments:
name (str): A string of form `package.subpackage.etc.module.property`.
This function will import `package.subpackage.etc.module` and
return `property` from that module.
'''
dot = name.rindex(".")
mod_name, property_name = name[:dot], name[dot + 1:]
__import__(mod_name)
return getattr(sys.modules[mod_name], property_name)

View file

@ -1,4 +1,5 @@
import sys import sys
import util
from registrasion import forms from registrasion import forms
from registrasion import util from registrasion import util
@ -16,6 +17,7 @@ from registrasion.exceptions import CartValidationError
from collections import namedtuple from collections import namedtuple
from django import forms as django_forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
@ -59,13 +61,6 @@ class GuidedRegistrationSection(_GuidedRegistrationSection):
pass pass
def get_form(name):
dot = name.rindex(".")
mod_name, form_name = name[:dot], name[dot + 1:]
__import__(mod_name)
return getattr(sys.modules[mod_name], form_name)
@login_required @login_required
def guided_registration(request): def guided_registration(request):
''' Goes through the registration process in order, making sure user sees ''' Goes through the registration process in order, making sure user sees
@ -274,6 +269,16 @@ def edit_profile(request):
return render(request, "registrasion/profile_form.html", data) return render(request, "registrasion/profile_form.html", data)
# Define the attendee profile form, or get a default.
try:
ProfileForm = util.get_object_from_name(settings.ATTENDEE_PROFILE_FORM)
except:
class ProfileForm(django_forms.ModelForm):
class Meta:
model = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL)
exclude = ["attendee"]
def _handle_profile(request, prefix): def _handle_profile(request, prefix):
''' Returns a profile form instance, and a boolean which is true if the ''' Returns a profile form instance, and a boolean which is true if the
form was handled. ''' form was handled. '''
@ -287,8 +292,6 @@ def _handle_profile(request, prefix):
except ObjectDoesNotExist: except ObjectDoesNotExist:
profile = None profile = None
ProfileForm = get_form(settings.ATTENDEE_PROFILE_FORM)
# Load a pre-entered name from the speaker's profile, # Load a pre-entered name from the speaker's profile,
# if they have one. # if they have one.
try: try: