2024-04-14 01:21:32 -04:00
import uuid
2024-01-22 12:33:34 -05:00
from datetime import datetime , timedelta
2024-04-14 01:21:32 -04:00
from typing import Any
2023-12-22 01:08:20 -05:00
from django . conf import settings
from django . contrib import messages
2024-01-17 11:16:43 -05:00
from django . contrib . auth . decorators import permission_required
2024-04-14 01:21:32 -04:00
from django . contrib . auth . mixins import (
AccessMixin ,
PermissionRequiredMixin ,
)
from django . core import mail
from django . core . files . base import ContentFile
2024-02-16 11:37:30 -05:00
from django . db . models import OuterRef , Q , Subquery
2024-01-19 15:33:54 -05:00
from django . db . models . functions import TruncMonth , TruncYear
2024-04-14 01:21:32 -04:00
from django . http import HttpRequest , HttpResponse
2023-12-22 01:08:20 -05:00
from django . shortcuts import render
2024-01-22 12:33:34 -05:00
from django . template . defaultfilters import floatformat
2024-04-14 01:21:32 -04:00
from django . template . loader import render_to_string
from django . utils import timezone
from django . utils . functional import cached_property
2024-03-04 15:53:53 -05:00
from django . utils . html import format_html
from django . utils . safestring import SafeString
2024-02-02 19:26:06 -05:00
from django . views . generic import DetailView , ListView
2024-01-19 15:33:54 -05:00
from django . views . generic . dates import (
ArchiveIndexView ,
MonthArchiveView ,
YearArchiveView ,
)
2024-04-14 01:21:32 -04:00
from django . views . generic . detail import BaseDetailView
from django . views . generic . edit import FormMixin , ProcessFormView
2023-12-22 01:08:20 -05:00
2024-02-02 19:26:06 -05:00
import django_filters
2024-01-22 12:33:34 -05:00
import django_tables2 as tables
2024-04-14 01:21:32 -04:00
import weasyprint
2022-05-05 17:21:48 -04:00
from dal import autocomplete
2024-02-02 19:26:06 -05:00
from django_filters . views import BaseFilterView
2024-02-16 11:37:30 -05:00
from django_mysql . models import GroupConcat
2024-04-14 01:21:32 -04:00
from django_sendfile import sendfile
2024-01-22 12:33:34 -05:00
from django_tables2 import A , SingleTableMixin
2024-01-22 13:17:02 -05:00
from django_tables2 . export . views import ExportMixin
2024-04-14 01:21:32 -04:00
from django_weasyprint . utils import django_url_fetcher
2022-02-03 13:45:58 -05:00
2024-01-17 21:17:24 -05:00
from membershipworks . membershipworks_api import MembershipWorks
2024-04-14 01:21:32 -04:00
from . forms import EventInvoiceForm
from . invoice_email import make_invoice_emails
from . models import EventAttendee , EventExt , EventInvoice , Member
2022-05-05 17:21:48 -04:00
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 ( )
2023-12-22 01:08:20 -05:00
2024-01-17 11:16:43 -05:00
@permission_required ( " membershipworks.view_eventext " )
2023-12-22 01:08:20 -05:00
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 )
2024-01-15 21:31:06 -05:00
2024-01-22 12:33:34 -05:00
class DurationColumn ( tables . Column ) :
def render ( self , value : timedelta ) :
if value is None :
return None
return floatformat ( value . total_seconds ( ) / 60 / 60 , - 2 )
2024-01-22 13:17:02 -05:00
def value ( self , value : timedelta ) :
if value is None :
return None
return value . total_seconds ( ) / 60 / 60
2024-01-22 12:33:34 -05:00
class EventTable ( tables . Table ) :
2024-01-30 14:28:09 -05:00
title = tables . TemplateColumn (
template_code = (
' <a title= " MembershipWorks " href= " https://membershipworks.com/admin/#!event/admin/ {{ record.url }} " > {{ value }}</a> '
' <a title= " Admin " href= " { % u rl " admin:membershipworks_eventext_change " record.pk % } " ><i class= " bi bi-pencil-square " ></i></a> '
2024-04-14 01:21:32 -04:00
' <a title= " Details " href= " { % u rl " membershipworks:event-detail " record.pk % } " ><i class= " bi bi-receipt " ></i></a> '
2024-01-30 14:28:09 -05:00
) ,
2024-04-04 00:23:06 -04:00
accessor = " unescaped_title " ,
2024-01-22 12:33:34 -05:00
)
2024-01-22 13:17:02 -05:00
occurred = tables . BooleanColumn ( visible = False )
2024-01-22 12:33:34 -05:00
start = tables . DateColumn ( " N d, Y " )
duration = DurationColumn ( )
person_hours = DurationColumn ( )
2024-01-29 14:04:35 -05:00
meetings = tables . Column ( )
2024-01-31 20:18:46 -05:00
gross_revenue = tables . Column ( )
total_due_to_instructor = tables . Column ( )
net_revenue = tables . Column ( )
2024-04-14 01:21:32 -04:00
invoice__date_submitted = tables . DateColumn ( verbose_name = " Invoice Submitted " )
invoice__date_paid = tables . DateColumn ( verbose_name = " Invoice Paid " )
2024-01-22 12:33:34 -05:00
class Meta :
model = EventExt
fields = (
" title " ,
2024-01-22 13:17:02 -05:00
" occurred " ,
2024-01-22 12:33:34 -05:00
" start " ,
" instructor " ,
" category " ,
" count " ,
" cap " ,
2024-01-29 14:04:35 -05:00
" meetings " ,
2024-01-22 12:33:34 -05:00
" duration " ,
" person_hours " ,
2024-01-31 20:18:46 -05:00
" gross_revenue " ,
" total_due_to_instructor " ,
" net_revenue " ,
2024-01-22 12:33:34 -05:00
)
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 " )
2024-01-29 14:04:35 -05:00
meetings__sum = tables . Column ( " Meetings " )
2024-01-22 12:33:34 -05:00
duration__sum = DurationColumn ( " Class Hours " )
person_hours__sum = DurationColumn ( " Person Hours " )
2024-01-31 20:18:46 -05:00
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 " )
2024-01-22 12:33:34 -05:00
2024-01-22 13:17:02 -05:00
class EventIndexReport (
ExportMixin , SingleTableMixin , PermissionRequiredMixin , ArchiveIndexView
) :
2024-01-19 15:33:54 -05:00
permission_required = " membershipworks.view_eventext "
queryset = EventExt . objects . all ( )
date_field = " start "
template_name = " membershipworks/event_index_report.dj.html "
make_object_list = True
2024-01-22 12:33:34 -05:00
table_class = EventSummaryTable
2024-01-22 13:17:02 -05:00
export_formats = ( " csv " , " xlsx " , " ods " )
export_name = " mw_events_index "
2024-01-22 12:33:34 -05:00
2024-01-29 14:13:00 -05:00
def get_table_data ( self ) :
return (
super ( )
. get_table_data ( )
2024-01-31 20:18:46 -05:00
. with_financials ( )
2024-01-29 14:13:00 -05:00
. values ( year = TruncYear ( " start " ) )
. summarize ( )
. order_by ( " year " )
)
2024-01-22 12:33:34 -05:00
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 ) , ) ,
}
2024-01-19 15:33:54 -05:00
2024-01-22 13:17:02 -05:00
class EventYearReport (
ExportMixin , SingleTableMixin , PermissionRequiredMixin , YearArchiveView
) :
2024-01-19 15:33:54 -05:00
permission_required = " membershipworks.view_eventext "
queryset = EventExt . objects . all ( )
date_field = " start "
template_name = " membershipworks/event_year_report.dj.html "
make_object_list = True
2024-01-22 12:33:34 -05:00
table_class = EventSummaryTable
2024-01-22 13:17:02 -05:00
export_formats = ( " csv " , " xlsx " , " ods " )
2024-01-29 14:13:00 -05:00
def get_table_data ( self ) :
return (
super ( )
. get_table_data ( )
2024-01-31 20:18:46 -05:00
. with_financials ( )
2024-01-29 14:13:00 -05:00
. values ( month = TruncMonth ( " start " ) )
. summarize ( )
. order_by ( " month " )
)
2024-01-22 13:17:02 -05:00
def get_export_filename ( self , export_format ) :
return f " mw_events_ { self . get_year ( ) } . { export_format } "
2024-01-22 12:33:34 -05:00
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 ) , ) ,
}
2024-01-19 15:33:54 -05:00
2024-01-22 13:17:02 -05:00
class EventMonthReport (
ExportMixin , SingleTableMixin , PermissionRequiredMixin , MonthArchiveView
) :
2024-01-15 21:31:06 -05:00
permission_required = " membershipworks.view_eventext "
2024-01-29 14:13:00 -05:00
queryset = EventExt . objects . all ( )
2024-01-15 21:31:06 -05:00
date_field = " start "
2024-01-17 11:15:57 -05:00
template_name = " membershipworks/event_month_report.dj.html "
2024-01-22 12:33:34 -05:00
table_class = EventTable
2024-01-22 13:17:02 -05:00
export_formats = ( " csv " , " xlsx " , " ods " )
2024-01-29 14:13:00 -05:00
def get_table_data ( self ) :
2024-01-31 20:18:46 -05:00
return (
super ( )
. get_table_data ( )
2024-04-14 01:21:32 -04:00
. select_related ( " category " , " instructor " , " invoice " )
2024-01-31 20:18:46 -05:00
. with_financials ( )
)
2024-01-29 14:13:00 -05:00
2024-01-22 13:17:02 -05:00
def get_export_filename ( self , export_format ) :
return f " mw_events_ { self . get_year ( ) } - { self . get_month ( ) : 02 } . { export_format } "
2024-01-31 20:18:46 -05:00
2024-04-14 01:21:32 -04:00
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
export_formats = ( " csv " , " xlsx " , " ods " )
template_name = " membershipworks/user_event_list.dj.html "
@cached_property
def member ( self ) - > Member | None :
return Member . from_user ( self . request . user )
def get_queryset ( self ) :
if self . member is None :
return self . model . objects . none ( )
else :
return super ( ) . get_queryset ( ) . filter ( instructor__member = self . member )
def get_table_data ( self ) :
return (
super ( )
. get_table_data ( )
. select_related ( " category " , " instructor " , " invoice " )
. with_financials ( )
)
def get_export_filename ( self , export_format ) :
return f " my_events_ { self . member . uid if self . member is not None else ' no-uid ' } . { export_format } "
2024-01-31 20:18:46 -05:00
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 )
2024-03-04 15:53:53 -05:00
@staticmethod
def _math_header ( name : str , formula : str ) - > SafeString :
return format_html (
' {} <div class= " text-nowrap font-monospace fw-light " >[ {} ]</div> ' ,
name ,
formula ,
)
2024-01-31 20:18:46 -05:00
label = tables . Column ( " Ticket Type " , footer = " Subtotals " )
list_price = InvoiceMoneyColumn ( " Ticket Price " )
2024-03-04 15:53:53 -05:00
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 " )
)
2024-01-31 20:18:46 -05:00
amount_without_materials = InvoiceMoneyFooterColumn (
2024-03-04 15:53:53 -05:00
_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 " )
2024-01-31 20:18:46 -05:00
)
class Meta :
attrs = {
2024-04-14 01:21:32 -04:00
" class " : " table table-sm mx-auto w-auto " ,
2024-01-31 20:18:46 -05:00
" tbody " : { " class " : " table-group-divider " } ,
" tfoot " : { " class " : " table-group-divider " } ,
}
orderable = False
2024-04-14 01:21:32 -04:00
class EventDetailView (
SingleTableMixin , FormMixin , AccessMixin , DetailView , ProcessFormView
) :
2024-01-31 20:18:46 -05:00
permission_required = " membershipworks.view_eventext "
queryset = EventExt . objects . with_financials ( ) . all ( )
pk_url_kwarg = " eid "
context_object_name = " event "
2024-04-14 01:21:32 -04:00
template_name = " membershipworks/event_detail.dj.html "
2024-01-31 20:18:46 -05:00
table_pagination = False
table_class = InvoiceTable
2024-04-14 01:21:32 -04:00
form_class = EventInvoiceForm
def render_to_response (
self , context : dict [ str , Any ] , * * response_kwargs : Any
) - > HttpResponse :
if self . request . user . has_perm (
self . permission_required
) or self . object . user_is_instructor ( self . request . user ) :
return super ( ) . render_to_response ( context , * * response_kwargs )
else :
return self . handle_no_permission ( )
def display_instructor_version ( self ) :
return (
self . request . method == " POST " # generating a PDF
or not self . request . user . has_perm ( self . permission_required )
or self . request . GET . get ( " instructor_view " )
)
2024-01-31 20:18:46 -05:00
def get_table_data ( self ) :
2024-04-14 01:21:32 -04:00
if self . display_instructor_version ( ) :
return self . object . ticket_types . group_by_ticket_type ( )
else :
return self . object . ticket_types . all ( )
2024-01-31 20:18:46 -05:00
def get_table_kwargs ( self ) :
2024-04-14 01:21:32 -04:00
kwargs = super ( ) . get_table_kwargs ( )
kwargs [ " event " ] = self . object
if self . display_instructor_version ( ) :
kwargs [ " exclude " ] = [
" list_price " ,
]
return kwargs
def get_success_url ( self ) :
return self . request . build_absolute_uri ( )
def get_context_data ( self , * * kwargs ) :
context = super ( ) . get_context_data ( * * kwargs )
context [ " user_is_instructor " ] = self . object . user_is_instructor (
self . request . user
)
return context
def post ( self , request , * args , * * kwargs ) :
self . object = self . get_object ( )
return super ( ) . post ( request , * args , * * kwargs )
def get_form_kwargs ( self ) :
kwargs = super ( ) . get_form_kwargs ( )
kwargs [ " event " ] = self . object
kwargs [ " user " ] = self . request . user
return kwargs
def form_valid ( self , form : EventInvoiceForm ) :
self . object = self . get_object ( )
event = self . object
invoice_uuid = uuid . uuid4 ( )
pdf_context = self . get_context_data ( object = event )
pdf_context . update ( { " now " : timezone . now ( ) , " invoice_uuid " : invoice_uuid } )
weasy_html = weasyprint . HTML (
string = render_to_string (
" membershipworks/event_invoice_pdf.dj.html " ,
context = pdf_context ,
request = self . request ,
) ,
url_fetcher = django_url_fetcher ,
base_url = " file:// " ,
)
pdf = weasy_html . write_pdf ( )
# the result will be None only if a target was provided
assert pdf is not None
# NOTE: this is only saved AFTER the emails are successfully sent
invoice = EventInvoice (
uuid = invoice_uuid ,
event = event ,
# NOTE: this needs to be resolved before the object is
# saved, so cannot use the Now() db function
date_submitted = timezone . now ( ) ,
amount = event . total_due_to_instructor ,
)
# removed), currently used in event_invoice_admin.dj.html.
emails = make_invoice_emails (
invoice , pdf , self . request . build_absolute_uri ( event . get_absolute_url ( ) )
)
with mail . get_connection ( ) as conn :
conn . send_messages ( emails )
# this also saves the invoice object
invoice . pdf . save ( f " { event . eid } .pdf " , ContentFile ( pdf ) )
messages . success (
self . request ,
" Created Invoice! You should receive a confirmation email shortly. " ,
)
return super ( ) . form_valid ( form )
class EventInvoicePDFView ( AccessMixin , BaseDetailView ) :
model = EventInvoice
pk_url_kwarg = " uuid "
def get ( self , request : HttpRequest , * args : Any , * * kwargs : Any ) - > HttpResponse :
invoice = self . get_object ( )
if request . user . has_perm (
" membershipworks.view_eventinvoice "
) or invoice . event . user_is_instructor ( request . user ) :
# return HttpResponse(invoice.pdf.path)
return sendfile ( request , invoice . pdf . path , mimetype = " application/pdf " )
else :
return self . handle_no_permission ( )
2024-02-02 19:26:06 -05:00
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 ( )
2024-02-16 11:37:30 -05:00
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 " ) )
) ,
)
)