From 7e74a2e0da427b99f20f44d823dc3494c228aa78 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 13:25:02 +1000 Subject: [PATCH 1/9] =?UTF-8?q?Updates=20the=20treasurer=E2=80=99s=20recon?= =?UTF-8?q?ciliation=20view=20to=20be=20MUCH=20more=20comprehensive.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/models/commerce.py | 4 ++ registrasion/reporting/views.py | 95 ++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index 0ab035d3..846d1ea6 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -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: diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 88f4eada..9de2d34f 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -48,20 +48,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") @@ -95,14 +102,20 @@ def items_sold(request, form): return ListReport("Paid items", 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,27 +123,59 @@ 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) + + +def payments(): + ''' Shows the history of payments into the system ''' + + payments = commerce.PaymentBase.objects.all() + return QuerysetReport( + "Payments", + ["invoice__id", "id", "reference", "amount"], + payments, + link_view=views.invoice, + ) + + +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, + ) @report_view("Product status", form_type=forms.ProductAndCategoryForm) From 2c99114d9fa622955fa9bb9bdd3791c53a9e48c5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 13:26:46 +1000 Subject: [PATCH 2/9] Improves wording on reconciliation report --- registrasion/reporting/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 9de2d34f..237330f7 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -99,7 +99,7 @@ def items_sold(): "(TOTAL)", "--", "--", total_income, ]) - return ListReport("Paid items", headings, data) + return ListReport("Items sold", headings, data) def sales_payment_summary(): @@ -147,7 +147,7 @@ def sales_payment_summary(): data.append(["Credit notes refunded", refunded_credit_notes]) data.append(["Unclaimed credit notes", unclaimed_credit_notes]) data.append([ - "Credit notes - claimed credit notes - unclaimed credit notes", + "Credit notes - (claimed credit notes + unclaimed credit notes)", all_credit_notes - claimed_credit_notes - refunded_credit_notes - unclaimed_credit_notes, ]) From 851c37508a1f3611deaf561261c2717f9ac4b2ec Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 13:39:39 +1000 Subject: [PATCH 3/9] Factors out annotating objects by cart status --- registrasion/reporting/views.py | 46 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 237330f7..7770d76e 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -178,19 +178,8 @@ def credit_note_refunds(): ) -@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 = items.annotate( +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), @@ -198,14 +187,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, @@ -242,6 +225,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", ] From f41bd9c65bec605e36e346c2d1a8a5e2a2f146f5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 19 Sep 2016 15:03:21 +1000 Subject: [PATCH 4/9] Adds paid invoices by date report --- registrasion/reporting/views.py | 43 ++++++++++++++++++++++++++++++++- registrasion/urls.py | 5 ++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 7770d76e..1eea40f3 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -1,10 +1,13 @@ import forms +import collections +import datetime + 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 @@ -265,6 +268,44 @@ def product_status(request, form): return ListReport("Inventory", 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. ''' diff --git a/registrasion/urls.py b/registrasion/urls.py index 4d746388..64fecbe0 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -46,6 +46,11 @@ reports = [ 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"^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"), ] From e2d027f71b093083e18d133df58c12e23196c62e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 10:33:43 +1000 Subject: [PATCH 5/9] Adds a report for consumption of a discount. Fixes #78 --- registrasion/reporting/forms.py | 10 +++++++++- registrasion/reporting/views.py | 34 +++++++++++++++++++++++++++++++++ registrasion/urls.py | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 94e99491..8543209b 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -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): diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 1eea40f3..2ec5d08f 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -268,6 +268,40 @@ 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 diff --git a/registrasion/urls.py b/registrasion/urls.py index 64fecbe0..d6850ef7 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -45,7 +45,7 @@ reports = [ url(r"^attendee/?$", 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"^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, From 6e4d2fab16ad84e5a8898bf5b3bfe87725563c0e Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 11:10:48 +1000 Subject: [PATCH 6/9] Adds ATTENDEE_PROFILE_MODEL as a thing that needs to be specified in settings.py. Fixes #65 --- docs/integration.rst | 8 ++++++-- registrasion/reporting/views.py | 1 - registrasion/views.py | 15 ++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/integration.rst b/docs/integration.rst index f8c58a64..dc012359 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -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" diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 2ec5d08f..19dd4449 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -274,7 +274,6 @@ def discount_status(request, form): discounts = form.cleaned_data["discount"] - items = commerce.DiscountItem.objects.filter( Q(discount__in=discounts), ).select_related("cart", "product", "product__category") diff --git a/registrasion/views.py b/registrasion/views.py index 9248dce4..2ee1469d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,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,7 +60,7 @@ class GuidedRegistrationSection(_GuidedRegistrationSection): pass -def get_form(name): +def get_object(name): dot = name.rindex(".") mod_name, form_name = name[:dot], name[dot + 1:] __import__(mod_name) @@ -274,6 +275,16 @@ def edit_profile(request): return render(request, "registrasion/profile_form.html", data) +# Define the attendee profile form, or get a default. +try: + ProfileForm = get_object(settings.ATTENDEE_PROFILE_FORM) +except: + class ProfileForm(django_forms.ModelForm): + class Meta: + model = get_object(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 +298,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: From 6611546a355c41378dd113e04fcc648f6532f0cd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 11:24:50 +1000 Subject: [PATCH 7/9] Moves get_object_from_name into util. --- registrasion/util.py | 17 +++++++++++++++++ registrasion/views.py | 12 +++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/registrasion/util.py b/registrasion/util.py index 54f56a1e..4d09fea4 100644 --- a/registrasion/util.py +++ b/registrasion/util.py @@ -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) diff --git a/registrasion/views.py b/registrasion/views.py index 2ee1469d..724c2aae 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,4 +1,5 @@ import sys +import util from registrasion import forms from registrasion import util @@ -60,13 +61,6 @@ class GuidedRegistrationSection(_GuidedRegistrationSection): pass -def get_object(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 @@ -277,11 +271,11 @@ def edit_profile(request): # Define the attendee profile form, or get a default. try: - ProfileForm = get_object(settings.ATTENDEE_PROFILE_FORM) + ProfileForm = util.get_object_from_name(settings.ATTENDEE_PROFILE_FORM) except: class ProfileForm(django_forms.ModelForm): class Meta: - model = get_object(settings.ATTENDEE_PROFILE_MODEL) + model = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL) exclude = ["attendee"] From e3b662fb67fd339eb9e36011b33166e6202e38b0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 12:08:03 +1000 Subject: [PATCH 8/9] Adds attendee profile data to the attendee page --- registrasion/reporting/views.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 19dd4449..3e2fa514 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -3,6 +3,7 @@ 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 @@ -14,6 +15,7 @@ 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 @@ -27,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. ''' @@ -375,6 +380,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]), From 2ed0a47f15bde1dfd1bdc4281043d21d0e99d3c9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Tue, 20 Sep 2016 13:36:49 +1000 Subject: [PATCH 9/9] Adds attendance by field report Fixes #93 --- registrasion/reporting/forms.py | 19 ++++++ registrasion/reporting/views.py | 103 ++++++++++++++++++++++++++++++-- registrasion/urls.py | 1 + 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/registrasion/reporting/forms.py b/registrasion/reporting/forms.py index 8543209b..2e983491 100644 --- a/registrasion/reporting/forms.py +++ b/registrasion/reporting/forms.py @@ -29,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 diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 3e2fa514..628102b7 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -364,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, @@ -484,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 diff --git a/registrasion/urls.py b/registrasion/urls.py index d6850ef7..d7e440df 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -43,6 +43,7 @@ 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"^discount_status/?$", rv.discount_status, name="discount_status"),