import datetime
from decimal import Decimal
import bleach
import pytz
from django.contrib.humanize.templatetags.humanize import ordinal
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import localdate, localtime
from accounts.models import User
from cobalt.settings import (
TIME_ZONE,
BRIDGE_CREDITS,
BLEACH_ALLOWED_TAGS,
BLEACH_ALLOWED_ATTRIBUTES,
BLEACH_ALLOWED_STYLES,
TBA_PLAYER,
)
from organisations.models import Organisation
from organisations.club_admin_core import is_player_a_member
from payments.models import MemberTransaction
from rbac.core import rbac_user_has_role
from utils.templatetags.cobalt_tags import cobalt_credits
from utils.utils import cobalt_round
PAYMENT_STATUSES = [
("Paid", "Entry Paid"),
("Pending Manual", "Pending Manual Payment"),
("Unpaid", "Entry Unpaid"),
("Free", "Free"),
]
# my-system-dollars - you can pay for your own or other people's entries with
# your money.
# their-system-dollars - you can use a team mates money to pay for their
# entry if you have permission
# other-system-dollars - we're not paying and we're not using their account
# to pay
PAYMENT_TYPES = [
(
"my-system-dollars",
BRIDGE_CREDITS,
),
("their-system-dollars", f"Their {BRIDGE_CREDITS}"),
("other-system-dollars", "TBA"),
("bank-transfer", "Bank Transfer"),
("off-system-pp", "Club PP System"),
("cash", "Cash"),
("cheque", "Cheque"),
("unknown", "Unknown"),
("Free", "Free"),
]
CONGRESS_STATUSES = [
("Draft", "Draft"),
("Published", "Published"),
("Closed", "Closed"),
]
EVENT_TYPES = [
("Open", "Open"),
("Restricted", "Restricted"),
("Novice", "Novice"),
("Senior", "Senior"),
("Youth", "Youth"),
("Rookies", "Rookies"),
("Veterans", "Veterans"),
("Womens", "Womens"),
("Intermediate", "Intermediate"),
("Mixed", "Mixed"),
]
EVENT_PLAYER_FORMAT = [
("Individual", "Individual"),
("Pairs", "Pairs"),
("Teams of 3", "Teams of Three"),
("Teams", "Teams"),
]
EVENT_PLAYER_FORMAT_SIZE = {
"Individual": 1,
"Pairs": 2,
"Teams of 3": 3,
"Teams": 6,
}
CONGRESS_TYPES = [
("national_gold", "National gold point"),
("state_championship", "State championship"),
("state_congress", "State congress"),
("state_event", "State event"),
("club_congress", "Club congress"),
("club", "Club event"),
("lesson", "Lesson"),
("other", "Other"),
]
PEOPLE_DEFAULT = """<table class="table"><tbody><tr><td><span style="font-weight: normal;">
Organiser:</span></td><td><span style="font-weight: normal;">Jane Doe</span></td>
</tr><tr><td><span style="font-weight: normal;">Phone:</span></td><td>
<span style="font-weight: normal;">040404040444</span></td></tr><tr><td>
<span style="font-weight: normal;">Email:</span></td><td><span style="font-weight: normal;">
me@club.com</span></td></tr><tr><td><span style="font-weight: normal;">
Chief Tournament Director:</span></td><td><span style="font-weight: normal;">
Alan Partridge</span></td></tr></tbody></table><p><br></p>"""
[docs]
class CongressMaster(models.Model):
"""Master List of congresses. E.g. GCC. This is not an instance
of a congress, just a list of the regular recurring ones.
Congresses can only belong to one club at a time. Control for
who can setup a congress as an instance of a congress master
is handled by who is a convener for a club"""
name = models.CharField("Congress Master Name", max_length=100)
org = models.ForeignKey(Organisation, on_delete=models.CASCADE)
def __str__(self):
return self.name
[docs]
class Congress(models.Model):
"""A specific congress including year
We set all values to be optional so we can use the wizard format and
save partial data as we go. The validation for completeness of data
lies in the view."""
[docs]
class CongressVenueType(models.TextChoices):
"""Face to Face, Online"""
FACE_TO_FACE = "F", "Face-to-Face"
ONLINE = "O"
MIXED = "M"
UNKNOWN = "U"
name = models.CharField("Name", max_length=100)
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True)
date_string = models.CharField("Dates", max_length=100, null=True, blank=True)
congress_master = models.ForeignKey(
CongressMaster, on_delete=models.CASCADE, null=True, blank=True
)
year = models.IntegerField("Congress Year", null=True, blank=True)
venue_name = models.CharField("Venue Name", max_length=100, null=True, blank=True)
venue_location = models.CharField(
"Venue Location", max_length=100, null=True, blank=True
)
venue_transport = models.TextField("Venue Transport", null=True, blank=True)
venue_catering = models.TextField("Venue Catering", null=True, blank=True)
venue_additional_info = models.TextField(
"Venue Additional Information", null=True, blank=True
)
sponsors = models.TextField("Sponsors", null=True, blank=True)
additional_info = models.TextField(
"Congress Additional Information", null=True, blank=True
)
raw_html = models.TextField("Raw HTML", null=True, blank=True)
people = models.TextField("People", null=True, blank=True, default=PEOPLE_DEFAULT)
general_info = models.TextField("General Information", null=True, blank=True)
links = models.TextField("Links", null=True, blank=True)
latest_news = models.TextField("Latest News", null=True, blank=True)
payment_method_system_dollars = models.BooleanField(default=True)
payment_method_bank_transfer = models.BooleanField(default=False)
bank_transfer_details = models.TextField(
"Bank Transfer Details", null=True, blank=True
)
payment_method_cash = models.BooleanField(default=False)
payment_method_cheques = models.BooleanField(default=False)
payment_method_off_system_pp = models.BooleanField(default=False)
cheque_details = models.TextField("Cheque Details", null=True, blank=True)
allow_early_payment_discount = models.BooleanField(default=False)
early_payment_discount_date = models.DateField(
"Last day for early discount", null=True, blank=True
)
allow_youth_payment_discount = models.BooleanField(default=False)
youth_payment_discount_date = models.DateField(
"Date for age check", null=True, blank=True
)
youth_payment_discount_age = models.IntegerField("Cut off age", default=26)
senior_date = models.DateField("Date for age check", null=True, blank=True)
senior_age = models.IntegerField("Cut off age", default=60)
members_only = models.BooleanField("Members Only", default=False)
allow_member_entry_fee = models.BooleanField(
"Allow Member Specific Entry Fee", default=False
)
# Open and close dates can be overridden at the event level
entry_open_date = models.DateField(null=True, blank=True)
entry_close_date = models.DateField(null=True, blank=True)
automatic_refund_cutoff = models.DateField(null=True, blank=True)
allow_partnership_desk = models.BooleanField(default=False)
author = models.ForeignKey(
User, on_delete=models.PROTECT, related_name="author", null=True, blank=True
)
created_date = models.DateTimeField(default=timezone.now)
last_updated_by = models.ForeignKey(
User,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="last_updated_by",
)
last_updated = models.DateTimeField(default=timezone.now)
status = models.CharField(
"Congress Status", max_length=10, choices=CONGRESS_STATUSES, default="Draft"
)
congress_type = models.CharField(
"Congress Type", max_length=30, choices=CONGRESS_TYPES, blank=True, null=True
)
contact_email = models.EmailField(blank=True, null=True)
congress_venue_type = models.CharField(
choices=CongressVenueType.choices,
default=CongressVenueType.UNKNOWN,
max_length=1,
)
online_platform = models.CharField(
choices=OnlinePlatform.choices,
default=OnlinePlatform.UNKNOWN,
max_length=1,
)
# We will automatically close events in a congress by marking entries as paid. This flag prevents it so
# a convener can continue to chase up any missing money
do_not_auto_close_congress = models.BooleanField(default=False)
class Meta:
verbose_name_plural = "Congresses"
def __str__(self):
return self.name
# If the text changes, run it through bleach before saving
[docs]
def save(self, *args, **kwargs):
if self.sponsors and getattr(self, "_sponsors_changed", True):
self.sponsors = bleach.clean(
self.sponsors,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.latest_news and getattr(self, "_latest_news_changed", True):
self.latest_news = bleach.clean(
self.latest_news,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.venue_transport and getattr(self, "_venue_transport_changed", True):
self.venue_transport = bleach.clean(
self.venue_transport,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.venue_catering and getattr(self, "_venue_catering_changed", True):
self.venue_catering = bleach.clean(
self.venue_catering,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.venue_additional_info and getattr(
self, "_venue_additional_info_changed", True
):
self.venue_additional_info = bleach.clean(
self.venue_additional_info,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.raw_html and getattr(self, "_raw_html_changed", True):
self.raw_html = bleach.clean(
self.raw_html,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.general_info and getattr(self, "_general_info_changed", True):
self.general_info = bleach.clean(
self.general_info,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.people and getattr(self, "_people_changed", True):
self.people = bleach.clean(
self.people,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.links and getattr(self, "_links_changed", True):
self.links = bleach.clean(
self.links,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.latest_news and getattr(self, "_latest_news_changed", True):
self.latest_news = bleach.clean(
self.latest_news,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.bank_transfer_details and getattr(
self, "_bank_transfer_details_changed", True
):
self.bank_transfer_details = bleach.clean(
self.bank_transfer_details,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.cheque_details and getattr(self, "_cheque_details_changed", True):
self.cheque_details = bleach.clean(
self.cheque_details,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.name and getattr(self, "_name_changed", True):
self.name = bleach.clean(
self.name,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.date_string and getattr(self, "_date_string_changed", True):
self.date_string = bleach.clean(
self.date_string,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.venue_location and getattr(self, "_venue_location_changed", True):
self.venue_location = bleach.clean(
self.venue_location,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.venue_name and getattr(self, "_venue_name_changed", True):
self.venue_name = bleach.clean(
self.venue_name,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.congress_type and getattr(self, "_congress_type_changed", True):
self.congress_type = bleach.clean(
self.congress_type,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(Congress, self).save(*args, **kwargs)
[docs]
def user_is_convener(self, user):
"""check if a user has convener rights to this congress"""
role = "events.org.%s.edit" % self.congress_master.org.id
return rbac_user_has_role(user, role)
[docs]
def get_payment_methods(self):
"""get a list of payment types for this congress. Excludes other-system-dollars
as this isn't applicable for the logged in user and is easier to add to the
list than remove"""
pay_methods = []
if self.payment_method_system_dollars:
pay_methods.append(("my-system-dollars", f"My {BRIDGE_CREDITS}"))
if self.payment_method_bank_transfer:
pay_methods.append(("bank-transfer", "Bank Transfer"))
if self.payment_method_cash:
pay_methods.append(("cash", "Cash on the day"))
if self.payment_method_cheques:
pay_methods.append(("cheque", "Cheque"))
if self.payment_method_off_system_pp:
pay_methods.append(("off-system-pp", "Club PP System"))
return pay_methods
@property
def href(self):
"""Returns an HTML link tag that can be used to go to the congress admin screen"""
tag = reverse("events:admin_summary", kwargs={"congress_id": self.id})
return f"<a href='{tag}' target='_blank'>{self.name}</a>"
[docs]
class Event(models.Model):
"""An event within a congress"""
congress = models.ForeignKey(Congress, on_delete=models.PROTECT)
event_name = models.CharField("Event Name", max_length=100)
description = models.CharField("Description", max_length=400, null=True, blank=True)
max_entries = models.IntegerField("Maximum Entries", null=True, blank=True)
event_type = models.CharField(
"Event Type", max_length=14, choices=EVENT_TYPES, null=True, blank=True
)
# Open and close dates can be overridden at the event level
entry_open_date = models.DateField(null=True, blank=True)
entry_close_date = models.DateField(null=True, blank=True)
entry_close_time = models.TimeField(null=True, blank=True)
entry_fee = models.DecimalField(
"Entry Fee",
max_digits=12,
decimal_places=2,
null=True,
blank=True,
default=Decimal(0.0),
)
member_entry_fee = models.DecimalField(
"Member Entry Fee",
max_digits=12,
decimal_places=2,
null=True,
blank=True,
default=Decimal(0.0),
)
entry_early_payment_discount = models.DecimalField(
"Early Payment Discount",
max_digits=12,
decimal_places=2,
null=True,
blank=True,
default=Decimal(0.0),
)
entry_youth_payment_discount = models.IntegerField(
"Youth Discount Percentage", default=50
)
player_format = models.CharField(
"Player Format",
max_length=14,
choices=EVENT_PLAYER_FORMAT,
)
free_format_question = models.CharField(
"Free Format Question", max_length=60, null=True, blank=True
)
allow_team_names = models.BooleanField(default=False)
list_priority_order = models.IntegerField(default=0)
# Originally Congresses and Sessions had start and end dates but Events didn't
# We do a lot of queries that want to know when an event starts or ends but because
# start_date() is a property and not a field, we can't use the database for this
# Adding denormalised date/time fields makes things easier. These only change if
# the sessions change which only happens in one place, so not a big deal
# TODO: Add to test data
denormalised_start_date = models.DateField(null=True, blank=True)
denormalised_start_time = models.TimeField(null=True, blank=True)
denormalised_end_date = models.DateField(null=True, blank=True)
denormalised_end_time = models.TimeField(null=True, blank=True)
def __str__(self):
return f"{self.congress} - {self.event_name}"
# If the text changes, run it through bleach before saving
[docs]
def save(self, *args, **kwargs):
if self.event_name and getattr(self, "_event_name_changed", True):
self.event_name = bleach.clean(
self.event_name,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.description and getattr(self, "_description_changed", True):
self.description = bleach.clean(
self.description,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.free_format_question and getattr(
self, "_free_format_question_changed", True
):
self.free_format_question = bleach.clean(
self.free_format_question,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(Event, self).save(*args, **kwargs)
[docs]
def is_open(self):
"""check if this event is taking entries today"""
today = localdate()
time_now = localtime().time()
open_date = self.entry_open_date
if not open_date:
open_date = self.congress.entry_open_date
if open_date and today < open_date:
return False
close_date = self.entry_close_date
if not close_date:
close_date = self.congress.entry_close_date
if close_date:
if today > close_date:
return False
if (
today == close_date
and self.entry_close_time
and self.entry_close_time < time_now
):
return False
# check start date of event
start_date = self.start_date()
if start_date and start_date < today: # event started
return False
elif start_date == today:
start_time = self.start_time()
if start_time and start_time < time_now:
return False
return True
[docs]
def is_open_with_reason(self):
"""check if this event is taking entries today and explain why"""
today = localdate()
time_now = localtime().time()
open_date = self.entry_open_date
if not open_date:
open_date = self.congress.entry_open_date
if open_date and today < open_date:
time_delta = open_date - today
if time_delta.days > 1:
time_delta_msg = f"in {time_delta.days} days"
elif time_delta.days == 1:
time_delta_msg = "tomorrow"
else:
# Shouldn't happen
time_delta_msg = "soon"
return False, f"Entries open {time_delta_msg}"
close_date = self.entry_close_date
if not close_date:
close_date = self.congress.entry_close_date
if close_date:
if today > close_date:
return False, f"Entries closed on {close_date:%A %-d %b %Y}"
if (
today == close_date
and self.entry_close_time
and self.entry_close_time < time_now
):
return False, f"Entries closed at {self.entry_close_time:%H:%M}"
# check start date of event
start_date = self.start_date()
if start_date and start_date < today: # event started
return False, "Event has started"
elif start_date == today:
start_time = self.start_time()
if start_time and start_time < time_now:
return False, "Event has started"
# Check if full
if self.is_full():
return False, "Event is full"
return True, "Open"
[docs]
def entry_fee_for(self, user, check_date=None, actual_team_size=None):
"""return entry fee for user based on age and date. Also any EventPlayerDiscount applied
We accept a check_date to work out what the entry fee would be for that date, if not
provided then we use today."""
if not check_date:
check_date = timezone.now().date()
# default
discount = 0.0
base_fee_reason = None
discount_reasons = []
players_per_entry = EVENT_PLAYER_FORMAT_SIZE[self.player_format]
if self.player_format == "Teams":
players_per_entry = actual_team_size if actual_team_size else 4
# determine base entry fee, considering club membership
if self.congress.members_only:
base_entry_fee = self.member_entry_fee
elif self.congress.allow_member_entry_fee:
if is_player_a_member(
self.congress.congress_master.org, user.system_number
):
base_entry_fee = self.member_entry_fee
base_fee_reason = "Member"
else:
base_entry_fee = self.entry_fee
base_fee_reason = "Non-member"
else:
base_entry_fee = self.entry_fee
entry_fee = cobalt_round(base_entry_fee / players_per_entry)
# date
if (
self.congress.allow_early_payment_discount
and self.congress.early_payment_discount_date
) and self.congress.early_payment_discount_date >= check_date:
early_entry_fee = cobalt_round(
(base_entry_fee - self.entry_early_payment_discount) / players_per_entry
)
# entry_fee = cobalt_round(entry_fee)
# discount = float(base_entry_fee) / players_per_entry - float(entry_fee)
discount = entry_fee - early_entry_fee
entry_fee = early_entry_fee
discount_reasons.append("Early")
# youth discounts apply after early entry discounts
if (
self.congress.allow_youth_payment_discount
and self.congress.youth_payment_discount_date
) and user.dob: # skip if no date of birth set
dob = datetime.datetime.combine(user.dob, datetime.time(0, 0))
dob = timezone.make_aware(dob, pytz.timezone(TIME_ZONE))
# changing the year if date is 29th Feb can cause errors - change to 28th
if dob.month == 2 and dob.day == 29:
dob = dob.replace(day=28)
ref_date = dob.replace(
year=dob.year + self.congress.youth_payment_discount_age
)
if self.congress.youth_payment_discount_date <= ref_date.date():
entry_fee = float(entry_fee) - (
float(entry_fee) * float(self.entry_youth_payment_discount) / 100.0
)
entry_fee = cobalt_round(entry_fee)
discount = float(base_entry_fee) / players_per_entry - entry_fee
discount_reasons.append("Youth")
# Build the reason and description strings
if discount:
if base_fee_reason:
reason = f"{base_fee_reason} {'+'.join(discount_reasons)} discount"
else:
reason = f"{'+'.join(discount_reasons)} discount"
if "Youth" in discount_reasons and len(discount_reasons) == 1:
# just youth discount, so show percentage
description = f"{reason} {self.entry_youth_payment_discount}%"
else:
# show amount of total discount
description = f"{reason} {cobalt_credits(cobalt_round(discount))}"
else:
# No discount, either full fee or member fee
reason = f"{base_fee_reason if base_fee_reason else 'Full'} fee"
description = reason
# EventPlayerDiscount
event_player_discount = (
EventPlayerDiscount.objects.filter(event=self).filter(player=user).first()
)
if event_player_discount:
discount_fee = cobalt_round(event_player_discount.entry_fee)
if discount_fee < entry_fee:
discount = entry_fee - discount_fee
entry_fee = discount_fee
reason = event_player_discount.reason
description = f"Manual override {reason}"
return entry_fee, discount, reason[:40], description[:40]
[docs]
def already_entered(self, user):
"""check if a user has already entered"""
event_entry_list = self.evententry_set.all().values_list("id")
event_entry_player = (
EventEntryPlayer.objects.filter(player=user)
.filter(event_entry__in=event_entry_list)
.exclude(event_entry__entry_status="Cancelled")
.first()
)
if event_entry_player:
return event_entry_player.event_entry
else:
return None
[docs]
def start_time(self):
"""Originally we didn't have a start time and this function calculated it"""
return self.denormalised_start_time
[docs]
def start_date(self):
"""Originally we didn't have a start date and this function calculated it"""
return self.denormalised_start_date
[docs]
def end_date(self):
"""Originally we didn't have an end date and this function calculated it"""
return self.denormalised_end_date
[docs]
def print_dates(self):
"""returns nicely formatted date string for event"""
start = self.start_date()
end = self.end_date()
if not start: # no start will also mean no end
return None
if start == end:
return f'{ordinal(start.strftime("%d"))} {start.strftime("%B %Y")}'
start_day = ordinal(start.strftime("%d"))
start_month = start.strftime("%B")
start_year = start.strftime("%Y")
end_day = ordinal(end.strftime("%d"))
end_month = end.strftime("%B")
end_year = end.strftime("%Y")
if start_year == end_year:
start_year = ""
if start_month == end_month:
start_month = ""
else:
start_month = f" {start_month}"
if start_year != "":
start_year = f" {start_year}"
return (
f"{start_day}{start_month}{start_year} to {end_day} {end_month} {end_year}"
)
[docs]
def entry_status(self, user):
"""returns the status of the team/pairs/individual entry"""
event_entry_player = (
EventEntryPlayer.objects.filter(player=user)
.exclude(event_entry__entry_status="Cancelled")
.filter(event_entry__event=self)
.first()
)
if event_entry_player:
return event_entry_player.event_entry.entry_status
return None
[docs]
def is_full(self):
"""check if event is already full"""
if self.max_entries is None:
return False
entries = (
EventEntry.objects.filter(event=self)
.exclude(entry_status="Cancelled")
.count()
)
return entries >= self.max_entries
@property
def href(self):
"""Returns an HTML link tag that can be used to go to the event log"""
tag = reverse("events:admin_event_log", kwargs={"event_id": self.id})
return format_html(
"<a href='{}' target='_blank'>{} - {}</a>",
mark_safe(tag),
self.congress,
self.event_name,
)
[docs]
class Category(models.Model):
"""Event Categories such as <100 MPs or club members etc. Free format."""
event = models.ForeignKey(Event, on_delete=models.CASCADE)
description = models.CharField("Event Category", max_length=30)
class Meta:
verbose_name_plural = "Categories"
def __str__(self):
return self.description
[docs]
def save(self, *args, **kwargs):
if self.description and getattr(self, "_description_changed", True):
self.description = bleach.clean(
self.description,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(Category, self).save(*args, **kwargs)
[docs]
class Session(models.Model):
"""A session within an event"""
event = models.ForeignKey(Event, on_delete=models.CASCADE)
session_date = models.DateField()
session_start = models.TimeField()
session_end = models.TimeField(null=True, blank=True)
@property
def href(self):
"""Returns an HTML link tag that can be used to go to the session edit screen"""
tag = reverse(
"events:edit_session",
kwargs={"session_id": self.id, "event_id": self.event.id},
)
return f"<a href='{tag}' target='_blank'>{self.session_date} {self.session_start}</a>"
[docs]
class EventEntry(models.Model):
"""An entry to an event"""
[docs]
class EntryStatus(models.TextChoices):
PENDING = "Pending"
COMPLETE = "Complete"
CANCELLED = "Cancelled"
IN_BASKET = "In Cart"
event = models.ForeignKey(Event, on_delete=models.PROTECT)
entry_status = models.CharField(
"Entry Status",
max_length=20,
choices=EntryStatus.choices,
default=EntryStatus.PENDING,
)
primary_entrant = models.ForeignKey(User, on_delete=models.PROTECT)
category = models.ForeignKey(
Category, on_delete=models.SET_NULL, null=True, blank=True
)
free_format_answer = models.CharField(
"Free Format Answer", max_length=60, null=True, blank=True
)
team_name = models.CharField(max_length=15, null=True, blank=True)
notes = models.TextField("Notes", null=True, blank=True)
comment = models.TextField("Comments", null=True, blank=True)
first_created_date = models.DateTimeField(default=timezone.now)
entry_complete_date = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name_plural = "Event entries"
def __str__(self):
return "%s - %s - %s" % (
self.event.congress,
self.event.event_name,
self.primary_entrant,
)
[docs]
def save(self, *args, **kwargs):
if self.free_format_answer and getattr(
self, "_free_format_answer_changed", True
):
self.free_format_answer = bleach.clean(
self.free_format_answer,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.notes and getattr(self, "_notes_changed", True):
self.notes = bleach.clean(
self.notes,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
if self.comment and getattr(self, "_comment_changed", True):
self.comment = bleach.clean(
self.comment,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(EventEntry, self).save(*args, **kwargs)
[docs]
def check_if_paid(self):
"""go through sub level event entry players and see if this is now
complete as well."""
all_complete = True
for event_entry_player in self.evententryplayer_set.all():
if event_entry_player.payment_status not in ["Paid", "Free"]:
all_complete = False
break
if all_complete:
self.entry_status = EventEntry.EntryStatus.COMPLETE
self.entry_complete_date = timezone.now()
else:
# See if in basket still
if BasketItem.objects.filter(event_entry=self).exists():
self.entry_status = EventEntry.EntryStatus.IN_BASKET
else:
self.entry_status = EventEntry.EntryStatus.PENDING
self.save()
[docs]
def user_can_change(self, member):
"""Check if a user has access to change this entry.
Either the primary_entrant who created the entry or
any of the players can change the entry."""
if member == self.primary_entrant:
return True
allowed = (
EventEntryPlayer.objects.filter(event_entry=self)
.filter(player=member)
.exclude(event_entry__entry_status="Cancelled")
.exists()
)
return allowed
@property
def href(self):
"""Returns an HTML link tag that can be used to go to the event entry view"""
tag = reverse("events:admin_evententry", kwargs={"evententry_id": self.id})
return f"<a href='{tag}' target='_blank'>{self.event.congress} - {self.event.event_name}</a>"
[docs]
def ordered_event_entry_player(self):
"""helper function to set order of queryset for event_entry_player"""
return (
self.evententryplayer_set.all()
.distinct("pk")
.order_by("pk")
.select_related("player")
)
[docs]
def get_team_name(self):
"""If the team name field is None we default the team name to the surname of the primary entrant.
We also return it in uppercase and truncate to 15 chars"""
if self.event.allow_team_names and self.team_name:
return self.team_name.upper()
if self.primary_entrant.id == TBA_PLAYER:
return "TBA"
else:
return self.primary_entrant.last_name.upper()[:15]
@property
def paying_players(self):
"""return the number of players in the entry who are paying (ie not Free)"""
return (
EventEntryPlayer.objects.filter(
event_entry=self,
)
.exclude(payment_status="Free")
.count()
)
@property
def can_recalculate(self):
"""Return whether the entry fees can be recalculated, ie a teams event with
more than 4 entries and no payments made"""
if self.event.player_format == "Teams":
players = EventEntryPlayer.objects.filter(event_entry=self)
if players.count() > 4:
total_payments_received = 0
for player in players:
total_payments_received += float(player.payment_received)
return total_payments_received == 0
return False
[docs]
def recalculate_fees(self, default_payment_type="my-system-dollars"):
"""Recalculate the entry fees for an existing team entry of 5/6
Returns success or failure.
default_payment_type is used for player entries that were previously free.
Note: the EventEntryPlayer objects must already exist and will be updated"""
if not self.can_recalculate:
return False
# update the event entry player records
event_entry_players = EventEntryPlayer.objects.filter(
event_entry=self,
)
actual_team_size = event_entry_players.count()
for event_entry_player in event_entry_players:
entry_fee, discount, reason, description = self.event.entry_fee_for(
event_entry_player.player,
check_date=self.first_created_date.date(),
actual_team_size=actual_team_size,
)
event_entry_player.entry_fee = entry_fee
event_entry_player.reason = description
if event_entry_player.payment_type == "Free":
event_entry_player.payment_type = default_payment_type
if event_entry_player.payment_status == "Free":
event_entry_player.payment_status = "Unpaid"
event_entry_player.save()
return True
[docs]
class EventEntryPlayer(models.Model):
"""A player who is entering an event"""
event_entry = models.ForeignKey(EventEntry, on_delete=models.CASCADE)
player = models.ForeignKey(User, on_delete=models.CASCADE, related_name="player")
paid_by = models.ForeignKey(
User, on_delete=models.CASCADE, null=True, blank=True, related_name="paid_by"
)
payment_type = models.CharField(
"Payment Type", max_length=20, choices=PAYMENT_TYPES, default="Unknown"
)
payment_status = models.CharField(
"Payment Status", max_length=20, choices=PAYMENT_STATUSES, default="Unpaid"
)
batch_id = models.CharField(
"Payment Batch ID", max_length=40, null=True, blank=True
)
reason = models.CharField("Entry Fee Reason", max_length=40, null=True, blank=True)
entry_fee = models.DecimalField(
"Entry Fee", decimal_places=2, max_digits=10, null=True, blank=True
)
payment_received = models.DecimalField(
"Payment Received", decimal_places=2, max_digits=10, default=0.0
)
# See doco for more info, this allows a convener to enter meaningful data into the entry
# for download to a scoring program. It is a last resort for registered players who refuse to
# sign up for Cobalt.
override_tba_name = models.CharField(max_length=50, null=True, blank=True)
override_tba_system_number = models.IntegerField(default=0)
first_created_date = models.DateTimeField(default=timezone.now)
entry_complete_date = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.event_entry} - {self.player}"
[docs]
def save(self, *args, **kwargs):
if self.reason and getattr(self, "_reason_changed", True):
self.reason = bleach.clean(
self.reason,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(EventEntryPlayer, self).save(*args, **kwargs)
[docs]
class PlayerBatchId(models.Model):
"""Maps a batch Id associated with a payment to the user who made the
payment. We use the same approach for all players so can't assume it
will be the primary entrant."""
player = models.ForeignKey(User, on_delete=models.CASCADE)
batch_id = models.CharField(
"Payment Batch ID", max_length=40, null=True, blank=True
)
[docs]
class CongressLink(models.Model):
"""Link Items for Congresses"""
congress = models.ForeignKey(Congress, on_delete=models.CASCADE)
link = models.CharField("Congress Link", max_length=100)
def __str__(self):
return "%s" % (self.congress)
[docs]
class CongressNewsItem(models.Model):
"""News Items for Congresses"""
congress = models.ForeignKey(Congress, on_delete=models.CASCADE)
text = models.TextField()
def __str__(self):
return f"{self.congress}"
[docs]
def save(self, *args, **kwargs):
if self.text and getattr(self, "_text_changed", True):
self.text = bleach.clean(
self.text,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(CongressNewsItem, self).save(*args, **kwargs)
[docs]
class BasketItem(models.Model):
"""items in a basket. We don't define basket itself as it isn't needed"""
player = models.ForeignKey(User, on_delete=models.CASCADE)
event_entry = models.ForeignKey(EventEntry, on_delete=models.CASCADE)
[docs]
class EventLog(models.Model):
"""log of things that happen within an event"""
event = models.ForeignKey(Event, on_delete=models.CASCADE)
actor = models.ForeignKey(User, on_delete=models.CASCADE)
event_entry = models.ForeignKey(
EventEntry, on_delete=models.SET_NULL, null=True, blank=True
)
action_date = models.DateTimeField(default=timezone.now)
action = models.TextField("Action")
def __str__(self):
return f"{self.event} - {self.actor}"
[docs]
class EventPlayerDiscount(models.Model):
"""Maps player discounts to events. For example if someone is given free
entry to an event."""
event = models.ForeignKey(Event, on_delete=models.CASCADE)
player = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="player_discount"
)
admin = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="admin_discount"
)
entry_fee = models.DecimalField("Entry Fee", max_digits=12, decimal_places=2)
reason = models.CharField("Reason", max_length=200)
create_date = models.DateTimeField(default=timezone.now)
def __str__(self):
return f"{self.event} - {self.player}"
[docs]
class Bulletin(models.Model):
"""Regular PDF bulletins for congresses"""
document = models.FileField(upload_to="bulletins/%Y/%m/%d/")
create_date = models.DateTimeField(default=timezone.now)
congress = models.ForeignKey(Congress, on_delete=models.CASCADE)
description = models.CharField("Description", max_length=200)
def __str__(self):
return f"{self.congress} - {self.description}"
[docs]
class CongressDownload(models.Model):
"""Documents associated with the congress that a convener wants on the
congress page"""
document = models.FileField(upload_to="congress-downloads/%Y/%m/%d/")
create_date = models.DateTimeField(default=timezone.now)
congress = models.ForeignKey(Congress, on_delete=models.CASCADE)
description = models.CharField("Description", max_length=200)
def __str__(self):
return f"{self.congress} - {self.description}"
[docs]
class PartnershipDesk(models.Model):
"""Partnership Desk players looking for partners"""
event = models.ForeignKey(Event, on_delete=models.CASCADE)
player = models.ForeignKey(User, on_delete=models.CASCADE)
private = models.BooleanField(default=False)
comment = models.TextField("Comment", null=True, blank=True)
create_date = models.DateTimeField(default=timezone.now)
def __str__(self):
return f"{self.event} - {self.player}"
[docs]
def save(self, *args, **kwargs):
if self.comment and getattr(self, "_comment_changed", True):
self.comment = bleach.clean(
self.comment,
strip=True,
tags=BLEACH_ALLOWED_TAGS,
attributes=BLEACH_ALLOWED_ATTRIBUTES,
styles=BLEACH_ALLOWED_STYLES,
)
super(PartnershipDesk, self).save(*args, **kwargs)