Merge branch 'chrisjrn/reports_20160919'
This commit is contained in:
commit
bcd7043862
7 changed files with 362 additions and 62 deletions
|
@ -46,9 +46,13 @@ Because every conference is different, Registrasion lets you define your own att
|
|||
.. 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 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"
|
||||
|
||||
|
|
|
@ -307,6 +307,10 @@ class CreditNote(PaymentBase):
|
|||
creditnoterefund=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def refunded(cls):
|
||||
return cls.objects.exclude(creditnoterefund=None)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
if self.is_unclaimed:
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
from registrasion.models import conditions
|
||||
from registrasion.models import inventory
|
||||
|
||||
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):
|
||||
|
@ -21,3 +29,22 @@ class UserIdForm(forms.Form):
|
|||
label="User ID",
|
||||
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
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
import forms
|
||||
|
||||
import collections
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
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.shortcuts import render
|
||||
|
||||
from registrasion.controllers.item import ItemController
|
||||
from registrasion.models import commerce
|
||||
from registrasion.models import people
|
||||
from registrasion import util
|
||||
from registrasion import views
|
||||
|
||||
from reports import get_all_reports
|
||||
|
@ -24,6 +29,9 @@ def CURRENCY():
|
|||
return models.DecimalField(decimal_places=2)
|
||||
|
||||
|
||||
AttendeeProfile = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL)
|
||||
|
||||
|
||||
@user_passes_test(views._staff_only)
|
||||
def reports_list(request):
|
||||
''' Lists all of the reports currently available. '''
|
||||
|
@ -48,20 +56,27 @@ def reports_list(request):
|
|||
|
||||
# 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)
|
||||
def items_sold(request, form):
|
||||
return [
|
||||
sales_payment_summary(),
|
||||
items_sold(),
|
||||
payments(),
|
||||
credit_note_refunds(),
|
||||
]
|
||||
|
||||
|
||||
def items_sold():
|
||||
''' Summarises the items sold and discounts granted for a given set of
|
||||
products, or products from categories. '''
|
||||
|
||||
data = None
|
||||
headings = None
|
||||
|
||||
products = form.cleaned_data["product"]
|
||||
categories = form.cleaned_data["category"]
|
||||
|
||||
line_items = commerce.LineItem.objects.filter(
|
||||
Q(product__in=products) | Q(product__category__in=categories),
|
||||
invoice__status=commerce.Invoice.STATUS_PAID,
|
||||
).select_related("invoice")
|
||||
|
||||
|
@ -92,17 +107,23 @@ def items_sold(request, form):
|
|||
"(TOTAL)", "--", "--", total_income,
|
||||
])
|
||||
|
||||
return ListReport("Paid items", headings, data)
|
||||
return ListReport("Items sold", headings, data)
|
||||
|
||||
|
||||
@report_view("Reconcilitation")
|
||||
def reconciliation(request, form):
|
||||
''' Reconciles all sales in the system with the payments in the
|
||||
system. '''
|
||||
def sales_payment_summary():
|
||||
''' Summarises paid items and payments. '''
|
||||
|
||||
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 = []
|
||||
|
||||
# Summarise all sales made (= income.)
|
||||
sales = commerce.LineItem.objects.filter(
|
||||
invoice__status=commerce.Invoice.STATUS_PAID,
|
||||
).values(
|
||||
|
@ -110,42 +131,63 @@ def reconciliation(request, form):
|
|||
).aggregate(
|
||||
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(
|
||||
"amount",
|
||||
).aggregate(total=Sum("amount"))
|
||||
# Manual payments
|
||||
# Credit notes generated (total)
|
||||
# Payments made by credit note
|
||||
# Claimed credit notes
|
||||
|
||||
data.append(["Payments", payments["total"]])
|
||||
|
||||
ucn = commerce.CreditNote.unclaimed().values(
|
||||
"amount"
|
||||
).aggregate(total=Sum("amount"))
|
||||
|
||||
data.append(["Unclaimed credit notes", 0 - ucn["total"]])
|
||||
all_credit_notes = 0 - sum_amount(commerce.CreditNote.objects.all())
|
||||
unclaimed_credit_notes = 0 - sum_amount(commerce.CreditNote.unclaimed())
|
||||
claimed_credit_notes = sum_amount(
|
||||
commerce.CreditNoteApplication.objects.all()
|
||||
)
|
||||
refunded_credit_notes = 0 - sum_amount(commerce.CreditNote.refunded())
|
||||
|
||||
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([
|
||||
"(Money not on invoices)",
|
||||
sales["total"] - payments["total"] - ucn["total"],
|
||||
"Credit notes - (claimed credit notes + unclaimed credit notes)",
|
||||
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 product_status(request, form):
|
||||
''' Summarises the inventory status of the given items, grouping by
|
||||
invoice status. '''
|
||||
def payments():
|
||||
''' Shows the history of payments into the system '''
|
||||
|
||||
products = form.cleaned_data["product"]
|
||||
categories = form.cleaned_data["category"]
|
||||
payments = commerce.PaymentBase.objects.all()
|
||||
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(
|
||||
When(cart__in=commerce.Cart.reserved_carts(), then=Value(True)),
|
||||
default=Value(False),
|
||||
|
@ -153,14 +195,8 @@ def product_status(request, form):
|
|||
),
|
||||
)
|
||||
|
||||
items = items.order_by(
|
||||
"product__category__order",
|
||||
"product__order",
|
||||
).values(
|
||||
"product",
|
||||
"product__category__name",
|
||||
"product__name",
|
||||
).annotate(
|
||||
values = queryset.order_by(*order).values(*values)
|
||||
values = values.annotate(
|
||||
total_paid=Sum(Case(
|
||||
When(
|
||||
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 = [
|
||||
"Product", "Paid", "Reserved", "Unreserved", "Refunded",
|
||||
]
|
||||
|
@ -216,6 +273,77 @@ def product_status(request, form):
|
|||
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")
|
||||
def credit_notes(request, form):
|
||||
''' 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)
|
||||
def attendee(request, form, user_id=None):
|
||||
''' Returns a list of all manifested attendees if no attendee is specified,
|
||||
|
@ -252,6 +386,22 @@ def attendee(request, form, user_id=None):
|
|||
|
||||
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.append((
|
||||
reverse(views.amend_registration, args=[user_id]),
|
||||
|
@ -340,9 +490,98 @@ def attendee_list(request):
|
|||
# Sort by whether they've registered, then ID.
|
||||
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
|
||||
|
|
|
@ -43,9 +43,15 @@ public = [
|
|||
reports = [
|
||||
url(r"^$", rv.reports_list, name="reports_list"),
|
||||
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"^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"^reconciliation/?$", rv.reconciliation, name="reconciliation"),
|
||||
]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import string
|
||||
import sys
|
||||
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
|
@ -55,3 +56,19 @@ def lazy(function, *args, **kwargs):
|
|||
return retval[0]
|
||||
|
||||
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)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import sys
|
||||
import util
|
||||
|
||||
from registrasion import forms
|
||||
from registrasion import util
|
||||
|
@ -16,6 +17,7 @@ from registrasion.exceptions import CartValidationError
|
|||
|
||||
from collections import namedtuple
|
||||
|
||||
from django import forms as django_forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
|
@ -59,13 +61,6 @@ class GuidedRegistrationSection(_GuidedRegistrationSection):
|
|||
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
|
||||
def guided_registration(request):
|
||||
''' 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)
|
||||
|
||||
|
||||
# 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):
|
||||
''' Returns a profile form instance, and a boolean which is true if the
|
||||
form was handled. '''
|
||||
|
@ -287,8 +292,6 @@ def _handle_profile(request, prefix):
|
|||
except ObjectDoesNotExist:
|
||||
profile = None
|
||||
|
||||
ProfileForm = get_form(settings.ATTENDEE_PROFILE_FORM)
|
||||
|
||||
# Load a pre-entered name from the speaker's profile,
|
||||
# if they have one.
|
||||
try:
|
||||
|
|
Loading…
Reference in a new issue