membershipworks: Add event invoices and financial info to event reports
All checks were successful
Ruff / ruff (push) Successful in 22s

This commit is contained in:
Adam Goldsmith 2024-01-31 20:18:46 -05:00
parent fc5a911c76
commit 3d7d428914
9 changed files with 432 additions and 2 deletions

View File

@ -40,6 +40,7 @@ INSTALLED_APPS = [
"django_nh3", "django_nh3",
"django_tables2", "django_tables2",
"django_filters", "django_filters",
"django_db_views",
"tasks.apps.TasksConfig", "tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig", "rentals.apps.RentalsConfig",
"membershipworks.apps.MembershipworksConfig", "membershipworks.apps.MembershipworksConfig",

View File

@ -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,
),
]

View File

@ -5,6 +5,7 @@ import django.core.mail.message
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import ( from django.db.models import (
Case,
Count, Count,
Exists, Exists,
ExpressionWrapper, ExpressionWrapper,
@ -14,9 +15,13 @@ from django.db.models import (
Q, Q,
Subquery, Subquery,
Sum, Sum,
When,
) )
from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from django_db_views.db_view import DBView
class BaseModel(models.Model): class BaseModel(models.Model):
_api_names_override = {} _api_names_override = {}
@ -444,6 +449,40 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
person_hours__sum=Sum("person_hours", filter=F("occurred")), person_hours__sum=Sum("person_hours", filter=F("occurred")),
event_count=Count("eid", filter=F("occurred")), event_count=Count("eid", filter=F("occurred")),
canceled_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" 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

View File

@ -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 %}
<p>
<b>Event Name:</b> {{ event.details.ttl|nh3 }}
</p>
<p>
<b>Instructor:</b> {{ event.instructor }}
</p>
<p>
{% with meeting_times=event.meeting_times.all %}
{% if meeting_times|length == 0 %}
<b>Dates of Event:</b> Not set
{% elif meeting_times|length == 1 %}
<b>Date of Event:</b> {{ meeting_times.0.start }} - {{ meeting_times.0.end }}
{% else %}
<b>Dates of Event:</b>
<ul>
{% for meeting_time in meeting_times %}<li>{{ meeting_time.start }} - {{ meeting_time.end }}</li>{% endfor %}
</ul>
{% endif %}
{% endwith %}
</p>
<p>
<b>Attendees:</b> {{ event.details.cnt }}/{{ event.details.cap }}
</p>
<p>
<b>Materials Fee [m]:</b>
{% 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 %}
</p>
<p>
<b>Instructor Percentage [I]:</b> {{ event.instructor_percentage }}
</p>
{% render_table table "membershipworks/tables/invoice_table.dj.html" %}
{% endblock %}

View File

