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: +
+ 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 %} + +