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,