2024-04-05 14:10:08 -04:00
import uuid
2024-05-04 16:38:51 -04:00
from datetime import datetime , timedelta
from typing import TYPE_CHECKING , TypedDict
2023-04-25 23:23:27 -04:00
2023-01-17 16:26:53 -05:00
import django . core . mail . message
from django . conf import settings
2024-04-14 01:21:32 -04:00
from django . contrib . auth . models import AbstractBaseUser
2022-02-03 13:45:58 -05:00
from django . db import models
2024-01-17 13:37:14 -05:00
from django . db . models import (
2024-01-31 20:18:46 -05:00
Case ,
2024-01-17 13:37:14 -05:00
Count ,
Exists ,
ExpressionWrapper ,
F ,
2024-01-29 21:48:19 -05:00
Func ,
2024-01-17 13:37:14 -05:00
OuterRef ,
2024-01-19 15:22:05 -05:00
Q ,
2024-05-04 16:38:51 -04:00
QuerySet ,
2024-01-17 13:37:14 -05:00
Subquery ,
Sum ,
2024-03-08 15:28:25 -05:00
Value ,
2024-01-31 20:18:46 -05:00
When ,
2024-01-17 13:37:14 -05:00
)
2024-01-31 20:18:46 -05:00
from django . db . models . functions import Coalesce
2024-04-14 01:21:32 -04:00
from django . urls import reverse
2023-12-19 23:43:49 -05:00
from django . utils import timezone
2022-02-03 13:45:58 -05:00
2024-04-04 00:23:06 -04:00
import nh3
2024-01-31 20:18:46 -05:00
from django_db_views . db_view import DBView
2024-05-04 16:38:51 -04:00
from django_stubs_ext import WithAnnotations
2024-01-31 20:18:46 -05:00
2024-08-05 22:48:36 -04:00
from reservations . models import Reservation
2022-02-03 13:45:58 -05:00
2022-02-28 16:06:10 -05:00
class BaseModel ( models . Model ) :
2024-05-04 16:38:51 -04:00
_api_names_override : dict [ str , str ] = { }
_date_fields : dict [ str , str | None ] = { }
_allowed_missing_fields : list [ str ] = [ ]
2022-02-28 16:06:10 -05:00
class Meta :
abstract = True
@classmethod
2022-03-02 17:17:30 -05:00
def _remap_headers ( cls , data ) :
for field in cls . _meta . get_fields ( ) :
# TODO: more robust filtering of fields that don't have a column
2024-01-24 16:32:42 -05:00
if (
field . auto_created
or field . many_to_many
or not field . concrete
or field . generated
) :
2022-03-02 17:17:30 -05:00
continue
2022-02-28 16:06:10 -05:00
2023-12-30 13:00:45 -05:00
field_name , api_name = field . get_attname_column ( )
2022-03-24 19:44:28 -04:00
2023-12-30 13:00:45 -05:00
if field_name in cls . _api_names_override :
api_name = cls . _api_names_override [ field_name ]
2022-02-28 16:06:10 -05:00
2023-12-30 13:00:45 -05:00
yield field_name , data [ api_name ]
2022-02-28 16:06:10 -05:00
@classmethod
2023-12-30 13:00:45 -05:00
def from_api_dict ( cls , data ) :
2022-02-28 16:06:10 -05:00
data = data . copy ( )
2023-12-30 13:00:45 -05:00
for field in cls . _allowed_missing_fields :
if field not in data :
data [ field ] = None
2022-02-28 16:06:10 -05:00
# parse date fields to datetime objects
for field , fmt in cls . _date_fields . items ( ) :
if data [ field ] :
2023-12-30 13:00:45 -05:00
if fmt is None :
# can't use '%s' format string, have to use the special function
data [ field ] = datetime . fromtimestamp (
data [ field ] , tz = timezone . get_current_timezone ( )
)
else :
data [ field ] = datetime . strptime ( str ( data [ field ] ) , fmt )
2022-02-28 16:06:10 -05:00
else :
# convert empty string to None to make NULL in SQL
data [ field ] = None
return cls ( * * dict ( cls . _remap_headers ( data ) ) )
class Flag ( BaseModel ) :
2022-02-10 16:51:32 -05:00
id = models . CharField ( max_length = 24 , primary_key = True )
2022-02-27 18:45:06 -05:00
name = models . TextField ( null = True , blank = True )
2022-02-10 16:51:32 -05:00
type = models . CharField ( max_length = 6 )
class Meta :
2022-02-11 13:48:47 -05:00
db_table = " flag "
2023-01-20 13:16:47 -05:00
ordering = ( " name " , )
2022-02-10 16:51:32 -05:00
2024-06-05 13:18:25 -04:00
def __str__ ( self ) :
return f " { self . name } ( { self . type } ) "
2022-02-10 16:51:32 -05:00
2023-01-03 23:16:06 -05:00
class MemberQuerySet ( models . QuerySet ) :
2023-08-26 20:18:23 -04:00
# TODO: maybe rename to reflect EXISTS?
2023-01-03 23:16:06 -05:00
@staticmethod
def has_flag ( flag_type : str , flag_name : str ) :
2023-09-07 10:56:22 -04:00
return Exists (
Flag . objects . filter ( type = flag_type , name = flag_name , members = OuterRef ( " pk " ) )
)
2023-08-26 20:18:23 -04:00
# TODO: it should be fairly easy to reduce the number of EXISTS by
# merging the ORed flags
2023-01-03 23:16:06 -05:00
def with_is_active ( self ) :
return self . annotate (
is_active = (
self . has_flag ( " folder " , " Members " )
| self . has_flag ( " folder " , " CMS Staff " )
)
& ~ (
self . has_flag ( " label " , " Account On Hold " )
| self . has_flag ( " level " , " CMS Membership on hold " )
| self . has_flag ( " folder " , " Former Members " )
)
)
2022-02-10 16:51:32 -05:00
# TODO: is this still a temporal table?
2022-02-28 16:06:10 -05:00
class Member ( BaseModel ) :
2022-02-10 16:51:32 -05:00
uid = models . CharField ( max_length = 24 , primary_key = True )
2022-02-27 18:45:06 -05:00
year_of_birth = models . TextField ( db_column = " Year of Birth " , null = True , blank = True )
account_name = models . TextField ( db_column = " Account Name " , null = True , blank = True )
first_name = models . TextField ( db_column = " First Name " , null = True , blank = True )
last_name = models . TextField ( db_column = " Last Name " , null = True , blank = True )
phone = models . TextField ( db_column = " Phone " , null = True , blank = True )
email = models . TextField ( db_column = " Email " , null = True , blank = True )
volunteer_email = models . TextField (
db_column = " Volunteer Email " , null = True , blank = True
)
address_street = models . TextField (
db_column = " Address (Street) " , null = True , blank = True
)
address_city = models . TextField ( db_column = " Address (City) " , null = True , blank = True )
2022-02-10 16:51:32 -05:00
address_state_province = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Address (State/Province) " , null = True , blank = True
)
address_postal_code = models . TextField (
db_column = " Address (Postal Code) " , null = True , blank = True
)
address_country = models . TextField (
db_column = " Address (Country) " , null = True , blank = True
)
profile_description = models . TextField (
db_column = " Profile description " , null = True , blank = True
)
website = models . TextField ( db_column = " Website " , null = True , blank = True )
fax = models . TextField ( db_column = " Fax " , null = True , blank = True )
contact_person = models . TextField ( db_column = " Contact Person " , null = True , blank = True )
password = models . TextField ( db_column = " Password " , null = True , blank = True )
position_relation = models . TextField (
db_column = " Position/relation " , null = True , blank = True
)
parent_account_id = models . TextField (
db_column = " Parent Account ID " , null = True , blank = True
)
closet_storage = models . TextField (
db_column = " Closet Storage # " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
2022-02-27 18:45:06 -05:00
storage_shelf = models . TextField ( db_column = " Storage Shelf # " , null = True , blank = True )
2022-02-11 13:48:47 -05:00
personal_studio_space = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Personal Studio Space # " , null = True , blank = True
2022-02-11 13:48:47 -05:00
)
2022-02-10 16:51:32 -05:00
access_permitted_shops_during_extended_hours = models . BooleanField (
db_column = " Access Permitted Shops During Extended Hours? "
)
access_front_door_and_studio_space_during_extended_hours = models . BooleanField (
db_column = " Access Front Door and Studio Space During Extended Hours? "
)
access_wood_shop = models . BooleanField ( db_column = " Access Wood Shop? " )
access_metal_shop = models . BooleanField ( db_column = " Access Metal Shop? " )
access_storage_closet = models . BooleanField ( db_column = " Access Storage Closet? " )
access_studio_space = models . BooleanField ( db_column = " Access Studio Space? " )
access_front_door = models . BooleanField ( db_column = " Access Front Door? " )
2022-02-27 18:45:06 -05:00
access_card_number = models . TextField (
db_column = " Access Card Number " , null = True , blank = True
)
2022-02-10 16:51:32 -05:00
access_card_facility_code = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Access Card Facility Code " , null = True , blank = True
)
auto_billing_id = models . TextField (
db_column = " Auto Billing ID " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
2022-02-27 18:45:06 -05:00
billing_method = models . TextField ( db_column = " Billing Method " , null = True , blank = True )
renewal_date = models . DateField ( db_column = " Renewal Date " , null = True , blank = True )
join_date = models . DateField ( db_column = " Join Date " , null = True , blank = True )
admin_note = models . TextField ( db_column = " Admin note " , null = True , blank = True )
2022-02-10 16:51:32 -05:00
profile_gallery_image_url = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Profile gallery image URL " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
business_card_image_url = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Business card image URL " , null = True , blank = True
)
instagram = models . TextField ( db_column = " Instagram " , null = True , blank = True )
pinterest = models . TextField ( db_column = " Pinterest " , null = True , blank = True )
youtube = models . TextField ( db_column = " Youtube " , null = True , blank = True )
yelp = models . TextField ( db_column = " Yelp " , null = True , blank = True )
google = models . TextField ( db_column = " Google+ " , null = True , blank = True )
bbb = models . TextField ( db_column = " BBB " , null = True , blank = True )
twitter = models . TextField ( db_column = " Twitter " , null = True , blank = True )
facebook = models . TextField ( db_column = " Facebook " , null = True , blank = True )
linked_in = models . TextField ( db_column = " LinkedIn " , null = True , blank = True )
2022-02-10 16:51:32 -05:00
do_not_show_street_address_in_profile = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Do not show street address in profile " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
do_not_list_in_directory = models . TextField (
2022-02-27 18:45:06 -05:00
db_column = " Do not list in directory " , null = True , blank = True
)
how_did_you_hear = models . TextField (
db_column = " HowDidYouHear " , null = True , blank = True
)
authorize_charge = models . TextField (
db_column = " authorizeCharge " , null = True , blank = True
)
policy_agreement = models . TextField (
db_column = " policyAgreement " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
waiver_form_signed_and_on_file_date = models . DateField (
2022-02-27 18:45:06 -05:00
db_column = " Waiver form signed and on file date. " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
membership_agreement_signed_and_on_file_date = models . DateField (
2022-02-27 18:45:06 -05:00
db_column = " Membership Agreement signed and on file date. " , null = True , blank = True
)
ip_address = models . TextField ( db_column = " IP Address " , null = True , blank = True )
audit_date = models . DateField ( db_column = " Audit Date " , null = True , blank = True )
agreement_version = models . TextField (
db_column = " Agreement Version " , null = True , blank = True
)
paperwork_status = models . TextField (
db_column = " Paperwork status " , null = True , blank = True
2022-02-10 16:51:32 -05:00
)
2022-02-11 13:48:47 -05:00
membership_agreement_dated = models . BooleanField (
db_column = " Membership agreement dated "
)
2022-02-10 16:51:32 -05:00
membership_agreement_acknowledgement_page_filled_out = models . BooleanField (
db_column = " Membership Agreement Acknowledgement Page Filled Out "
)
membership_agreement_signed = models . BooleanField (
db_column = " Membership Agreement Signed "
)
2022-02-11 13:48:47 -05:00
liability_form_filled_out = models . BooleanField (
db_column = " Liability Form Filled Out "
)
flags = models . ManyToManyField ( Flag , through = " MemberFlag " , related_name = " members " )
2022-02-03 13:45:58 -05:00
2023-12-30 13:00:45 -05:00
_api_names_override = {
2022-03-02 17:17:30 -05:00
" uid " : " Account ID " ,
" how_did_you_hear " : " Please tell us how you heard about the Claremont MakerSpace and what tools or shops you are most excited to start using: " ,
" authorize_charge " : " Yes - I authorize TwinState MakerSpaces, Inc. to charge my credit card for the membership and other options that I have selected. " ,
" policy_agreement " : " I have read the Claremont MakerSpace Membership Agreement & Policies, and agree to all terms stated therein. " ,
2022-02-28 16:06:10 -05:00
}
_date_fields = {
" Join Date " : " % b %d , % Y " ,
" Renewal Date " : " % b %d , % Y " ,
" Audit Date " : " % m/ %d / % Y " ,
" Membership Agreement signed and on file date. " : " % m/ %d / % Y " ,
" Waiver form signed and on file date. " : " % m/ %d / % Y " ,
}
2024-06-05 13:18:25 -04:00
objects = MemberQuerySet . as_manager ( )
2022-02-03 13:45:58 -05:00
class Meta :
2022-02-11 13:48:47 -05:00
db_table = " members "
ordering = ( " first_name " , " last_name " )
2023-03-31 23:54:16 -04:00
indexes = [
2023-12-20 00:54:31 -05:00
models . Index ( fields = [ " account_name " ] , name = " account_name_idx " ) ,
models . Index ( fields = [ " first_name " ] , name = " first_name_idx " ) ,
models . Index ( fields = [ " last_name " ] , name = " last_name_idx " ) ,
2023-03-31 23:54:16 -04:00
]
2022-02-10 16:51:32 -05:00
2024-06-05 13:18:25 -04:00
def __str__ ( self ) :
return f " { self . account_name } "
2023-04-25 23:23:27 -04:00
@classmethod
2024-05-04 16:38:51 -04:00
def from_user ( cls , user ) - > " Member | None " :
2023-04-25 23:23:27 -04:00
if hasattr ( user , " ldap_user " ) :
return cls . objects . get ( uid = user . ldap_user . attrs [ " employeeNumber " ] [ 0 ] )
2024-05-04 16:38:51 -04:00
return None
2023-04-25 23:23:27 -04:00
2024-02-07 13:40:04 -05:00
def sanitized_mailbox ( self , use_volunteer = False ) - > str :
2023-02-02 21:35:27 -05:00
if use_volunteer and self . volunteer_email :
email = self . volunteer_email
2023-02-02 22:33:13 -05:00
elif self . email :
2023-02-02 21:35:27 -05:00
email = self . email
2023-02-02 22:33:13 -05:00
else :
raise Exception ( f " No Email Address for user: { self . uid } " )
if not self . account_name :
return email
2023-01-17 16:26:53 -05:00
return django . core . mail . message . sanitize_address (
2024-02-07 13:40:04 -05:00
( self . account_name , email ) , settings . DEFAULT_CHARSET
2023-01-17 16:26:53 -05:00
)
2022-02-10 16:51:32 -05:00
2022-02-28 16:06:10 -05:00
class MemberFlag ( BaseModel ) :
2023-12-26 12:47:06 -05:00
member = models . ForeignKey (
Member , on_delete = models . PROTECT , db_column = " uid " , db_constraint = False
)
2022-02-10 16:51:32 -05:00
flag = models . ForeignKey ( Flag , on_delete = models . PROTECT )
class Meta :
2022-02-11 13:48:47 -05:00
db_table = " memberflag "
2022-02-10 16:51:32 -05:00
constraints = [
2022-02-11 13:48:47 -05:00
models . UniqueConstraint (
2022-02-28 17:09:48 -05:00
fields = [ " member " , " flag " ] , name = " unique_member_flag "
2022-02-11 13:48:47 -05:00
)
2022-02-10 16:51:32 -05:00
]
2023-12-19 23:42:46 -05:00
2024-06-05 13:18:25 -04:00
def __str__ ( self ) :
return f " { self . member } - { self . flag } "
2023-12-19 23:42:46 -05:00
2023-12-19 23:43:49 -05:00
class Transaction ( BaseModel ) :
2022-03-03 14:03:40 -05:00
sid = models . CharField ( max_length = 256 , null = True , blank = True )
2023-12-19 23:42:46 -05:00
member = models . ForeignKey (
Member ,
on_delete = models . PROTECT ,
db_column = " uid " ,
2023-12-26 12:47:06 -05:00
db_constraint = False ,
2023-12-19 23:42:46 -05:00
related_name = " transactions " ,
null = True ,
blank = True ,
)
timestamp = models . DateTimeField ( )
type = models . TextField ( null = True , blank = True )
sum = models . DecimalField ( max_digits = 13 , decimal_places = 4 , null = True , blank = True )
fee = models . DecimalField ( max_digits = 13 , decimal_places = 4 , null = True , blank = True )
event_id = models . TextField ( null = True , blank = True )
for_what = models . TextField ( db_column = " For " , null = True , blank = True )
items = models . TextField ( db_column = " Items " , null = True , blank = True )
discount_code = models . TextField ( db_column = " Discount Code " , null = True , blank = True )
note = models . TextField ( db_column = " Note " , null = True , blank = True )
name = models . TextField ( db_column = " Name " , null = True , blank = True )
contact_person = models . TextField ( db_column = " Contact Person " , null = True , blank = True )
full_address = models . TextField ( db_column = " Full Address " , null = True , blank = True )
street = models . TextField ( db_column = " Street " , null = True , blank = True )
city = models . TextField ( db_column = " City " , null = True , blank = True )
state_province = models . TextField ( db_column = " State/Province " , null = True , blank = True )
postal_code = models . TextField ( db_column = " Postal Code " , null = True , blank = True )
country = models . TextField ( db_column = " Country " , null = True , blank = True )
phone = models . TextField ( db_column = " Phone " , null = True , blank = True )
email = models . TextField ( db_column = " Email " , null = True , blank = True )
2023-12-30 13:00:45 -05:00
_allowed_missing_fields = [
" sid " ,
" uid " ,
" eid " ,
" fee " ,
" sum " ,
]
_api_names_override = {
2022-03-24 19:45:13 -04:00
" event_id " : " eid " ,
2022-03-02 17:17:30 -05:00
" timestamp " : " _dp " ,
" type " : " Transaction Type " ,
2024-01-05 14:58:37 -05:00
" for_what " : " Reference " ,
2023-12-19 23:43:49 -05:00
}
2023-12-30 13:00:45 -05:00
_date_fields = {
" _dp " : None ,
}
2023-12-19 23:43:49 -05:00
2023-12-19 23:42:46 -05:00
class Meta :
db_table = " transactions "
2023-12-30 14:34:55 -05:00
2024-06-05 13:18:25 -04:00
def __str__ ( self ) :
return f " { self . type } [ { self . member if self . member else self . name } ] { self . timestamp } "
2023-12-30 14:34:55 -05:00
class EventCategory ( models . Model ) :
id = models . IntegerField ( primary_key = True )
title = models . TextField ( )
2024-06-05 13:18:25 -04:00
def __str__ ( self ) :
return self . title
2023-12-30 14:34:55 -05:00
@classmethod
2024-08-07 13:46:39 -04:00
def from_api_dict ( cls , id_ : int , data ) :
return cls ( id = id_ , title = data [ " ttl " ] )
2023-12-30 14:34:55 -05:00
class Event ( BaseModel ) :
class EventCalendar ( models . IntegerChoices ) :
HIDDEN = 0
GREEN = 1
RED = 2
YELLOW = 3
BLUE = 4
PURPLE = 5
MAGENTA = 6
GREY = 7
TEAL = 8
eid = models . CharField ( max_length = 255 , primary_key = True )
url = models . TextField ( )
title = models . TextField ( )
start = models . DateTimeField ( )
end = models . DateTimeField ( null = True , blank = True )
cap = models . IntegerField ( null = True , blank = True )
count = models . IntegerField ( )
category = models . ForeignKey ( EventCategory , on_delete = models . PROTECT )
calendar = models . IntegerField ( choices = EventCalendar )
venue = models . TextField ( null = True , blank = True )
2024-01-19 15:22:05 -05:00
occurred = models . GeneratedField (
expression = ~ ( Q ( cap = 0 ) | Q ( count = 0 ) | Q ( calendar = EventCalendar . HIDDEN ) ) ,
output_field = models . BooleanField ( ) ,
db_persist = False ,
)
2023-12-30 14:34:55 -05:00
# TODO:
# "lgo": {
# "l": "https://d1tif55lvfk8gc.cloudfront.net/656e3842ae3975908b05e304.jpg?1673405126",
# "s": "https://d1tif55lvfk8gc.cloudfront.net/656e3842ae3975908b05e304s.jpg?1673405126"
# },
_api_names_override = {
" title " : " ttl " ,
" category_id " : " grp " ,
" start " : " sdp " ,
" end " : " edp " ,
" count " : " cnt " ,
" calendar " : " cal " ,
" venue " : " adn " ,
}
_date_fields = {
" sdp " : None ,
" edp " : None ,
}
_allowed_missing_fields = [ " cap " , " edp " , " adn " ]
2024-06-05 13:18:25 -04:00
def __str__ ( self ) :
return self . unescaped_title
2024-04-04 00:23:06 -04:00
@property
def unescaped_title ( self ) :
return nh3 . clean ( self . title , tags = set ( ) )
2023-12-30 14:34:55 -05:00
class EventInstructor ( models . Model ) :
name = models . TextField ( blank = True )
member = models . OneToOneField (
Member , on_delete = models . PROTECT , null = True , blank = True , db_constraint = False
)
def __str__ ( self ) :
return str ( self . member ) if self . member else self . name
2024-05-04 16:38:51 -04:00
class EventExtQuerySet ( models . QuerySet [ " EventExtAnnotated " ] ) :
2024-01-19 15:33:54 -05:00
def summarize ( self , aggregate : bool = False ) :
method = self . aggregate if aggregate else self . annotate
return method (
count__sum = Sum ( " count " , filter = F ( " occurred " ) ) ,
instructor__count = Count ( " instructor " , distinct = True , filter = F ( " occurred " ) ) ,
2024-01-29 14:04:35 -05:00
meetings__sum = Sum ( " meetings " , filter = F ( " occurred " ) ) ,
2024-01-19 15:33:54 -05:00
duration__sum = Sum ( " duration " , filter = F ( " occurred " ) ) ,
person_hours__sum = Sum ( " person_hours " , filter = F ( " occurred " ) ) ,
event_count = Count ( " eid " , filter = F ( " occurred " ) ) ,
canceled_event_count = Count ( " eid " , filter = ~ F ( " occurred " ) ) ,
2024-01-31 20:18:46 -05:00
gross_revenue__sum = Sum ( " gross_revenue " , filter = F ( " occurred " ) ) ,
total_due_to_instructor__sum = Sum (
" total_due_to_instructor " , filter = F ( " occurred " )
) ,
net_revenue__sum = Sum ( " net_revenue " , filter = F ( " occurred " ) ) ,
)
2024-05-04 16:38:51 -04:00
def with_financials ( self ) - > " QuerySet[EventExtAnnotatedWithFinancials] " :
2024-01-31 20:18:46 -05:00
return self . annotate (
* * {
field : Subquery (
EventTicketType . objects . filter ( event = OuterRef ( " pk " ) )
. values ( " event__pk " )
. annotate ( d = Sum ( field ) )
. values ( " d " ) ,
output_field = models . DecimalField ( ) ,
)
for field in [
" quantity " ,
" amount " ,
" materials " ,
" amount_without_materials " ,
2024-03-04 15:35:37 -05:00
" instructor_revenue " ,
2024-01-31 20:18:46 -05:00
" instructor_amount " ,
]
} ,
total_due_to_instructor = (
F ( " instructor_amount " ) + F ( " instructor_flat_rate " )
) ,
gross_revenue = Coalesce ( F ( " attendee_stats__gross_revenue " ) , 0.0 ) ,
net_revenue = ExpressionWrapper (
F ( " gross_revenue " ) - F ( " total_due_to_instructor " ) ,
output_field = models . DecimalField ( ) ,
) ,
2024-01-19 15:33:54 -05:00
)
2024-05-04 16:38:51 -04:00
class EventExtManager ( models . Manager ) :
def get_queryset ( self ) - > EventExtQuerySet :
return EventExtQuerySet ( self . model , using = self . _db ) . annotate (
meetings = Subquery (
EventMeetingTime . objects . filter ( event = OuterRef ( " pk " ) )
. values ( " event__pk " )
. annotate ( d = Count ( " pk " ) )
. values ( " d " ) [ : 1 ] ,
output_field = models . IntegerField ( ) ,
) ,
2024-07-18 12:58:35 -04:00
next_meeting_start = Subquery (
EventMeetingTime . objects . filter (
event = OuterRef ( " pk " ) , end__gt = timezone . now ( )
)
. order_by ( " start " )
. values ( " start " ) [ : 1 ]
) ,
2024-05-04 16:38:51 -04:00
duration = Subquery (
EventMeetingTime . objects . filter ( event = OuterRef ( " pk " ) )
. values ( " event__pk " )
. annotate ( d = Sum ( " duration " ) )
. values ( " d " ) [ : 1 ] ,
output_field = models . DurationField ( ) ,
) ,
person_hours = ExpressionWrapper (
ExpressionWrapper ( F ( " duration " ) , models . IntegerField ( ) ) * F ( " count " ) ,
models . DurationField ( ) ,
) ,
2024-01-17 13:25:37 -05:00
)
2023-12-30 14:34:55 -05:00
class EventExt ( Event ) :
""" Extension of `Event` to capture some fields not supported in MembershipWorks """
instructor = models . ForeignKey (
EventInstructor , on_delete = models . PROTECT , null = True , blank = True
)
materials_fee = models . DecimalField (
max_digits = 13 , decimal_places = 4 , null = True , blank = True
)
2024-01-24 22:12:14 -05:00
materials_fee_included_in_price = models . BooleanField ( null = True )
2024-01-01 21:15:03 -05:00
instructor_percentage = models . DecimalField (
max_digits = 5 , decimal_places = 4 , default = 0.5
)
instructor_flat_rate = models . DecimalField (
max_digits = 13 , decimal_places = 4 , default = 0
)
2024-01-29 21:48:19 -05:00
details = models . JSONField ( null = True , blank = True )
2024-05-08 12:45:34 -04:00
details_timestamp = models . GeneratedField (
expression = Func (
Func ( F ( " details___ts " ) , function = " FROM_UNIXTIME " ) ,
template = " CONVERT_TZ( %(expressions)s , @@session.time_zone, ' UTC ' ) " ,
) ,
output_field = models . DateTimeField ( ) ,
db_persist = False ,
verbose_name = " Last details fetch " ,
)
2024-04-30 14:34:45 -04:00
registrations = models . JSONField ( null = True , blank = True )
2023-12-30 14:34:55 -05:00
2024-05-23 19:20:01 -04:00
should_survey = models . BooleanField ( default = False )
survey_email_sent = models . BooleanField ( default = False )
2024-06-05 13:18:25 -04:00
objects = EventExtManager . from_queryset ( EventExtQuerySet ) ( )
class Meta :
verbose_name = " event "
ordering = [ " -start " ]
2024-04-14 01:21:32 -04:00
def get_absolute_url ( self ) - > str :
return reverse ( " membershipworks:event-detail " , kwargs = { " eid " : self . eid } )
def user_is_instructor ( self , user : AbstractBaseUser ) - > bool :
2024-05-01 14:35:12 -04:00
if self . instructor is None :
return False
2024-04-14 01:21:32 -04:00
member = Member . from_user ( user )
if member is not None :
return self . instructor . member == member
return False
2024-05-03 12:37:01 -04:00
@property
2024-05-02 23:53:48 -04:00
def ready_for_invoice ( self ) - > bool :
return (
self . instructor is not None
and self . instructor . member is not None
and self . materials_fee is not None
and (
self . materials_fee_included_in_price is not None
or self . materials_fee == 0
)
2024-08-07 14:29:52 -04:00
and self . total_due_to_instructor is not None
2024-05-02 23:53:48 -04:00
)
2023-12-30 14:34:55 -05:00
2024-05-04 16:38:51 -04:00
if TYPE_CHECKING :
2024-08-07 14:09:42 -04:00
from decimal import Decimal
2024-05-04 16:38:51 -04:00
class EventExtAnnotations ( TypedDict ) :
meetings : int
duration : timedelta
person_hours : timedelta
details_timestamp : datetime
class EventExtFinancialAnnotations ( TypedDict ) :
quantity : Decimal
amount : Decimal
materials : Decimal
amount_without_materials : Decimal
instructor_revenue : Decimal
instructor_amount : Decimal
total_due_to_instructor : Decimal
gross_revenue : Decimal
net_revenue : Decimal
EventExtAnnotated = WithAnnotations [ EventExt , EventExtAnnotations ]
EventExtAnnotatedWithFinancials = WithAnnotations [ EventExt , EventExtAnnotations ]
else :
EventExtAnnotated = WithAnnotations [ EventExt ]
EventExtAnnotatedWithFinancials = WithAnnotations [ EventExt ]
2024-08-05 22:48:36 -04:00
class EventMeetingTime ( Reservation ) :
2023-12-30 14:34:55 -05:00
event = models . ForeignKey (
EventExt , on_delete = models . CASCADE , related_name = " meeting_times "
)
2024-08-07 12:27:32 -04:00
def get_title ( self ) - > str :
return self . event . unescaped_title
2024-08-05 22:48:36 -04:00
# TODO: should probably do some validation in python to enforce
# - uniqueness and non-overlapping (per event)
# - min/max start/end time == event start end
2023-12-30 14:34:55 -05:00
2024-08-05 22:48:36 -04:00
def make_google_calendar_event ( self ) :
status = (
" confirmed "
if self . event . cap > 0 and self . event . calendar != Event . EventCalendar . HIDDEN
else " cancelled "
)
2024-01-31 20:18:46 -05:00
2024-08-05 22:48:36 -04:00
return super ( ) . make_google_calendar_event ( ) | {
# TODO: add event description and links
" summary " : self . event . unescaped_title ,
" status " : status ,
}
2024-06-05 13:09:03 -04:00
2024-01-31 20:18:46 -05:00
2024-04-05 14:10:08 -04:00
class EventInvoice ( models . Model ) :
uuid = models . UUIDField ( primary_key = True , default = uuid . uuid4 , editable = False )
event = models . OneToOneField (
EventExt , on_delete = models . PROTECT , related_name = " invoice "
)
date_submitted = models . DateField ( )
date_paid = models . DateField ( blank = True , null = True )
pdf = models . FileField ( upload_to = " protected/invoices/ % Y/ % m/ %d / " )
amount = models . DecimalField ( max_digits = 13 , decimal_places = 4 )
def __str__ ( self ) - > str :
return f ' " { self . event } " submitted= { self . date_submitted } paid: { self . date_paid } $ { self . amount } '
2024-03-08 15:28:25 -05:00
class EventTicketTypeQuerySet ( models . QuerySet [ " EventTicketType " ] ) :
def group_by_ticket_type ( self ) :
return self . values ( " is_members_ticket " ) . annotate (
label = Case (
When ( Q ( is_members_ticket = True ) , Value ( " Members " ) ) ,
default = Value ( " Non-Members " ) ,
) ,
actual_price = F ( " actual_price " ) ,
* * {
field : Sum ( field )
for field in [
" quantity " ,
" materials " ,
" amount " ,
" amount_without_materials " ,
" instructor_revenue " ,
" instructor_amount " ,
]
} ,
)
2024-01-31 20:18:46 -05:00
class EventTicketTypeManager ( models . Manager [ " EventTicketType " ] ) :
def get_queryset ( self ) - > models . QuerySet [ " EventTicketType " ] :
members_folder = Subquery (
Flag . objects . filter ( name = " Members " , type = " folder " ) . values ( " id " ) [ : 1 ]
)
qs = super ( ) . get_queryset ( )
return qs . annotate (
2024-07-23 12:49:44 -04:00
members_price = Subquery (
qs . filter ( event = OuterRef ( " event " ) , restrict_to = members_folder ) . values (
" list_price "
) ,
output_field = models . FloatField ( ) ,
) ,
2024-06-27 15:54:13 -04:00
# Before 2024-07-01, use Members ticket price for any
# restricted ticket, but list price for unrestricted
# (Non-Members) ticket. After, use Members ticket price
2024-07-23 12:49:44 -04:00
# for all tickets except where members ticket is free.
2024-01-31 20:18:46 -05:00
actual_price = Case (
When (
2024-07-23 12:49:44 -04:00
# member ticket
2024-06-27 15:54:13 -04:00
Q ( restrict_to = members_folder )
| (
2024-07-23 12:49:44 -04:00
# non-member ticket
Q ( restrict_to__isnull = True )
& (
2024-07-30 17:40:44 -04:00
Q (
event__start__lt = datetime (
year = 2024 ,
month = 7 ,
day = 1 ,
tzinfo = timezone . get_default_timezone ( ) ,
)
)
2024-07-23 12:49:44 -04:00
| Q ( members_price = 0 )
)
2024-06-27 15:54:13 -04:00
) ,
2024-01-31 20:18:46 -05:00
" list_price " ,
) ,
2024-07-23 12:49:44 -04:00
default = " members_price " ,
2024-01-31 20:18:46 -05:00
) ,
is_members_ticket = ( Q ( restrict_to__isnull = False ) ) ,
materials = Case (
When (
(
Q ( event__materials_fee_included_in_price = True )
2024-02-03 23:33:19 -05:00
| Q ( event__materials_fee = 0 )
2024-01-31 20:18:46 -05:00
& Q ( event__materials_fee__isnull = False )
) ,
ExpressionWrapper (
F ( " event__materials_fee " ) * F ( " quantity " ) ,
output_field = models . DecimalField ( ) ,
) ,
) ,
2024-02-03 23:33:19 -05:00
When ( Q ( event__materials_fee_included_in_price__isnull = True ) , None ) ,
2024-01-31 20:18:46 -05:00
default = 0 ,
output_field = models . DecimalField ( ) ,
) ,
amount = ExpressionWrapper (
F ( " actual_price " ) * F ( " quantity " ) ,
output_field = models . DecimalField ( ) ,
) ,
amount_without_materials = ExpressionWrapper (
F ( " amount " ) - F ( " materials " ) , output_field = models . DecimalField ( )
) ,
2024-03-04 15:35:37 -05:00
instructor_revenue = ExpressionWrapper (
2024-01-31 20:18:46 -05:00
F ( " amount_without_materials " ) * F ( " event__instructor_percentage " ) ,
output_field = models . DecimalField ( ) ,
) ,
instructor_amount = ExpressionWrapper (
2024-03-04 15:35:37 -05:00
F ( " instructor_revenue " ) + F ( " materials " ) ,
output_field = models . DecimalField ( ) ,
2024-01-31 20:18:46 -05:00
) ,
)
class EventTicketType ( DBView ) :
2024-03-08 15:28:25 -05:00
objects = EventTicketTypeManager . from_queryset ( EventTicketTypeQuerySet ) ( )
2024-01-31 20:18:46 -05:00
event = models . ForeignKey (
2024-08-10 09:57:52 -04:00
EventExt , on_delete = models . DO_NOTHING , related_name = " ticket_types "
2024-01-31 20:18:46 -05:00
)
label = models . TextField ( )
restrict_to = models . TextField ( null = True , blank = True )
list_price = models . FloatField ( )
quantity = models . IntegerField ( )
# Due to the presence of JSON_TABLE, this view must (as of MariaDB
# 11.2.2) be created as the root user. See
# https://jira.mariadb.org/browse/MDEV-27898
# nested path/group_concat to workaround inability to create JSON columns using
# JSON_TABLE in views
view_definition = """
SELECT
row_number ( ) over ( ) as id ,
eventext . event_ptr_id AS event_id ,
tkt . label ,
tkt . list_price ,
tkt . quantity ,
GROUP_CONCAT ( tkt . restrict_to SEPARATOR ' , ' ) as restrict_to
FROM
membershipworks_eventext AS eventext ,
JSON_TABLE ( eventext . details , ' $.tkt[*] ' COLUMNS (
id FOR ORDINALITY ,
label VARCHAR ( 256 ) PATH ' $.lbl ' ERROR ON ERROR ,
list_price DOUBLE PATH ' $.amt ' ERROR ON ERROR ,
quantity INTEGER PATH ' $.cnt ' DEFAULT 0 ON EMPTY ERROR ON ERROR ,
NESTED PATH ' $.dsp[*] ' COLUMNS (
restrict_to VARCHAR ( 100 ) PATH ' $ ' ERROR ON ERROR
)
) ) AS tkt
GROUP BY event_id , id
"""
def __str__ ( self ) - > str :
return f " { self . label } : { self . quantity } * { self . list_price } "
class Meta :
managed = False
base_manager_name = " objects "
class EventAttendeeStats ( DBView ) :
event = models . ForeignKey (
2024-08-10 09:57:52 -04:00
EventExt , on_delete = models . DO_NOTHING , related_name = " attendee_stats "
2024-01-31 20:18:46 -05:00
)
gross_revenue = models . FloatField ( )
view_definition = """
SELECT eventext . event_ptr_id as event_id , SUM ( tkt . s ) as gross_revenue
FROM
membershipworks_eventext as eventext ,
JSON_TABLE ( eventext . details , ' $.usr[*] ' COLUMNS (
s DOUBLE PATH ' $.sum ' DEFAULT 0 ON EMPTY
) ) as tkt
GROUP BY event_id
"""
class Meta :
managed = False
2024-02-02 19:26:06 -05:00
class EventAttendee ( DBView ) :
event = models . ForeignKey (
2024-08-10 09:57:52 -04:00
EventExt , on_delete = models . DO_NOTHING , related_name = " attendees "
2024-02-02 19:26:06 -05:00
)
uid = models . ForeignKey ( Member , on_delete = models . DO_NOTHING )
name = models . CharField ( max_length = 256 )
email = models . CharField ( max_length = 256 )
sum = models . FloatField ( )
view_definition = """
SELECT eventext . event_ptr_id as event_id , tkt . uid , tkt . name , tkt . email , tkt . sum
FROM
membershipworks_eventext as eventext ,
JSON_TABLE ( eventext . details , ' $.usr[*] ' COLUMNS (
uid VARCHAR ( 24 ) PATH ' $.uid ' ,
name VARCHAR ( 256 ) PATH ' $.nam ' ,
email VARCHAR ( 256 ) PATH ' $.eml ' ,
sum DOUBLE PATH ' $.sum '
) ) as tkt
"""
class Meta :
managed = False