cmsmanage/membershipworks/views.py

419 lines
14 KiB
Python

from datetime import datetime, timedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import OuterRef, Q, Subquery
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, ListView
from django.views.generic.dates import (
ArchiveIndexView,
MonthArchiveView,
YearArchiveView,
)
import django_filters
import django_tables2 as tables
from dal import autocomplete
from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
from django_tables2 import A, SingleTableMixin
from django_tables2.export.views import ExportMixin
from membershipworks.membershipworks_api import MembershipWorks
from .models import EventAttendee, EventExt, Member
class MemberAutocomplete(autocomplete.Select2QuerySetView):
model = Member
search_fields = ["account_name"]
def get_queryset(self):
if not self.request.user.has_perm("membershipworks.view_member"):
return Member.objects.none()
else:
return super().get_queryset()
@permission_required("membershipworks.view_eventext")
def upcoming_events(request):
now = datetime.now()
membershipworks = MembershipWorks()
membershipworks.login(
settings.MEMBERSHIPWORKS_USERNAME, settings.MEMBERSHIPWORKS_PASSWORD
)
events = membershipworks.get_events_list(now)
if "error" in events:
messages.add_message(
request,
messages.ERROR,
f"MembershipWorks Error: {events['error']}",
)
# TODO: this should probably be an HTTP 500 response
return render(request, "base.dj.html")
ongoing_events = []
full_events = []
upcoming_events = []
for event in events["evt"]:
try:
# ignore hidden events
if event["cal"] == 0:
continue
event_details = membershipworks.get_event_by_eid(event["eid"])
# Convert timestamps to datetime objects
event_details["sdp_dt"] = datetime.fromtimestamp(event_details["sdp"])
event_details["edp_dt"] = datetime.fromtimestamp(event_details["edp"])
# registration has already ended
if (
"erd" in event_details
and datetime.fromtimestamp(event_details["erd"]) < now
):
ongoing_events.append(event_details)
# class is full
elif event_details["cnt"] >= event_details["cap"]:
full_events.append(event_details)
else:
upcoming_events.append(event_details)
except KeyError as e:
messages.add_message(
request,
messages.ERROR,
f"Event '{event.get('ttl')}' missing required property: '{e.args[0]}'",
)
# TODO: this should probably be an HTTP 500 response
return render(request, "base.dj.html")
context = {
"event_sections": [
{
"title": "Upcoming Events",
"blurb": "Events that are currently open for registration.",
"events": upcoming_events,
"truncate": False,
},
{
"title": "Just Missed",
"blurb": "These classes are currently full at time of writing. If you are interested, please check the event's page; spots occasionally open up. Keep an eye on this newsletter to see when these classes are offered again.",
"events": full_events,
"truncate": True,
},
{
"title": "Ongoing Events",
"blurb": "These events are ongoing. Registration is currently closed, but these events may be offered again in the future.",
"events": ongoing_events,
"truncate": True,
},
]
}
return render(request, "membershipworks/upcoming_events.dj.html", context)
class DurationColumn(tables.Column):
def render(self, value: timedelta):
if value is None:
return None
return floatformat(value.total_seconds() / 60 / 60, -2)
def value(self, value: timedelta):
if value is None:
return None
return value.total_seconds() / 60 / 60
class EventTable(tables.Table):
title = tables.TemplateColumn(
template_code=(
'<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="Invoice" href="{% url "membershipworks:event-invoice" record.pk %}"><i class="bi bi-receipt"></i></a> '
),
)
occurred = tables.BooleanColumn(visible=False)
start = tables.DateColumn("N d, Y")
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
fields = (
"title",
"occurred",
"start",
"instructor",
"category",
"count",
"cap",
"meetings",
"duration",
"person_hours",
"gross_revenue",
"total_due_to_instructor",
"net_revenue",
)
row_attrs = {
"class": lambda record: (
"" if record.occurred else "text-decoration-line-through table-danger"
)
}
class EventSummaryTable(tables.Table):
event_count = tables.Column("Events")
canceled_event_count = tables.Column("Canceled Events")
count__sum = tables.Column("Tickets")
instructor__count = tables.Column("Unique Instructors")
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(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, ArchiveIndexView
):
permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.all()
date_field = "start"
template_name = "membershipworks/event_index_report.dj.html"
make_object_list = True
table_class = EventSummaryTable
export_formats = ("csv", "xlsx", "ods")
export_name = "mw_events_index"
def get_table_data(self):
return (
super()
.get_table_data()
.with_financials()
.values(year=TruncYear("start"))
.summarize()
.order_by("year")
)
def get_table_kwargs(self):
year_column = tables.DateColumn(
"Y",
linkify=(
"membershipworks:event-year-report",
[A("year__year")],
),
)
return {
"sequence": ("year", "..."),
"extra_columns": (("year", year_column),),
}
class EventYearReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, YearArchiveView
):
permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.all()
date_field = "start"
template_name = "membershipworks/event_year_report.dj.html"
make_object_list = True
table_class = EventSummaryTable
export_formats = ("csv", "xlsx", "ods")
def get_table_data(self):
return (
super()
.get_table_data()
.with_financials()
.values(month=TruncMonth("start"))
.summarize()
.order_by("month")
)
def get_export_filename(self, export_format):
return f"mw_events_{self.get_year()}.{export_format}"
def get_table_kwargs(self):
month_column = tables.DateColumn(
"F Y",
linkify=(
"membershipworks:event-month-report",
[A("month__year"), A("month__month")],
),
)
return {
"sequence": ("month", "..."),
"extra_columns": (("month", month_column),),
}
class EventMonthReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, MonthArchiveView
):
permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.all()
date_field = "start"
template_name = "membershipworks/event_month_report.dj.html"
table_class = EventTable
export_formats = ("csv", "xlsx", "ods")
def get_table_data(self):
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}
class EventAttendeeTable(tables.Table):
class Meta:
model = EventAttendee
fields = ("name", "email")
class EventAttendeeFilters(django_filters.FilterSet):
new_since = django_filters.DateFilter(
field_name="event__start", method="filter_new_since"
)
def filter_new_since(self, queryset, name, value):
return queryset.filter(**{f"{name}__gte": value}).exclude(
email__in=Subquery(
queryset.filter(**{f"{name}__lt": value}).values("email")
)
)
class EventAttendeeListView(
BaseFilterView, ExportMixin, SingleTableMixin, PermissionRequiredMixin, ListView
):
permission_required = "membershipworks.view_eventext"
queryset = EventAttendee.objects.all()
table_class = EventAttendeeTable
template_name = "membershipworks/eventattendee_list.dj.html"
export_formats = ("csv", "xlsx", "ods")
filterset_class = EventAttendeeFilters
def get_table_data(self):
return super().get_table_data().values("name", "email").distinct()
class MissingPaperworkTable(tables.Table):
policy_agreement = tables.BooleanColumn()
authorize_charge = tables.BooleanColumn()
class Meta:
model = Member
fields = [
"first_name",
"last_name",
"membership",
"billing_method",
"join_date",
"membership_agreement_signed_and_on_file_date",
"waiver_form_signed_and_on_file_date",
"policy_agreement",
"authorize_charge",
]
class MissingPaperworkReport(
ExportMixin,
SingleTableMixin,
PermissionRequiredMixin,
ListView,
):
model = Member
permission_required = "membershipworks.view_member"
template_name = "membershipworks/missing_paperwork_report.dj.html"
table_class = MissingPaperworkTable
export_formats = ("csv", "xlsx", "ods")
def get_queryset(self):
qs = super().get_queryset()
return (
qs.with_is_active()
.filter(
Q(membership_agreement_signed_and_on_file_date__isnull=True)
| Q(waiver_form_signed_and_on_file_date__isnull=True),
is_active=True,
)
.annotate(
membership=Subquery(
qs.filter(
pk=OuterRef("pk"), flags__type__in=("level", "addon")
).values(m=GroupConcat("flags__name"))
),
)
)