From 3225a353e075a1e21ea38e561865465796b369e8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 19:51:36 +1000 Subject: [PATCH 01/21] Migrates to the less-deprecated URL syntax --- registrasion/urls.py | 45 +++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 7b28693e..412e7f7c 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -2,21 +2,32 @@ import views from django.conf.urls import url, patterns -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, ) + +urlpatterns = [ + 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"), +] From 00476498a8f4faac682224a897795dae2862579a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 20:33:19 +1000 Subject: [PATCH 02/21] Very first attempt at a staff-facing report (items sold) --- registrasion/forms.py | 13 +++++ registrasion/staff_views.py | 101 ++++++++++++++++++++++++++++++++++++ registrasion/urls.py | 2 + 3 files changed, 116 insertions(+) create mode 100644 registrasion/staff_views.py 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/staff_views.py b/registrasion/staff_views.py new file mode 100644 index 00000000..d0debd78 --- /dev/null +++ b/registrasion/staff_views.py @@ -0,0 +1,101 @@ +import forms + +from django.db.models import Q +from django.shortcuts import render +from functools import wraps + +from models import commerce + + +''' + +All reports must be viewable by staff only (permissions?) + +Reports can have: + +A form + * Reports are all *gettable* - you can save a URL and get back to the same + report + * Fetching a report *cannot* break the underlying data. +A table + * Headings + * Data lines + * Formats are pluggable + +''' + + +class Report(object): + + def __init__(self, form, headings, data): + self._form = form + self._headings = headings + self._data = data + + @property + def form(self): + ''' Returns the form. ''' + return self._form + + @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 + + +def report(view): + ''' Decorator that converts a report view function into something that + displays a Report. + + ''' + print "hello" + + @wraps(view) + def inner_view(request, *a, **k): + print "lol" + report = view(request, *a, **k) + + ctx = { + "form": report.form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + return inner_view + + +@report +def items_sold(request): + ''' Summarises the items sold and discounts granted for a given set of + products, or products from categories. ''' + + print "beep" + + form = forms.ProductAndCategoryForm(request.GET) + + data = None + headings = None + + if form.is_valid() and form.has_changed(): + products = form.cleaned_data["product"] + categories = form.cleaned_data["category"] + + # TODO augment the form to allow us to filter by invoice status. + line_items = commerce.LineItem.objects.filter( + Q(product=products) | Q(product__category=categories), + invoice__status=commerce.Invoice.STATUS_PAID, + ).select_related("invoice") + + headings = ["invoice_id", "description", "quantity", "price"] + + data = [] + for line in line_items: + data.append([line.invoice.id, line.description, line.quantity, line.price]) + + return Report(form, headings, data) diff --git a/registrasion/urls.py b/registrasion/urls.py index 412e7f7c..89c2aac8 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,4 +1,5 @@ import views +import staff_views from django.conf.urls import url, patterns @@ -30,4 +31,5 @@ urlpatterns = [ url(r"^register$", guided_registration, name="guided_registration"), url(r"^register/([0-9]+)$", guided_registration, name="guided_registration"), + url(r"^report$", staff_views.items_sold, name="items_sold"), # TODO: rm ] From d131b547f695621c17c29d8c32bcab0a38cc143c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 21:01:32 +1000 Subject: [PATCH 03/21] Delete errant prints --- registrasion/staff_views.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index d0debd78..50c2a18b 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -53,11 +53,9 @@ def report(view): displays a Report. ''' - print "hello" @wraps(view) def inner_view(request, *a, **k): - print "lol" report = view(request, *a, **k) ctx = { @@ -75,8 +73,6 @@ def items_sold(request): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' - print "beep" - form = forms.ProductAndCategoryForm(request.GET) data = None From a320f822fcfa928810bb0299e3fe0baf373b9c64 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 21:05:02 +1000 Subject: [PATCH 04/21] Report for total items sold. --- registrasion/staff_views.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 50c2a18b..8cf1e590 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,6 +1,7 @@ import forms from django.db.models import Q +from django.db.models import Sum from django.shortcuts import render from functools import wraps @@ -84,14 +85,28 @@ def items_sold(request): # TODO augment the form to allow us to filter by invoice status. line_items = commerce.LineItem.objects.filter( - Q(product=products) | Q(product__category=categories), + Q(product__in=products) | Q(product__category__in=categories), invoice__status=commerce.Invoice.STATUS_PAID, ).select_related("invoice") - headings = ["invoice_id", "description", "quantity", "price"] + 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 = [] for line in line_items: - data.append([line.invoice.id, line.description, line.quantity, line.price]) + data.append([ + line["description"], line["total_quantity"], + line["price"], line["total_quantity"] * line["price"], + ]) return Report(form, headings, data) From b7650ca772d6250829f4689c1f052363179ccd9d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 25 Aug 2016 21:10:07 +1000 Subject: [PATCH 05/21] Reports now display titles --- registrasion/staff_views.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 8cf1e590..aa19e443 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -28,11 +28,17 @@ A table class Report(object): - def __init__(self, form, headings, data): + def __init__(self, title, form, headings, data): + self._title = title self._form = form self._headings = headings self._data = data + @property + def title(self): + ''' Returns the form. ''' + return self._title + @property def form(self): ''' Returns the form. ''' @@ -60,6 +66,7 @@ def report(view): report = view(request, *a, **k) ctx = { + "title": report.title, "form": report.form, "report": report, } @@ -74,6 +81,8 @@ def items_sold(request): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' + title = "Paid items" + form = forms.ProductAndCategoryForm(request.GET) data = None @@ -83,7 +92,6 @@ def items_sold(request): products = form.cleaned_data["product"] categories = form.cleaned_data["category"] - # TODO augment the form to allow us to filter by invoice status. line_items = commerce.LineItem.objects.filter( Q(product__in=products) | Q(product__category__in=categories), invoice__status=commerce.Invoice.STATUS_PAID, @@ -109,4 +117,4 @@ def items_sold(request): line["price"], line["total_quantity"] * line["price"], ]) - return Report(form, headings, data) + return Report(title, form, headings, data) From db8f428ee1af0b08924562bf777333ed666fda70 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 13:40:49 +1000 Subject: [PATCH 06/21] Makes the sales report keep a total. --- registrasion/staff_views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index aa19e443..2ba5a222 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -111,10 +111,17 @@ def items_sold(request): 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"], line["total_quantity"] * line["price"], + line["price"], cost, ]) + total_income += cost + + data.append([ + "(TOTAL)", "--", "--", total_income, + ]) return Report(title, form, headings, data) From 5c41a3576caf069e366c3e50ce59182f9c44008c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 13:53:40 +1000 Subject: [PATCH 07/21] re-structures the URLs a bit, puts the items sold report under reports/items_sold --- registrasion/urls.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/registrasion/urls.py b/registrasion/urls.py index 89c2aac8..f22b67c6 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,7 +1,8 @@ import views import staff_views -from django.conf.urls import url, patterns +from django.conf.urls import include +from django.conf.urls import url from .views import ( product_category, @@ -15,7 +16,8 @@ from .views import ( guided_registration, ) -urlpatterns = [ + +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"), @@ -31,5 +33,15 @@ urlpatterns = [ url(r"^register$", guided_registration, name="guided_registration"), url(r"^register/([0-9]+)$", guided_registration, name="guided_registration"), - url(r"^report$", staff_views.items_sold, name="items_sold"), # TODO: rm +] + + +reports = [ + url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), +] + + +urlpatterns = [ + url(r"^reports/", include(reports)), + url(r"^", include(public)) # This one must go last. ] From 2a850c49bc5e50938b3fe9ced8124385413a4528 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 14:17:53 +1000 Subject: [PATCH 08/21] Fixes some documentation snafus --- registrasion/models/inventory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. ''' From 3607fb19b83c2c07b678714bd769da33e7673017 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 14:59:54 +1000 Subject: [PATCH 09/21] Adds inventory report --- registrasion/staff_views.py | 71 ++++++++++++++++++++++++++++++++++++- registrasion/urls.py | 1 + 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 2ba5a222..9967a3e9 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,11 +1,14 @@ import forms +from django.db import models from django.db.models import Q from django.db.models import Sum +from django.db.models import Case, When, Value from django.shortcuts import render from functools import wraps from models import commerce +from models import inventory ''' @@ -108,7 +111,7 @@ def items_sold(request): print line_items - headings = ["description", "quantity", "price", "total"] + headings = ["Description", "Quantity", "Price", "Total"] data = [] total_income = 0 @@ -125,3 +128,69 @@ def items_sold(request): ]) return Report(title, form, headings, data) + + +@report +def inventory(request): + ''' Summarises the inventory status of the given items, grouping by + invoice status. ''' + + title = "Inventory" + + form = forms.ProductAndCategoryForm(request.GET) + + data = None + headings = None + + if form.is_valid() and form.has_changed(): + 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") + + # TODO annotate with whether the item is reserved or not. + + 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( + "cart__status", + "product__category__order", + "product__order", + ).values( + "product", + "product__category__name", + "product__name", + "cart__status", + "is_reserved", + ).annotate( + total_quantity=Sum("quantity"), + ) + + headings = ["Product", "Status", "Quantity"] + data = [] + + def status(reserved, status): + r = "Reserved" if reserved else "Unreserved" + s = "".join( + "%s" % i[1] + for i in commerce.Cart.STATUS_TYPES if i[0]==status + ) + return "%s - %s" % (r, s) + + for item in items: + print commerce.Cart.STATUS_TYPES + data.append([ + "%s - %s" % ( + item["product__category__name"], item["product__name"] + ), + status(item["is_reserved"], item["cart__status"]), + item["total_quantity"], + ]) + + return Report(title, form, headings, data) diff --git a/registrasion/urls.py b/registrasion/urls.py index f22b67c6..43f58110 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -37,6 +37,7 @@ public = [ reports = [ + url(r"^inventory/?$", staff_views.inventory, name="inventory"), url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), ] From 32b887fed3a990039f6a96528e57b2e1a9b3457c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 15:11:08 +1000 Subject: [PATCH 10/21] Makes the reporting framework a bit more DRY. --- registrasion/staff_views.py | 204 +++++++++++++++++------------------- 1 file changed, 97 insertions(+), 107 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 9967a3e9..c406b80f 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -31,22 +31,15 @@ A table class Report(object): - def __init__(self, title, form, headings, data): - self._title = title - self._form = form + def __init__(self, title, headings, data): self._headings = headings self._data = data @property def title(self): - ''' Returns the form. ''' + ''' Returns the title for this report. ''' return self._title - @property - def form(self): - ''' Returns the form. ''' - return self._form - @property def headings(self): ''' Returns the headings for the table. ''' @@ -58,139 +51,136 @@ class Report(object): return self._data -def report(view): +def report(title, form_type): ''' Decorator that converts a report view function into something that displays a Report. + Arguments: + form_type: A form class that can make this report display things. + ''' - @wraps(view) - def inner_view(request, *a, **k): - report = view(request, *a, **k) + def _report(view): - ctx = { - "title": report.title, - "form": report.form, - "report": report, - } + @wraps(view) + def inner_view(request, *a, **k): - return render(request, "registrasion/report.html", ctx) + form = form_type(request.GET) + if form.is_valid() and form.has_changed(): + report = view(request, form, *a, **k) + else: + report = None - return inner_view + ctx = { + "title": title, + "form": form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + return inner_view + return _report -@report -def items_sold(request): +@report("Paid items", forms.ProductAndCategoryForm) +def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' - title = "Paid items" - - form = forms.ProductAndCategoryForm(request.GET) - data = None headings = None - if form.is_valid() and form.has_changed(): - products = form.cleaned_data["product"] - categories = form.cleaned_data["category"] + 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 = 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"), - ) + 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 + 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 + headings = ["Description", "Quantity", "Price", "Total"] + data = [] + total_income = 0 + for line in line_items: + cost = line["total_quantity"] * line["price"] data.append([ - "(TOTAL)", "--", "--", total_income, + line["description"], line["total_quantity"], + line["price"], cost, ]) + total_income += cost - return Report(title, form, headings, data) + data.append([ + "(TOTAL)", "--", "--", total_income, + ]) + + return Report("Paid items", headings, data) -@report -def inventory(request): +@report("Inventory", forms.ProductAndCategoryForm) +def inventory(request, form): ''' Summarises the inventory status of the given items, grouping by invoice status. ''' - title = "Inventory" + products = form.cleaned_data["product"] + categories = form.cleaned_data["category"] - form = forms.ProductAndCategoryForm(request.GET) + items = commerce.ProductItem.objects.filter( + Q(product__in=products) | Q(product__category__in=categories), + ).select_related("cart", "product") - data = None - headings = None + # TODO annotate with whether the item is reserved or not. - if form.is_valid() and form.has_changed(): - products = form.cleaned_data["product"] - categories = form.cleaned_data["category"] + items = items.annotate(is_reserved=Case( + When(cart__in=commerce.Cart.reserved_carts(), then=Value(1)), + default=Value(0), + output_field=models.BooleanField(), + )) - items = commerce.ProductItem.objects.filter( - Q(product__in=products) | Q(product__category__in=categories), - ).select_related("cart", "product") + items = items.order_by( + "cart__status", + "product__category__order", + "product__order", + ).values( + "product", + "product__category__name", + "product__name", + "cart__status", + "is_reserved", + ).annotate( + total_quantity=Sum("quantity"), + ) - # TODO annotate with whether the item is reserved or not. + headings = ["Product", "Status", "Quantity"] + data = [] - 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( - "cart__status", - "product__category__order", - "product__order", - ).values( - "product", - "product__category__name", - "product__name", - "cart__status", - "is_reserved", - ).annotate( - total_quantity=Sum("quantity"), + def status(reserved, status): + r = "Reserved" if reserved else "Unreserved" + # This is a bit weird -- can we simplify? + s = "".join( + "%s" % i[1] for i in commerce.Cart.STATUS_TYPES if i[0]==status ) + return "%s - %s" % (r, s) - headings = ["Product", "Status", "Quantity"] - data = [] + for item in items: + data.append([ + "%s - %s" % ( + item["product__category__name"], item["product__name"] + ), + status(item["is_reserved"], item["cart__status"]), + item["total_quantity"], + ]) - def status(reserved, status): - r = "Reserved" if reserved else "Unreserved" - s = "".join( - "%s" % i[1] - for i in commerce.Cart.STATUS_TYPES if i[0]==status - ) - return "%s - %s" % (r, s) - - for item in items: - print commerce.Cart.STATUS_TYPES - data.append([ - "%s - %s" % ( - item["product__category__name"], item["product__name"] - ), - status(item["is_reserved"], item["cart__status"]), - item["total_quantity"], - ]) - - return Report(title, form, headings, data) + return Report("Inventory", headings, data) From 66226663d53fc131455c1883a33646f60926a33b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 26 Aug 2016 15:53:33 +1000 Subject: [PATCH 11/21] Makes the inventory report even clearer. --- registrasion/staff_views.py | 72 +++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index c406b80f..17b30b5f 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,7 +1,7 @@ import forms from django.db import models -from django.db.models import Q +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 @@ -141,46 +141,72 @@ def inventory(request, form): Q(product__in=products) | Q(product__category__in=categories), ).select_related("cart", "product") - # TODO annotate with whether the item is reserved or not. - - 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.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( - "cart__status", "product__category__order", "product__order", ).values( "product", "product__category__name", "product__name", - "cart__status", - "is_reserved", ).annotate( - total_quantity=Sum("quantity"), + 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", "Status", "Quantity"] + headings = [ + "Product", "Paid", "Reserved", "Unreserved", "Refunded", + ] data = [] - def status(reserved, status): - r = "Reserved" if reserved else "Unreserved" - # This is a bit weird -- can we simplify? - s = "".join( - "%s" % i[1] for i in commerce.Cart.STATUS_TYPES if i[0]==status - ) - return "%s - %s" % (r, s) - for item in items: data.append([ "%s - %s" % ( item["product__category__name"], item["product__name"] ), - status(item["is_reserved"], item["cart__status"]), - item["total_quantity"], + item["total_paid"], + item["total_reserved"], + item["total_unreserved"], + item["total_refunded"], ]) return Report("Inventory", headings, data) From 1e066952e9677aa5119f845deb509b0e16c325f1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 09:31:12 +1000 Subject: [PATCH 12/21] Reports now need staff credentials to load. --- registrasion/staff_views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 17b30b5f..77226ab0 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,9 +1,12 @@ import forms +import views +from django.contrib.auth.decorators import user_passes_test 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.http import Http404 from django.shortcuts import render from functools import wraps @@ -63,6 +66,7 @@ def report(title, form_type): def _report(view): @wraps(view) + @user_passes_test(views._staff_only) def inner_view(request, *a, **k): form = form_type(request.GET) From fb022bbc7b92f8ba313035882c352ea9f2513597 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 09:55:29 +1000 Subject: [PATCH 13/21] Adds a view that shows all reports --- registrasion/staff_views.py | 39 +++++++++++++++++++++++++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 40 insertions(+) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 77226ab0..c07c5a92 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -1,7 +1,10 @@ import forms import views +from collections import namedtuple + 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 @@ -54,6 +57,11 @@ class Report(object): return self._data +''' A list of report views objects that can be used to load a list of +reports. ''' +_all_report_views = [] + + def report(title, form_type): ''' Decorator that converts a report view function into something that displays a Report. @@ -83,10 +91,41 @@ def report(title, form_type): return render(request, "registrasion/report.html", ctx) + # Add this report to the list of reports -- makes this reversable. + _all_report_views.append(inner_view) + + # Return the callable return inner_view return _report +@user_passes_test(views._staff_only) +def reports_list(request): + ''' Lists all of the reports currently available. ''' + + reports = [] + + for report in _all_report_views: + reports.append({ + "name" : report.__name__, + "url" : reverse(report), + "description" : report.__doc__, + }) + + reports.sort(key=lambda report: report["name"]) + + ctx = { + "reports" : reports, + } + + print reports + + return render(request, "registrasion/reports_list.html", ctx) + + +# Report functions + + @report("Paid items", forms.ProductAndCategoryForm) def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of diff --git a/registrasion/urls.py b/registrasion/urls.py index 43f58110..d051c277 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -37,6 +37,7 @@ public = [ reports = [ + url(r"^$", staff_views.reports_list, name="reports_list"), url(r"^inventory/?$", staff_views.inventory, name="inventory"), url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), ] From 86d1ab71603ce7c834ed750122ff3ec166366c5c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:08:11 +1000 Subject: [PATCH 14/21] Refactors core reporting bits into a reporting package --- registrasion/reporting/__init__.py | 0 registrasion/reporting/reports.py | 89 ++++++++++++++++++++++++++++++ registrasion/staff_views.py | 89 ++---------------------------- 3 files changed, 93 insertions(+), 85 deletions(-) create mode 100644 registrasion/reporting/__init__.py create mode 100644 registrasion/reporting/reports.py 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..799711b7 --- /dev/null +++ b/registrasion/reporting/reports.py @@ -0,0 +1,89 @@ +from collections import namedtuple + +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.http import Http404 +from django.shortcuts import render +from functools import wraps + +from registrasion import forms +from registrasion import views +from registrasion.models import commerce +from registrasion.models import inventory + + +''' 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): + self._headings = headings + self._data = data + + @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 + + +def report_view(title, form_type): + ''' Decorator that converts a report view function into something that + displays a Report. + + Arguments: + title (str): + The title of the report. + form_type: + A form class that can make this report display things. + + ''' + + def _report(view): + + @wraps(view) + @user_passes_test(views._staff_only) + def inner_view(request, *a, **k): + + form = form_type(request.GET) + if form.is_valid() and form.has_changed(): + report = view(request, form, *a, **k) + else: + report = None + + ctx = { + "title": title, + "form": form, + "report": report, + } + + return render(request, "registrasion/report.html", ctx) + + # Add this report to the list of reports -- makes this reversable. + _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/staff_views.py b/registrasion/staff_views.py index c07c5a92..96ade521 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -16,87 +16,8 @@ from functools import wraps from models import commerce from models import inventory - -''' - -All reports must be viewable by staff only (permissions?) - -Reports can have: - -A form - * Reports are all *gettable* - you can save a URL and get back to the same - report - * Fetching a report *cannot* break the underlying data. -A table - * Headings - * Data lines - * Formats are pluggable - -''' - - -class Report(object): - - def __init__(self, title, headings, data): - self._headings = headings - self._data = data - - @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 - - -''' A list of report views objects that can be used to load a list of -reports. ''' -_all_report_views = [] - - -def report(title, form_type): - ''' Decorator that converts a report view function into something that - displays a Report. - - Arguments: - form_type: A form class that can make this report display things. - - ''' - - def _report(view): - - @wraps(view) - @user_passes_test(views._staff_only) - def inner_view(request, *a, **k): - - form = form_type(request.GET) - if form.is_valid() and form.has_changed(): - report = view(request, form, *a, **k) - else: - report = None - - ctx = { - "title": title, - "form": form, - "report": report, - } - - return render(request, "registrasion/report.html", ctx) - - # Add this report to the list of reports -- makes this reversable. - _all_report_views.append(inner_view) - - # Return the callable - return inner_view - return _report +from reporting.reports import Report +from reporting.reports import report_view @user_passes_test(views._staff_only) @@ -118,15 +39,13 @@ def reports_list(request): "reports" : reports, } - print reports - return render(request, "registrasion/reports_list.html", ctx) # Report functions -@report("Paid items", forms.ProductAndCategoryForm) +@report_view("Paid items", forms.ProductAndCategoryForm) def items_sold(request, form): ''' Summarises the items sold and discounts granted for a given set of products, or products from categories. ''' @@ -172,7 +91,7 @@ def items_sold(request, form): return Report("Paid items", headings, data) -@report("Inventory", forms.ProductAndCategoryForm) +@report_view("Inventory", forms.ProductAndCategoryForm) def inventory(request, form): ''' Summarises the inventory status of the given items, grouping by invoice status. ''' From 960de87343fada7dd1ee70cb61fd43d32590f861 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:10:21 +1000 Subject: [PATCH 15/21] oops --- registrasion/staff_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 96ade521..4bedf3d1 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -16,6 +16,7 @@ from functools import wraps from models import commerce from models import inventory +from reporting.reports import get_all_reports from reporting.reports import Report from reporting.reports import report_view @@ -26,7 +27,7 @@ def reports_list(request): reports = [] - for report in _all_report_views: + for report in get_all_reports(): reports.append({ "name" : report.__name__, "url" : reverse(report), From f1c8e90b77d336eeadf7a0e618e8030c5518d6eb Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:28:24 +1000 Subject: [PATCH 16/21] Makes the form type optional for reports --- registrasion/reporting/reports.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 799711b7..17f24dc4 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -50,8 +50,9 @@ def report_view(title, form_type): Arguments: title (str): The title of the report. - form_type: - A form class that can make this report display things. + form_type (forms.Form or None): + A form class that can make this report display things. If None, + no form will be displayed. ''' @@ -61,11 +62,13 @@ def report_view(title, form_type): @user_passes_test(views._staff_only) def inner_view(request, *a, **k): - form = form_type(request.GET) - if form.is_valid() and form.has_changed(): - report = view(request, form, *a, **k) + if form_type is not None: + form = form_type(request.GET) + form.is_valid() else: - report = None + form = None + + report = view(request, form, *a, **k) ctx = { "title": title, @@ -75,7 +78,7 @@ def report_view(title, form_type): return render(request, "registrasion/report.html", ctx) - # Add this report to the list of reports -- makes this reversable. + # Add this report to the list of reports. _all_report_views.append(inner_view) # Return the callable From 499c4209cfdda2fc34804c8405b05314304d6787 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:30:12 +1000 Subject: [PATCH 17/21] Makes form_type *properly* optional --- registrasion/reporting/reports.py | 8 ++++---- registrasion/staff_views.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 17f24dc4..36e11942 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -43,16 +43,16 @@ class Report(object): return self._data -def report_view(title, form_type): +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 (forms.Form or None): - A form class that can make this report display things. If None, - no form will be displayed. + form_type (Optional[forms.Form]): + A form class that can make this report display things. If not + supplied, no form will be displayed. ''' diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 4bedf3d1..a21e663b 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -46,7 +46,7 @@ def reports_list(request): # Report functions -@report_view("Paid items", forms.ProductAndCategoryForm) +@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. ''' @@ -92,7 +92,7 @@ def items_sold(request, form): return Report("Paid items", headings, data) -@report_view("Inventory", forms.ProductAndCategoryForm) +@report_view("Inventory", form_type=forms.ProductAndCategoryForm) def inventory(request, form): ''' Summarises the inventory status of the given items, grouping by invoice status. ''' From 372512c6affc4c7b9bb6dde4d4e456896e3f687f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 10:40:28 +1000 Subject: [PATCH 18/21] Adds report to view credit notes. --- registrasion/staff_views.py | 27 +++++++++++++++++++++++++++ registrasion/urls.py | 1 + 2 files changed, 28 insertions(+) diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index a21e663b..6672bfba 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -173,3 +173,30 @@ def inventory(request, form): ]) 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) diff --git a/registrasion/urls.py b/registrasion/urls.py index d051c277..8f7408da 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -38,6 +38,7 @@ public = [ reports = [ url(r"^$", staff_views.reports_list, name="reports_list"), + url(r"^credit_notes/?$", staff_views.credit_notes, name="credit_notes"), url(r"^inventory/?$", staff_views.inventory, name="inventory"), url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), ] From f9e26a2e492b9b5db0934c3fb80a1de5d3c0f7ce Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:05:38 +1000 Subject: [PATCH 19/21] Adds the link_view concept to reports; adds a link_view to credit notes report --- registrasion/reporting/reports.py | 11 ++++++++++- registrasion/staff_views.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 36e11942..817cc5de 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -23,9 +23,10 @@ _all_report_views = [] class Report(object): - def __init__(self, title, headings, data): + def __init__(self, title, headings, data, link_view=None): self._headings = headings self._data = data + self._link_view = link_view @property def title(self): @@ -42,6 +43,14 @@ class Report(object): ''' 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 diff --git a/registrasion/staff_views.py b/registrasion/staff_views.py index 6672bfba..47174fdb 100644 --- a/registrasion/staff_views.py +++ b/registrasion/staff_views.py @@ -199,4 +199,4 @@ def credit_notes(request, form): note.value, ]) - return Report("Credit Notes", headings, data) + return Report("Credit Notes", headings, data, link_view="credit_note") From 4664c4711a943e06be9d97ed2082ff2f623509f2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:20:03 +1000 Subject: [PATCH 20/21] Moves staff_views to reporting/views --- .../{staff_views.py => reporting/views.py} | 15 +++++++-------- registrasion/urls.py | 10 +++++----- 2 files changed, 12 insertions(+), 13 deletions(-) rename registrasion/{staff_views.py => reporting/views.py} (95%) diff --git a/registrasion/staff_views.py b/registrasion/reporting/views.py similarity index 95% rename from registrasion/staff_views.py rename to registrasion/reporting/views.py index 47174fdb..935a3299 100644 --- a/registrasion/staff_views.py +++ b/registrasion/reporting/views.py @@ -1,6 +1,3 @@ -import forms -import views - from collections import namedtuple from django.contrib.auth.decorators import user_passes_test @@ -13,12 +10,14 @@ from django.http import Http404 from django.shortcuts import render from functools import wraps -from models import commerce -from models import inventory +from registrasion import forms +from registrasion.models import commerce +from registrasion.models import inventory +from registrasion import views -from reporting.reports import get_all_reports -from reporting.reports import Report -from reporting.reports import report_view +from reports import get_all_reports +from reports import Report +from reports import report_view @user_passes_test(views._staff_only) diff --git a/registrasion/urls.py b/registrasion/urls.py index 8f7408da..22df0e51 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,5 +1,5 @@ import views -import staff_views +from reporting import views as reporting_views from django.conf.urls import include from django.conf.urls import url @@ -37,10 +37,10 @@ public = [ reports = [ - url(r"^$", staff_views.reports_list, name="reports_list"), - url(r"^credit_notes/?$", staff_views.credit_notes, name="credit_notes"), - url(r"^inventory/?$", staff_views.inventory, name="inventory"), - url(r"^items_sold/?$", staff_views.items_sold, name="items_sold"), + url(r"^$", reporting_views.reports_list, name="reports_list"), + url(r"^credit_notes/?$", reporting_views.credit_notes, name="inventory"), + url(r"^inventory/?$", reporting_views.inventory, name="inventory"), + url(r"^items_sold/?$", reporting_views.items_sold, name="items_sold"), ] From aacdab7d16ee55a4ba590518c499418da8cbbb0f Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 2 Sep 2016 11:25:50 +1000 Subject: [PATCH 21/21] The reporting module now passes flake8 --- registrasion/reporting/reports.py | 11 ----------- registrasion/reporting/views.py | 17 ++++++----------- registrasion/urls.py | 8 ++++++-- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/registrasion/reporting/reports.py b/registrasion/reporting/reports.py index 817cc5de..f8339a5d 100644 --- a/registrasion/reporting/reports.py +++ b/registrasion/reporting/reports.py @@ -1,19 +1,8 @@ -from collections import namedtuple - 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.http import Http404 from django.shortcuts import render from functools import wraps -from registrasion import forms from registrasion import views -from registrasion.models import commerce -from registrasion.models import inventory ''' A list of report views objects that can be used to load a list of diff --git a/registrasion/reporting/views.py b/registrasion/reporting/views.py index 935a3299..d44de514 100644 --- a/registrasion/reporting/views.py +++ b/registrasion/reporting/views.py @@ -1,18 +1,13 @@ -from collections import namedtuple - 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.http import Http404 from django.shortcuts import render -from functools import wraps from registrasion import forms from registrasion.models import commerce -from registrasion.models import inventory from registrasion import views from reports import get_all_reports @@ -28,15 +23,15 @@ def reports_list(request): for report in get_all_reports(): reports.append({ - "name" : report.__name__, - "url" : reverse(report), - "description" : report.__doc__, + "name": report.__name__, + "url": reverse(report), + "description": report.__doc__, }) reports.sort(key=lambda report: report["name"]) ctx = { - "reports" : reports, + "reports": reports, } return render(request, "registrasion/reports_list.html", ctx) @@ -91,8 +86,8 @@ def items_sold(request, form): return Report("Paid items", headings, data) -@report_view("Inventory", form_type=forms.ProductAndCategoryForm) -def inventory(request, form): +@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. ''' diff --git a/registrasion/urls.py b/registrasion/urls.py index 22df0e51..d87b13fe 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -38,8 +38,12 @@ public = [ reports = [ url(r"^$", reporting_views.reports_list, name="reports_list"), - url(r"^credit_notes/?$", reporting_views.credit_notes, name="inventory"), - url(r"^inventory/?$", reporting_views.inventory, name="inventory"), + 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"), ]