@ -0,0 +1,45 @@
{% extends "django_tables2/bootstrap5.html" %}
{% block table.tfoot %}
{% if table.has_footer %}
<tfoot {{ table.attrs.tfoot.as_html }}>
<tr>
{% for column in table.columns %}
{% if forloop.first %}
<th for="row" {{ column.attrs.tf.as_html }}>
{{ column.footer }}
</th>
{% else %}
<td {{ column.attrs.tf.as_html }}>
{{ column.footer }}
</td>
{% endif %}
{% endfor %}
</tr>
{% if table.event.instructor_flat_rate != 0 %}
<tr>
<td colspan="{{ table.columns|length|add:-2 }}"></td>
<th scope="row">Flat Rate</th>
<td>
{% if table.event.instructor_flat_rate is None %}
{{ table.default }}
{% else %}
${{ table.event.instructor_flat_rate|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<td colspan="{{ table.columns|length|add:-2 }}"></td>
<th scope="row">Total</th>
<td>
{% if table.event.total_due_to_instructor is None %}
{{ table.default }}
{% else %}
${{ table.event.total_due_to_instructor|floatformat:2 }}
{% endif %}
</td>
</tr>
</tfoot>
{% endif %}
{% endblock table.tfoot %}

View File

@ -2,6 +2,7 @@ from django.urls import path
from .views import ( from .views import (
EventIndexReport, EventIndexReport,
EventInvoiceView,
EventMonthReport, EventMonthReport,
EventYearReport, EventYearReport,
MemberAutocomplete, MemberAutocomplete,
@ -36,4 +37,9 @@ urlpatterns = [
EventMonthReport.as_view(month_format="%m"), EventMonthReport.as_view(month_format="%m"),
name="event-month-report", name="event-month-report",
), ),
path(
"event-invoice/<eid>",
EventInvoiceView.as_view(),
name="event-invoice",
),
] ]

View File

@ -7,6 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models.functions import TruncMonth, TruncYear from django.db.models.functions import TruncMonth, TruncYear
from django.shortcuts import render from django.shortcuts import render
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.views.generic import DetailView
from django.views.generic.dates import ( from django.views.generic.dates import (
ArchiveIndexView, ArchiveIndexView,
MonthArchiveView, MonthArchiveView,
@ -130,6 +131,7 @@ class EventTable(tables.Table):
template_code=( template_code=(
'<a title="MembershipWorks" href="https://membershipworks.com/admin/#!event/admin/{{ record.url }}">{{ value }}</a> ' '<a title="MembershipWorks" href="https://membershipworks.com/admin/#!event/admin/{{ record.url }}">{{ value }}</a> '
'<a title="Admin" href="{% url "admin:membershipworks_eventext_change" record.pk %}"><i class="bi bi-pencil-square"></i></a> ' '<a title="Admin" href="{% url "admin:membershipworks_eventext_change" record.pk %}"><i class="bi bi-pencil-square"></i></a> '
'<a title="Invoice" href="{% url "membershipworks:event-invoice" record.pk %}"><i class="bi bi-receipt"></i></a> '
), ),
) )
occurred = tables.BooleanColumn(visible=False) occurred = tables.BooleanColumn(visible=False)
@ -137,6 +139,9 @@ class EventTable(tables.Table):
duration = DurationColumn() duration = DurationColumn()
person_hours = DurationColumn() person_hours = DurationColumn()
meetings = tables.Column() meetings = tables.Column()
gross_revenue = tables.Column()
total_due_to_instructor = tables.Column()
net_revenue = tables.Column()
class Meta: class Meta:
model = EventExt model = EventExt
@ -151,6 +156,9 @@ class EventTable(tables.Table):
"meetings", "meetings",
"duration", "duration",
"person_hours", "person_hours",
"gross_revenue",
"total_due_to_instructor",
"net_revenue",
) )
row_attrs = { row_attrs = {
"class": lambda record: ( "class": lambda record: (
@ -167,6 +175,9 @@ class EventSummaryTable(tables.Table):
meetings__sum = tables.Column("Meetings") meetings__sum = tables.Column("Meetings")
duration__sum = DurationColumn("Class Hours") duration__sum = DurationColumn("Class Hours")
person_hours__sum = DurationColumn("Person 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( class EventIndexReport(
@ -185,6 +196,7 @@ class EventIndexReport(
return ( return (
super() super()
.get_table_data() .get_table_data()
.with_financials()
.values(year=TruncYear("start")) .values(year=TruncYear("start"))
.summarize() .summarize()
.order_by("year") .order_by("year")
@ -219,6 +231,7 @@ class EventYearReport(
return ( return (
super() super()
.get_table_data() .get_table_data()
.with_financials()
.values(month=TruncMonth("start")) .values(month=TruncMonth("start"))
.summarize() .summarize()
.order_by("month") .order_by("month")
@ -252,7 +265,67 @@ class EventMonthReport(
export_formats = ("csv", "xlsx", "ods") export_formats = ("csv", "xlsx", "ods")
def get_table_data(self): 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): def get_export_filename(self, export_format):
return f"mw_events_{self.get_year()}-{self.get_month():02}.{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}

View File

@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"] groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"] strategy = ["cross_platform"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:16af371b3e9b9b16889ab6dc4e3bd5d84e0119a6804a6081ad64f86f45a2259f" content_hash = "sha256:81d4d8e17c53271139f785226097e04aab990252f78bfd47c237ccb223a54d81"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -432,6 +432,19 @@ files = [
{file = "django-autocomplete-light-3.9.7.tar.gz", hash = "sha256:a34f192ac438c4df056dbfd399550799ddc631c4661960134ded924648770373"}, {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]] [[package]]
name = "django-debug-toolbar" name = "django-debug-toolbar"
version = "4.2.0" version = "4.2.0"

View File

@ -32,6 +32,7 @@ dependencies = [
"django-tables2~=2.7", "django-tables2~=2.7",
"tablib[ods,xlsx]~=3.5", "tablib[ods,xlsx]~=3.5",
"django-filter~=23.5", "django-filter~=23.5",
"django-db-views~=0.1",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"