From 3d7d4289145d96c9eaf4d47e7294599cb59d9d2e Mon Sep 17 00:00:00 2001 From: Adam Goldsmith Date: Wed, 31 Jan 2024 20:18:46 -0500 Subject: [PATCH] membershipworks: Add event invoices and financial info to event reports --- cmsmanage/settings/base.py | 1 + ...0012_eventattendeestats_eventtickettype.py | 79 +++++++++ membershipworks/models.py | 160 ++++++++++++++++++ .../membershipworks/event_invoice.dj.html | 52 ++++++ .../tables/invoice_table.dj.html | 45 +++++ membershipworks/urls.py | 6 + membershipworks/views.py | 75 +++++++- pdm.lock | 15 +- pyproject.toml | 1 + 9 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 membershipworks/migrations/0012_eventattendeestats_eventtickettype.py create mode 100644 membershipworks/templates/membershipworks/event_invoice.dj.html create mode 100644 membershipworks/templates/membershipworks/tables/invoice_table.dj.html diff --git a/cmsmanage/settings/base.py b/cmsmanage/settings/base.py index 0fbf010..9ba92c1 100644 --- a/cmsmanage/settings/base.py +++ b/cmsmanage/settings/base.py @@ -40,6 +40,7 @@ INSTALLED_APPS = [ "django_nh3", "django_tables2", "django_filters", + "django_db_views", "tasks.apps.TasksConfig", "rentals.apps.RentalsConfig", "membershipworks.apps.MembershipworksConfig", diff --git a/membershipworks/migrations/0012_eventattendeestats_eventtickettype.py b/membershipworks/migrations/0012_eventattendeestats_eventtickettype.py new file mode 100644 index 0000000..00a2cff --- /dev/null +++ b/membershipworks/migrations/0012_eventattendeestats_eventtickettype.py @@ -0,0 +1,79 @@ +# Generated by Django 5.0.1 on 2024-01-29 19:19 + +from django.db import migrations, models + +import django_db_views.migration_functions +import django_db_views.operations + + +class Migration(migrations.Migration): + dependencies = [ + ("membershipworks", "0011_eventext_details"), + ] + + operations = [ + migrations.CreateModel( + name="EventAttendeeStats", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("gross_revenue", models.FloatField()), + ], + options={ + "managed": False, + }, + ), + django_db_views.operations.ViewRunPython( + code=django_db_views.migration_functions.ForwardViewMigration( + "SELECT\n row_number() over () as id,\n eventext.event_ptr_id AS event_id,\n tkt.label,\n tkt.list_price,\n tkt.quantity,\n GROUP_CONCAT(tkt.restrict_to SEPARATOR ',') as restrict_to\n FROM\n membershipworks_eventext AS eventext,\n JSON_TABLE (eventext.details, '$.tkt[*]' COLUMNS (\n id FOR ORDINALITY,\n label VARCHAR(256) PATH '$.lbl' ERROR ON ERROR,\n list_price DOUBLE PATH '$.amt' ERROR ON ERROR,\n quantity INTEGER PATH '$.cnt' DEFAULT 0 ON EMPTY ERROR ON ERROR,\n NESTED PATH '$.dsp[*]' COLUMNS (\n restrict_to VARCHAR(100) PATH '$' ERROR ON ERROR\n )\n )) AS tkt\n GROUP BY event_id, id", + "membershipworks_eventtickettype", + engine="django.db.backends.mysql", + ), + reverse_code=django_db_views.migration_functions.BackwardViewMigration( + "", "membershipworks_eventtickettype", engine="django.db.backends.mysql" + ), + atomic=False, + ), + migrations.CreateModel( + name="EventTicketType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.TextField()), + ("restrict_to", models.TextField(blank=True, null=True)), + ("list_price", models.FloatField()), + ("quantity", models.IntegerField()), + ], + options={ + "managed": False, + "base_manager_name": "objects", + }, + ), + django_db_views.operations.ViewRunPython( + code=django_db_views.migration_functions.ForwardViewMigration( + "SELECT eventext.event_ptr_id as event_id, SUM(tkt.s) as gross_revenue\n FROM\n membershipworks_eventext as eventext,\n JSON_TABLE(eventext.details, '$.usr[*]' COLUMNS (\n s DOUBLE PATH '$.sum' DEFAULT 0 ON EMPTY\n )) as tkt\n GROUP BY event_id", + "membershipworks_eventattendeestats", + engine="django.db.backends.mysql", + ), + reverse_code=django_db_views.migration_functions.BackwardViewMigration( + "", + "membershipworks_eventattendeestats", + engine="django.db.backends.mysql", + ), + atomic=False, + ), + ] diff --git a/membershipworks/models.py b/membershipworks/models.py index b03cf49..bc29237 100644 --- a/membershipworks/models.py +++ b/membershipworks/models.py @@ -5,6 +5,7 @@ import django.core.mail.message from django.conf import settings from django.db import models from django.db.models import ( + Case, Count, Exists, ExpressionWrapper, @@ -14,9 +15,13 @@ from django.db.models import ( Q, Subquery, Sum, + When, ) +from django.db.models.functions import Coalesce from django.utils import timezone +from django_db_views.db_view import DBView + class BaseModel(models.Model): _api_names_override = {} @@ -444,6 +449,40 @@ class EventExtQuerySet(models.QuerySet["EventExt"]): person_hours__sum=Sum("person_hours", filter=F("occurred")), event_count=Count("eid", filter=F("occurred")), canceled_event_count=Count("eid", filter=~F("occurred")), + gross_revenue__sum=Sum("gross_revenue", filter=F("occurred")), + total_due_to_instructor__sum=Sum( + "total_due_to_instructor", filter=F("occurred") + ), + net_revenue__sum=Sum("net_revenue", filter=F("occurred")), + ) + + def with_financials(self): + return self.annotate( + **{ + field: Subquery( + EventTicketType.objects.filter(event=OuterRef("pk")) + .values("event__pk") + .annotate(d=Sum(field)) + .values("d"), + output_field=models.DecimalField(), + ) + for field in [ + "quantity", + "amount", + "materials", + "amount_without_materials", + "instructor_fee", + "instructor_amount", + ] + }, + total_due_to_instructor=( + F("instructor_amount") + F("instructor_flat_rate") + ), + gross_revenue=Coalesce(F("attendee_stats__gross_revenue"), 0.0), + net_revenue=ExpressionWrapper( + F("gross_revenue") - F("total_due_to_instructor"), + output_field=models.DecimalField(), + ), ) @@ -528,3 +567,124 @@ class EventMeetingTime(models.Model): fields=["event", "start", "end"], name="unique_event_start_end" ) ] + + +class EventTicketTypeManager(models.Manager["EventTicketType"]): + def get_queryset(self) -> models.QuerySet["EventTicketType"]: + members_folder = Subquery( + Flag.objects.filter(name="Members", type="folder").values("id")[:1] + ) + qs = super().get_queryset() + return qs.annotate( + actual_price=Case( + When( + Q(restrict_to=members_folder) | Q(restrict_to__isnull=True), + "list_price", + ), + # Use Members ticket price for any restricted ticket + # which is not the Members ticket + default=Subquery( + qs.filter( + event=OuterRef("event"), restrict_to=members_folder + ).values("list_price"), + output_field=models.FloatField(), + ), + ), + is_members_ticket=(Q(restrict_to__isnull=False)), + materials=Case( + When(Q(event__materials_fee_included_in_price__isnull=True), None), + When( + ( + Q(event__materials_fee_included_in_price=True) + & Q(event__materials_fee__isnull=False) + ), + ExpressionWrapper( + F("event__materials_fee") * F("quantity"), + output_field=models.DecimalField(), + ), + ), + default=0, + output_field=models.DecimalField(), + ), + amount=ExpressionWrapper( + F("actual_price") * F("quantity"), + output_field=models.DecimalField(), + ), + amount_without_materials=ExpressionWrapper( + F("amount") - F("materials"), output_field=models.DecimalField() + ), + instructor_fee=ExpressionWrapper( + F("amount_without_materials") * F("event__instructor_percentage"), + output_field=models.DecimalField(), + ), + instructor_amount=ExpressionWrapper( + F("instructor_fee") + F("materials"), output_field=models.DecimalField() + ), + ) + + +class EventTicketType(DBView): + objects = EventTicketTypeManager() + + event = models.ForeignKey( + EventExt, on_delete=models.CASCADE, related_name="ticket_types" + ) + label = models.TextField() + restrict_to = models.TextField(null=True, blank=True) + list_price = models.FloatField() + quantity = models.IntegerField() + + # Due to the presence of JSON_TABLE, this view must (as of MariaDB + # 11.2.2) be created as the root user. See + # https://jira.mariadb.org/browse/MDEV-27898 + + # nested path/group_concat to workaround inability to create JSON columns using + # JSON_TABLE in views + view_definition = """ + SELECT + row_number() over () as id, + eventext.event_ptr_id AS event_id, + tkt.label, + tkt.list_price, + tkt.quantity, + GROUP_CONCAT(tkt.restrict_to SEPARATOR ',') as restrict_to + FROM + membershipworks_eventext AS eventext, + JSON_TABLE (eventext.details, '$.tkt[*]' COLUMNS ( + id FOR ORDINALITY, + label VARCHAR(256) PATH '$.lbl' ERROR ON ERROR, + list_price DOUBLE PATH '$.amt' ERROR ON ERROR, + quantity INTEGER PATH '$.cnt' DEFAULT 0 ON EMPTY ERROR ON ERROR, + NESTED PATH '$.dsp[*]' COLUMNS ( + restrict_to VARCHAR(100) PATH '$' ERROR ON ERROR + ) + )) AS tkt + GROUP BY event_id, id + """ + + def __str__(self) -> str: + return f"{self.label}: {self.quantity} * {self.list_price}" + + class Meta: + managed = False + base_manager_name = "objects" + + +class EventAttendeeStats(DBView): + event = models.ForeignKey( + EventExt, on_delete=models.CASCADE, related_name="attendee_stats" + ) + gross_revenue = models.FloatField() + + view_definition = """ + SELECT eventext.event_ptr_id as event_id, SUM(tkt.s) as gross_revenue + FROM + membershipworks_eventext as eventext, + JSON_TABLE(eventext.details, '$.usr[*]' COLUMNS ( + s DOUBLE PATH '$.sum' DEFAULT 0 ON EMPTY + )) as tkt + GROUP BY event_id + """ + + class Meta: + managed = False diff --git a/membershipworks/templates/membershipworks/event_invoice.dj.html b/membershipworks/templates/membershipworks/event_invoice.dj.html new file mode 100644 index 0000000..8c1b9a5 --- /dev/null +++ b/membershipworks/templates/membershipworks/event_invoice.dj.html @@ -0,0 +1,52 @@ +{% extends "base.dj.html" %} + +{% load nh3_tags %} +{% load render_table from django_tables2 %} + +{% block title %}Event Invoice for {{ event.details.ttl|nh3 }}{% endblock %} +{% block admin_link %} + {% url 'admin:membershipworks_eventext_change' event.pk %} +{% endblock %} +{% block content %} +

+ Event Name: {{ event.details.ttl|nh3 }} +

+

+ Instructor: {{ event.instructor }} +

+

+ {% with meeting_times=event.meeting_times.all %} + {% if meeting_times|length == 0 %} + Dates of Event: Not set + {% elif meeting_times|length == 1 %} + Date of Event: {{ meeting_times.0.start }} - {{ meeting_times.0.end }} + {% else %} + Dates of Event: +

+ {% endif %} + {% endwith %} +

+

+ Attendees: {{ event.details.cnt }}/{{ event.details.cap }} +

+

+ Materials Fee [m]: + {% if event.materials_fee_included_in_price is None %} + Unknown if included in price + {% elif event.materials_fee_included_in_price %} + {% if event.materials_fee is not None %} + ${{ event.materials_fee|floatformat:2 }} + {% else %} + Not defined + {% endif %} + {% else %} + Not collected by CMS + {% endif %} +

+

+ Instructor Percentage [I]: {{ event.instructor_percentage }} +

+ {% render_table table "membershipworks/tables/invoice_table.dj.html" %} +{% endblock %} diff --git a/membershipworks/templates/membershipworks/tables/invoice_table.dj.html b/membershipworks/templates/membershipworks/tables/invoice_table.dj.html new file mode 100644 index 0000000..ff99fbc --- /dev/null +++ b/membershipworks/templates/membershipworks/tables/invoice_table.dj.html @@ -0,0 +1,45 @@ +{% extends "django_tables2/bootstrap5.html" %} + +{% block table.tfoot %} + {% if table.has_footer %} + + + {% for column in table.columns %} + {% if forloop.first %} + + {{ column.footer }} + + {% else %} + + {{ column.footer }} + + {% endif %} + {% endfor %} + + {% if table.event.instructor_flat_rate != 0 %} + + + Flat Rate + + {% if table.event.instructor_flat_rate is None %} + {{ table.default }} + {% else %} + ${{ table.event.instructor_flat_rate|floatformat:2 }} + {% endif %} + + + {% endif %} + + + Total + + {% if table.event.total_due_to_instructor is None %} + {{ table.default }} + {% else %} + ${{ table.event.total_due_to_instructor|floatformat:2 }} + {% endif %} + + + + {% endif %} +{% endblock table.tfoot %} diff --git a/membershipworks/urls.py b/membershipworks/urls.py index 9bc68de..4925c82 100644 --- a/membershipworks/urls.py +++ b/membershipworks/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .views import ( EventIndexReport, + EventInvoiceView, EventMonthReport, EventYearReport, MemberAutocomplete, @@ -36,4 +37,9 @@ urlpatterns = [ EventMonthReport.as_view(month_format="%m"), name="event-month-report", ), + path( + "event-invoice/", + EventInvoiceView.as_view(), + name="event-invoice", + ), ] diff --git a/membershipworks/views.py b/membershipworks/views.py index 5f72857..6540dad 100644 --- a/membershipworks/views.py +++ b/membershipworks/views.py @@ -7,6 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models.functions import TruncMonth, TruncYear from django.shortcuts import render from django.template.defaultfilters import floatformat +from django.views.generic import DetailView from django.views.generic.dates import ( ArchiveIndexView, MonthArchiveView, @@ -130,6 +131,7 @@ class EventTable(tables.Table): template_code=( '{{ value }} ' ' ' + ' ' ), ) occurred = tables.BooleanColumn(visible=False) @@ -137,6 +139,9 @@ class EventTable(tables.Table): duration = DurationColumn() person_hours = DurationColumn() meetings = tables.Column() + gross_revenue = tables.Column() + total_due_to_instructor = tables.Column() + net_revenue = tables.Column() class Meta: model = EventExt @@ -151,6 +156,9 @@ class EventTable(tables.Table): "meetings", "duration", "person_hours", + "gross_revenue", + "total_due_to_instructor", + "net_revenue", ) row_attrs = { "class": lambda record: ( @@ -167,6 +175,9 @@ class EventSummaryTable(tables.Table): meetings__sum = tables.Column("Meetings") duration__sum = DurationColumn("Class Hours") person_hours__sum = DurationColumn("Person Hours") + gross_revenue__sum = tables.Column("Gross Revenue") + total_due_to_instructor__sum = tables.Column("Total Due to Instructor") + net_revenue__sum = tables.Column("Net Revenue") class EventIndexReport( @@ -185,6 +196,7 @@ class EventIndexReport( return ( super() .get_table_data() + .with_financials() .values(year=TruncYear("start")) .summarize() .order_by("year") @@ -219,6 +231,7 @@ class EventYearReport( return ( super() .get_table_data() + .with_financials() .values(month=TruncMonth("start")) .summarize() .order_by("month") @@ -252,7 +265,67 @@ class EventMonthReport( export_formats = ("csv", "xlsx", "ods") def get_table_data(self): - return super().get_table_data().select_related("category", "instructor") + return ( + super() + .get_table_data() + .select_related("category", "instructor") + .with_financials() + ) def get_export_filename(self, export_format): return f"mw_events_{self.get_year()}-{self.get_month():02}.{export_format}" + + +class InvoiceMoneyColumn(tables.columns.Column): + def render(self, value): + return f"${super().render(value):.2f}" + + +class InvoiceMoneyFooterColumn(InvoiceMoneyColumn): + def render_footer(self, bound_column, table): + value = getattr(table.event, bound_column.accessor) + if value is not None: + return f"${value:.2f}" + else: + return bound_column.default + + +class InvoiceTable(tables.Table): + def __init__(self, *args, **kwargs): + self.event = kwargs.pop("event") + super().__init__(*args, **kwargs) + + label = tables.Column("Ticket Type", footer="Subtotals") + list_price = InvoiceMoneyColumn("Ticket Price") + actual_price = InvoiceMoneyColumn("Actual Price [P]") + quantity = tables.Column("Quantity [Q]", footer=lambda table: table.event.quantity) + amount = InvoiceMoneyFooterColumn("Amount [A = P * Q]") + materials = InvoiceMoneyFooterColumn("CMS Collected Materials Fee [M = m * Q]") + amount_without_materials = InvoiceMoneyFooterColumn( + "Event Revenue Base [R = A - M]" + ) + instructor_fee = InvoiceMoneyFooterColumn("Instructor Fee [F = R * I]") + instructor_amount = InvoiceMoneyFooterColumn("Amount Due to Instructor [F + M]") + + class Meta: + attrs = { + "tbody": {"class": "table-group-divider"}, + "tfoot": {"class": "table-group-divider"}, + } + orderable = False + + +class EventInvoiceView(SingleTableMixin, PermissionRequiredMixin, DetailView): + permission_required = "membershipworks.view_eventext" + queryset = EventExt.objects.with_financials().all() + pk_url_kwarg = "eid" + context_object_name = "event" + template_name = "membershipworks/event_invoice.dj.html" + table_pagination = False + table_class = InvoiceTable + + def get_table_data(self): + return self.object.ticket_types.all() + + def get_table_kwargs(self): + return {"event": self.object} diff --git a/pdm.lock b/pdm.lock index c457fee..afa7671 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "debug", "lint", "server", "typing", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:16af371b3e9b9b16889ab6dc4e3bd5d84e0119a6804a6081ad64f86f45a2259f" +content_hash = "sha256:81d4d8e17c53271139f785226097e04aab990252f78bfd47c237ccb223a54d81" [[package]] name = "aiohttp" @@ -432,6 +432,19 @@ files = [ {file = "django-autocomplete-light-3.9.7.tar.gz", hash = "sha256:a34f192ac438c4df056dbfd399550799ddc631c4661960134ded924648770373"}, ] +[[package]] +name = "django-db-views" +version = "0.1.6" +summary = "Handle database views. Allow to create migrations for database views. View migrations using django code. They can be reversed. Changes in model view definition are detected automatically. Support almost all options as regular makemigrations command" +dependencies = [ + "Django", + "six", +] +files = [ + {file = "django-db-views-0.1.6.tar.gz", hash = "sha256:05718bb87c819323d577b294ee75f25807e5bb767793aa27f1ecc4c7ae073172"}, + {file = "django_db_views-0.1.6-py3-none-any.whl", hash = "sha256:1ae8a6b389a2e8a7a2e246050ce7688780343bf4fd4f9622263b607ae27e5524"}, +] + [[package]] name = "django-debug-toolbar" version = "4.2.0" diff --git a/pyproject.toml b/pyproject.toml index 2d42828..7baae09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "django-tables2~=2.7", "tablib[ods,xlsx]~=3.5", "django-filter~=23.5", + "django-db-views~=0.1", ] requires-python = ">=3.11"