diff --git a/doorcontrol/tables.py b/doorcontrol/tables.py new file mode 100644 index 0000000..1ee561d --- /dev/null +++ b/doorcontrol/tables.py @@ -0,0 +1,63 @@ +import calendar + +import django_tables2 as tables + +from .models import HIDEvent + + +class UnitTimeTable(tables.Table): + members = tables.columns.Column() + members_delta = tables.columns.TemplateColumn( + "{{ value|floatformat:2}}%", verbose_name="Δ Members" + ) + access_count = tables.columns.Column() + access_count_delta = tables.columns.TemplateColumn( + "{{ value|floatformat:2}}%", verbose_name="Δ Access Count" + ) + + class Meta: + fields = ("members", "members_delta", "access_count", "access_count_delta") + + +class DeniedAccessTable(tables.Table): + name = tables.TemplateColumn( + "{{ record.forename|default:'' }} {{ record.surname|default:'' }}" + ) + + class Meta: + model = HIDEvent + + fields = ( + "timestamp", + "door", + "event_type", + "name", + "raw_card_number", + "decoded_card_number", + ) + + +class MostActiveMembersTable(tables.Table): + name = tables.Column() + access_count = tables.Column() + + +class DetailByDayTable(tables.Table): + timestamp__date = tables.DateColumn(verbose_name="Date") + name = tables.Column() + access_count = tables.Column() + + +class BusiestDayOfWeekTable(tables.Table): + timestamp__week_day = tables.Column("Week Day") + events = tables.Column() + members = tables.Column() + + def render_timestamp__week_day(self, value): + return calendar.day_name[(value - 2) % 7] + + +class BusiestTimeOfDayTable(tables.Table): + timestamp__hour = tables.TemplateColumn("{{ value }}:00", verbose_name="Hour") + events = tables.Column() + members = tables.Column() diff --git a/doorcontrol/views.py b/doorcontrol/views.py index cd702a6..e91f283 100644 --- a/doorcontrol/views.py +++ b/doorcontrol/views.py @@ -1,4 +1,3 @@ -import calendar import datetime from django.contrib.auth.mixins import PermissionRequiredMixin @@ -19,6 +18,14 @@ from django_tables2 import SingleTableMixin from django_tables2.export.views import ExportMixin from .models import Door, HIDEvent +from .tables import ( + BusiestDayOfWeekTable, + BusiestTimeOfDayTable, + DeniedAccessTable, + DetailByDayTable, + MostActiveMembersTable, + UnitTimeTable, +) REPORTS = [] @@ -96,20 +103,6 @@ class BaseAccessReport( return context -class UnitTimeTable(tables.Table): - members = tables.columns.Column() - members_delta = tables.columns.TemplateColumn( - "{{ value|floatformat:2}}%", verbose_name="Δ Members" - ) - access_count = tables.columns.Column() - access_count_delta = tables.columns.TemplateColumn( - "{{ value|floatformat:2}}%", verbose_name="Δ Access Count" - ) - - class Meta: - fields = ("members", "members_delta", "access_count", "access_count_delta") - - @register_report class AccessPerUnitTime(BaseAccessReport): table_class = UnitTimeTable @@ -209,24 +202,6 @@ class AccessPerUnitTime(BaseAccessReport): ) -class DeniedAccessTable(tables.Table): - name = tables.TemplateColumn( - "{{ record.forename|default:'' }} {{ record.surname|default:'' }}" - ) - - class Meta: - model = HIDEvent - - fields = ( - "timestamp", - "door", - "event_type", - "name", - "raw_card_number", - "decoded_card_number", - ) - - @register_report class DeniedAccess(BaseAccessReport): _report_name = "Denied Access" @@ -244,11 +219,6 @@ class DeniedAccess(BaseAccessReport): ) -class MostActiveMembersTable(tables.Table): - name = tables.Column() - access_count = tables.Column() - - @register_report class MostActiveMembers(BaseAccessReport): _report_name = "Most Active Members" @@ -271,12 +241,6 @@ class MostActiveMembers(BaseAccessReport): ) -class DetailByDayTable(tables.Table): - timestamp__date = tables.DateColumn(verbose_name="Date") - name = tables.Column() - access_count = tables.Column() - - @register_report class DetailByDay(BaseAccessReport): _report_name = "Detail by Day" @@ -299,15 +263,6 @@ class DetailByDay(BaseAccessReport): ) -class BusiestDayOfWeekTable(tables.Table): - timestamp__week_day = tables.Column("Week Day") - events = tables.Column() - members = tables.Column() - - def render_timestamp__week_day(self, value): - return calendar.day_name[(value - 2) % 7] - - @register_report class BusiestDayOfWeek(BaseAccessReport): _report_name = "Busiest Day of the Week" @@ -326,12 +281,6 @@ class BusiestDayOfWeek(BaseAccessReport): ) -class BusiestTimeOfDayTable(tables.Table): - timestamp__hour = tables.TemplateColumn("{{ value }}:00", verbose_name="Hour") - events = tables.Column() - members = tables.Column() - - @register_report class BusiestTimeOfDay(BaseAccessReport): _report_name = "Busiest Time of Day" diff --git a/membershipworks/tables.py b/membershipworks/tables.py new file mode 100644 index 0000000..9371dd7 --- /dev/null +++ b/membershipworks/tables.py @@ -0,0 +1,168 @@ +from datetime import timedelta + +from django.template.defaultfilters import floatformat +from django.utils.html import format_html +from django.utils.safestring import SafeString + +import django_tables2 as tables + +from .models import EventAttendee, EventExt, Member + + +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=( + '{{ value }} ' + ' ' + ' ' + ), + accessor="unescaped_title", + ) + 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() + invoice__date_submitted = tables.DateColumn(verbose_name="Invoice Submitted") + invoice__date_paid = tables.DateColumn(verbose_name="Invoice Paid") + + 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 UserEventTable(EventTable): + title = tables.Column(linkify=True, accessor="unescaped_title") + instructor = None + person_hours = None + gross_revenue = None + net_revenue = None + + +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) + + @staticmethod + def _math_header(name: str, formula: str) -> SafeString: + return format_html( + '{}
[{}]
', + name, + formula, + ) + + label = tables.Column("Ticket Type", footer="Subtotals") + list_price = InvoiceMoneyColumn("Ticket Price") + actual_price = InvoiceMoneyColumn(_math_header("Actual Price", "P")) + quantity = tables.Column( + _math_header("Quantity", "Q"), + footer=lambda table: table.event.quantity, + ) + amount = InvoiceMoneyFooterColumn(_math_header("Amount", "A=P*Q")) + materials = InvoiceMoneyFooterColumn( + _math_header("CMS Collected Materials Fee", "M=m*Q") + ) + amount_without_materials = InvoiceMoneyFooterColumn( + _math_header("Event Revenue Base", "B=A-M") + ) + instructor_revenue = InvoiceMoneyFooterColumn( + _math_header("Instructor Percentage Revenue", "R=B*I") + ) + instructor_amount = InvoiceMoneyFooterColumn( + _math_header("Amount Due to Instructor", "R+M") + ) + + class Meta: + attrs = { + "class": "table table-sm mx-auto w-auto", + "tbody": {"class": "table-group-divider"}, + "tfoot": {"class": "table-group-divider"}, + } + orderable = False + + +class EventAttendeeTable(tables.Table): + class Meta: + model = EventAttendee + fields = ("name", "email") + + +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", + ] diff --git a/membershipworks/views.py b/membershipworks/views.py index b18487d..8612ee0 100644 --- a/membershipworks/views.py +++ b/membershipworks/views.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta +from datetime import datetime from typing import Any from django.conf import settings @@ -15,12 +15,9 @@ from django.db.models import OuterRef, Q, Subquery from django.db.models.functions import TruncMonth, TruncYear from django.http import HttpRequest, HttpResponse from django.shortcuts import render -from django.template.defaultfilters import floatformat from django.template.loader import render_to_string from django.utils import timezone from django.utils.functional import cached_property -from django.utils.html import format_html -from django.utils.safestring import SafeString from django.views.generic import DetailView, ListView from django.views.generic.dates import ( ArchiveIndexView, @@ -46,6 +43,14 @@ from membershipworks.membershipworks_api import MembershipWorks from .forms import EventInvoiceForm from .invoice_email import make_invoice_emails from .models import EventAttendee, EventExt, EventInvoice, Member +from .tables import ( + EventAttendeeTable, + EventSummaryTable, + EventTable, + InvoiceTable, + MissingPaperworkTable, + UserEventTable, +) class MemberAutocomplete(autocomplete.Select2QuerySetView): @@ -138,75 +143,6 @@ def upcoming_events(request): 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=( - '{{ value }} ' - ' ' - ' ' - ), - accessor="unescaped_title", - ) - 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() - invoice__date_submitted = tables.DateColumn(verbose_name="Invoice Submitted") - invoice__date_paid = tables.DateColumn(verbose_name="Invoice Paid") - - 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 ): @@ -303,14 +239,6 @@ class EventMonthReport( return f"mw_events_{self.get_year()}-{self.get_month():02}.{export_format}" -class UserEventTable(EventTable): - title = tables.Column(linkify=True, accessor="unescaped_title") - instructor = None - person_hours = None - gross_revenue = None - net_revenue = None - - class UserEventView(SingleTableMixin, ListView): model = EventExt table_class = UserEventTable @@ -339,63 +267,6 @@ class UserEventView(SingleTableMixin, ListView): return f"my_events_{self.member.uid if self.member is not None else 'no-uid'}.{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) - - @staticmethod - def _math_header(name: str, formula: str) -> SafeString: - return format_html( - '{}
[{}]
', - name, - formula, - ) - - label = tables.Column("Ticket Type", footer="Subtotals") - list_price = InvoiceMoneyColumn("Ticket Price") - actual_price = InvoiceMoneyColumn(_math_header("Actual Price", "P")) - quantity = tables.Column( - _math_header("Quantity", "Q"), - footer=lambda table: table.event.quantity, - ) - amount = InvoiceMoneyFooterColumn(_math_header("Amount", "A=P*Q")) - materials = InvoiceMoneyFooterColumn( - _math_header("CMS Collected Materials Fee", "M=m*Q") - ) - amount_without_materials = InvoiceMoneyFooterColumn( - _math_header("Event Revenue Base", "B=A-M") - ) - instructor_revenue = InvoiceMoneyFooterColumn( - _math_header("Instructor Percentage Revenue", "R=B*I") - ) - instructor_amount = InvoiceMoneyFooterColumn( - _math_header("Amount Due to Instructor", "R+M") - ) - - class Meta: - attrs = { - "class": "table table-sm mx-auto w-auto", - "tbody": {"class": "table-group-divider"}, - "tfoot": {"class": "table-group-divider"}, - } - orderable = False - - class EventDetailView( SingleTableMixin, FormMixin, AccessMixin, DetailView, ProcessFormView ): @@ -527,12 +398,6 @@ class EventInvoicePDFView(AccessMixin, BaseDetailView): return self.handle_no_permission() -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" @@ -560,25 +425,6 @@ class EventAttendeeListView( 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, diff --git a/paperwork/tables.py b/paperwork/tables.py new file mode 100644 index 0000000..f0d0f01 --- /dev/null +++ b/paperwork/tables.py @@ -0,0 +1,92 @@ +import django_tables2 as tables + +from .models import ( + InstructorOrVendor, + Waiver, +) + + +class WarnEmptyColumn(tables.Column): + attrs = { + "td": { + "class": lambda value, bound_column: ( + "table-danger" if value == bound_column.default else "" + ) + } + } + + +class WaiverReportTable(tables.Table): + emergency_contact_name = WarnEmptyColumn() + emergency_contact_number = WarnEmptyColumn() + + class Meta: + model = Waiver + fields = [ + "name", + "date", + "emergency_contact_name", + "emergency_contact_number", + "waiver_version", + "guardian_name", + "guardian_relation", + "guardian_date", + ] + + +class InstructorOrVendorTable(tables.Table): + instructor_agreement_date = WarnEmptyColumn( + "Instructor Agreement Date(s)", default="Missing" + ) + w9_date = WarnEmptyColumn(default="Missing") + + class Meta: + model = InstructorOrVendor + fields = [ + "name", + "instructor_agreement_date", + "w9_date", + "phone", + "email_address", + ] + + +class ShopAccessErrorColumn(tables.Column): + def td_class(value): + if value.startswith("Has access but"): + return "table-danger" + elif value.startswith("Has cert but"): + return "table-warning" + else: + return "" + + attrs = {"td": {"class": td_class}} + + +class AccessVerificationTable(tables.Table): + account_name = tables.Column() + access_card = tables.Column() + billing_method = tables.Column() + join_date = tables.DateColumn() + renewal_date = tables.DateColumn() + access_front_door = tables.BooleanColumn(verbose_name="Front Door") + access_studio_space = tables.BooleanColumn(verbose_name="Studio Space") + wood_shop_error = ShopAccessErrorColumn() + metal_shop_error = ShopAccessErrorColumn() + extended_hours_error = ShopAccessErrorColumn() + extended_hours_shops_error = ShopAccessErrorColumn() + storage_closet_error = ShopAccessErrorColumn() + + +class CertifiersTable(tables.Table): + certified_by = tables.Column() + certification_version__definition__name = tables.Column("Certification") + certification_version__definition__department__name = tables.Column("Department") + number_issued_on_this_tool = tables.Column() + last_issued_certification_date = tables.Column() + + +class CertificationCountTable(tables.Table): + certification_version__definition__name = tables.Column("Certification") + certification_version__definition__department__name = tables.Column("Department") + total_issued = tables.Column() diff --git a/paperwork/views.py b/paperwork/views.py index 1136b7b..0c880ba 100644 --- a/paperwork/views.py +++ b/paperwork/views.py @@ -19,7 +19,6 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFou from django.shortcuts import get_object_or_404, render from django.views.generic import ListView -import django_tables2 as tables import requests import weasyprint from django_mysql.models import GroupConcat @@ -35,6 +34,13 @@ from .models import ( InstructorOrVendor, Waiver, ) +from .tables import ( + AccessVerificationTable, + CertificationCountTable, + CertifiersTable, + InstructorOrVendorTable, + WaiverReportTable, +) WIKI_URL = settings.WIKI_URL @@ -120,34 +126,6 @@ def certification_pdf(request, cert_name): ) -class WarnEmptyColumn(tables.Column): - attrs = { - "td": { - "class": lambda value, bound_column: "table-danger" - if value == bound_column.default - else "" - } - } - - -class WaiverReportTable(tables.Table): - emergency_contact_name = WarnEmptyColumn() - emergency_contact_number = WarnEmptyColumn() - - class Meta: - model = Waiver - fields = [ - "name", - "date", - "emergency_contact_name", - "emergency_contact_number", - "waiver_version", - "guardian_name", - "guardian_relation", - "guardian_date", - ] - - class WaiverReport(ExportMixin, SingleTableMixin, PermissionRequiredMixin, ListView): permission_required = "paperwork.view_waiver" template_name = "paperwork/waiver_report.dj.html" @@ -156,23 +134,6 @@ class WaiverReport(ExportMixin, SingleTableMixin, PermissionRequiredMixin, ListV table_pagination = False -class InstructorOrVendorTable(tables.Table): - instructor_agreement_date = WarnEmptyColumn( - "Instructor Agreement Date(s)", default="Missing" - ) - w9_date = WarnEmptyColumn(default="Missing") - - class Meta: - model = InstructorOrVendor - fields = [ - "name", - "instructor_agreement_date", - "w9_date", - "phone", - "email_address", - ] - - class InstructorOrVendorReport( ExportMixin, SingleTableMixin, @@ -203,33 +164,6 @@ class InstructorOrVendorReport( ) -class ShopAccessErrorColumn(tables.Column): - def td_class(value): - if value.startswith("Has access but"): - return "table-danger" - elif value.startswith("Has cert but"): - return "table-warning" - else: - return "" - - attrs = {"td": {"class": td_class}} - - -class AccessVerificationTable(tables.Table): - account_name = tables.Column() - access_card = tables.Column() - billing_method = tables.Column() - join_date = tables.DateColumn() - renewal_date = tables.DateColumn() - access_front_door = tables.BooleanColumn(verbose_name="Front Door") - access_studio_space = tables.BooleanColumn(verbose_name="Studio Space") - wood_shop_error = ShopAccessErrorColumn() - metal_shop_error = ShopAccessErrorColumn() - extended_hours_error = ShopAccessErrorColumn() - extended_hours_shops_error = ShopAccessErrorColumn() - storage_closet_error = ShopAccessErrorColumn() - - class AccessVerificationReport( ExportMixin, SingleTableMixin, @@ -317,14 +251,6 @@ class AccessVerificationReport( return qs -class CertifiersTable(tables.Table): - certified_by = tables.Column() - certification_version__definition__name = tables.Column("Certification") - certification_version__definition__department__name = tables.Column("Department") - number_issued_on_this_tool = tables.Column() - last_issued_certification_date = tables.Column() - - class CertifiersReport( ExportMixin, SingleTableMixin, @@ -356,12 +282,6 @@ class CertifiersReport( ) -class CertificationCountTable(tables.Table): - certification_version__definition__name = tables.Column("Certification") - certification_version__definition__department__name = tables.Column("Department") - total_issued = tables.Column() - - class CertificationCountReport( ExportMixin, SingleTableMixin,