368 lines
12 KiB
Python
368 lines
12 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 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_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()
|