diff --git a/registrasion/forms.py b/registrasion/forms.py index 552ea991..7e7ddc16 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -345,3 +345,16 @@ class VoucherForm(forms.Form): help_text="If you have a voucher code, enter it here", required=False, ) + + +# Staff-facing forms. + +class ProductAndCategoryForm(forms.Form): + product = forms.ModelMultipleChoiceField( + queryset=inventory.Product.objects.all(), + required=False, + ) + category = forms.ModelMultipleChoiceField( + queryset=inventory.Category.objects.all(), + required=False, + ) diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py index a84f0fa8..bb1b0e19 100644 --- a/registrasion/models/inventory.py +++ b/registrasion/models/inventory.py @@ -46,7 +46,7 @@ class Category(models.Model): from this Category that each attendee may claim. This extends across multiple Invoices. - display_order (int): An ascending order for displaying the Categories + order (int): An ascending order for displaying the Categories available. By convention, your Category for ticket types should have the lowest display order. ''' @@ -129,7 +129,7 @@ class Product(models.Model): pay for it. This reservation duration determines how long an item should be allowed to be reserved whilst being unpaid. - display_order (int): An ascending order for displaying the Products + order (int): An ascending order for displaying the Products within each Category. ''' diff --git a/registrasion/reporting/__init__.py b/registrasion/reporting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py new file mode 100644 index 00000000..f8339a5d --- /dev/null +++ b/registrasion/reporting/reports.py @@ -0,0 +1,90 @@ +from django.contrib.auth.decorators import user_passes_test +from django.shortcuts import render +from functools import wraps + +from registrasion import views + + +''' A list of report views objects that can be used to load a list of +reports. ''' +_all_report_views = [] + + +class Report(object): + + def __init__(self, title, headings, data, link_view=None): + self._headings = headings + self._data = data + self._link_view = link_view + + @property + def title(self): + ''' Returns the title for this report. ''' + return self._title + + @property + def headings(self): + ''' Returns the headings for the table. ''' + return self._headings + + @property + def data(self): + ''' Returns the data rows for the table. ''' + return self._data + + @property + def link_view(self): + ''' Returns the URL name or the view callable that can be used to + view the row's detail. The left-most value is passed into `reverse` + as an argument. ''' + + return self._link_view + + +def report_view(title, form_type=None): + ''' Decorator that converts a report view function into something that + displays a Report. + + Arguments: + title (str): + The title of the report. + form_type (Optional[forms.Form]): + A form class that can make this report display things. If not + supplied, no form will be displayed. + + ''' + + def _report(view): + + @wraps(view) + @user_passes_test(views._staff_only) + def inner_view(request, *a, **k): + + if form_type is not None: + form = form_type(request.GET) + form.is_valid() + else: + form = None + + report = view(request, form, *a, **k) + + ctx = { + "title": title, + "form": form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + # Add this report to the list of reports. + _all_report_views.append(inner_view) + + # Return the callable + return inner_view + return _report + + +def get_all_reports(): + ''' Returns all the views that have been registered with @report ''' + + return list(_all_report_views) diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py new file mode 100644 index 00000000..d44de514 --- /dev/null +++ b/registrasion/reporting/views.py @@ -0,0 +1,196 @@ +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 Sum +from django.db.models import Case, When, Value +from django.shortcuts import render + +from registrasion import forms +from registrasion.models import commerce +from registrasion import views + +from reports import get_all_reports +from reports import Report +from reports import report_view + + +@user_passes_test(views._staff_only) +def reports_list(request): + ''' Lists all of the reports currently available. ''' + + reports = [] + + for report in get_all_reports(): + reports.append({ + "name": report.__name__, + "url": reverse(report), + "description": report.__doc__, + }) + + reports.sort(key=lambda report: report["name"]) + + ctx = { + "reports": reports, + } + + return render(request, "registrasion/reports_list.html", ctx) + + +# Report functions + + +@report_view("Paid items", form_type=forms.ProductAndCategoryForm) +def items_sold(request, form): + ''' 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") + + line_items = line_items.order_by( + # sqlite requires an order_by for .values() to work + "-price", "description", + ).values( + "price", "description", + ).annotate( + total_quantity=Sum("quantity"), + ) + + print line_items + + headings = ["Description", "Quantity", "Price", "Total"] + + data = [] + total_income = 0 + for line in line_items: + cost = line["total_quantity"] * line["price"] + data.append([ + line["description"], line["total_quantity"], + line["price"], cost, + ]) + total_income += cost + + data.append([ + "(TOTAL)", "--", "--", total_income, + ]) + + return Report("Paid items", 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. ''' + + 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( + is_reserved=Case( + When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), + default=Value(0), + output_field=models.BooleanField(), + ), + ) + + items = items.order_by( + "product__category__order", + "product__order", + ).values( + "product", + "product__category__name", + "product__name", + ).annotate( + total_paid=Sum(Case( + When( + cart__status=commerce.Cart.STATUS_PAID, + then=F("quantity"), + ), + default=Value(0), + )), + total_refunded=Sum(Case( + When( + cart__status=commerce.Cart.STATUS_RELEASED, + then=F("quantity"), + ), + default=Value(0), + )), + total_unreserved=Sum(Case( + When( + ( + Q(cart__status=commerce.Cart.STATUS_ACTIVE) & + Q(is_reserved=False) + ), + then=F("quantity"), + ), + default=Value(0), + )), + total_reserved=Sum(Case( + When( + ( + Q(cart__status=commerce.Cart.STATUS_ACTIVE) & + Q(is_reserved=True) + ), + then=F("quantity"), + ), + default=Value(0), + )), + ) + + headings = [ + "Product", "Paid", "Reserved", "Unreserved", "Refunded", + ] + data = [] + + for item in items: + data.append([ + "%s - %s" % ( + item["product__category__name"], item["product__name"] + ), + item["total_paid"], + item["total_reserved"], + item["total_unreserved"], + item["total_refunded"], + ]) + + return Report("Inventory", headings, data) + + +@report_view("Credit notes") +def credit_notes(request, form): + ''' Shows all of the credit notes in the system. ''' + + notes = commerce.CreditNote.objects.all().select_related( + "creditnoterefund", + "creditnoteapplication", + "invoice", + "invoice__user__attendee__attendeeprofilebase", + ) + + headings = [ + "id", "Owner", "Status", "Value", + ] + + data = [] + for note in notes: + data.append([ + note.id, + note.invoice.user.attendee.attendeeprofilebase.invoice_recipient(), + note.status, + note.value, + ]) + + return Report("Credit Notes", headings, data, link_view="credit_note") diff --git a/registrasion/urls.py b/registrasion/urls.py index 7b28693e..d87b13fe 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,22 +1,54 @@ import views +from reporting import views as reporting_views -from django.conf.urls import url, patterns +from django.conf.urls import include +from django.conf.urls import url -urlpatterns = patterns( - "registrasion.views", - url(r"^category/([0-9]+)$", "product_category", name="product_category"), - url(r"^checkout$", "checkout", name="checkout"), - url(r"^credit_note/([0-9]+)$", views.credit_note, name="credit_note"), - url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), - url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), - url(r"^invoice/([0-9]+)/manual_payment$", - views.manual_payment, name="manual_payment"), - url(r"^invoice/([0-9]+)/refund$", - views.refund, name="refund"), - url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, - name="invoice_access"), - url(r"^profile$", "edit_profile", name="attendee_edit"), - url(r"^register$", "guided_registration", name="guided_registration"), - url(r"^register/([0-9]+)$", "guided_registration", - name="guided_registration"), +from .views import ( + product_category, + checkout, + credit_note, + invoice, + manual_payment, + refund, + invoice_access, + edit_profile, + guided_registration, ) + + +public = [ + url(r"^category/([0-9]+)$", product_category, name="product_category"), + url(r"^checkout$", checkout, name="checkout"), + url(r"^credit_note/([0-9]+)$", credit_note, name="credit_note"), + url(r"^invoice/([0-9]+)$", invoice, name="invoice"), + url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", invoice, name="invoice"), + url(r"^invoice/([0-9]+)/manual_payment$", + manual_payment, name="manual_payment"), + url(r"^invoice/([0-9]+)/refund$", + refund, name="refund"), + url(r"^invoice_access/([A-Z0-9]+)$", invoice_access, + name="invoice_access"), + url(r"^profile$", edit_profile, name="attendee_edit"), + url(r"^register$", guided_registration, name="guided_registration"), + url(r"^register/([0-9]+)$", guided_registration, + name="guided_registration"), +] + + +reports = [ + url(r"^$", reporting_views.reports_list, name="reports_list"), + url(r"^credit_notes/?$", reporting_views.credit_notes, name="credit_notes"), + url( + r"^product_status/?$", + reporting_views.product_status, + name="product_status", + ), + url(r"^items_sold/?$", reporting_views.items_sold, name="items_sold"), +] + + +urlpatterns = [ + url(r"^reports/", include(reports)), + url(r"^", include(public)) # This one must go last. +]