diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 00000000..0958dab0 Binary files /dev/null and b/dump.rdb differ diff --git a/hiss/Procfile b/hiss/Procfile new file mode 100644 index 00000000..d48c4bff --- /dev/null +++ b/hiss/Procfile @@ -0,0 +1,2 @@ +web: gunicorn hiss.wsgi --log-file - +worker: celery -A hiss worker --loglevel=info \ No newline at end of file diff --git a/hiss/application/admin.py b/hiss/application/admin.py index ebca4f0a..76506b6b 100644 --- a/hiss/application/admin.py +++ b/hiss/application/admin.py @@ -59,6 +59,7 @@ def build_approval_email( "event_year": settings.EVENT_YEAR, "confirmation_deadline": confirmation_deadline, "organizer_email": settings.ORGANIZER_EMAIL, + "event_date_text": settings.EVENT_DATE_TEXT, } html_message = render_to_string("application/emails/approved.html", context) message = strip_tags(html_message) @@ -78,6 +79,7 @@ def build_rejection_email(application: Application) -> Tuple[str, str, None, Lis "organizer_name": settings.ORGANIZER_NAME, "event_year": settings.EVENT_YEAR, "organizer_email": settings.ORGANIZER_EMAIL, + "event_date_text": settings.EVENT_DATE_TEXT, } html_message = render_to_string("application/emails/rejected.html", context) message = strip_tags(html_message) @@ -102,7 +104,7 @@ def approve(_modeladmin, _request: HttpRequest, queryset: QuerySet) -> None: print(f"approval email built for {approval_email[-1:]}") email_tuples.append(approval_email) application.save() - send_mass_html_mail(email_tuples) + send_mass_html_mail.delay(email_tuples) def reject(_modeladmin, _request: HttpRequest, queryset: QuerySet) -> None: @@ -115,7 +117,7 @@ def reject(_modeladmin, _request: HttpRequest, queryset: QuerySet) -> None: application.status = STATUS_REJECTED email_tuples.append(build_rejection_email(application)) application.save() - send_mass_html_mail(email_tuples) + send_mass_html_mail.delay(email_tuples) def resend_confirmation(_modeladmin, _request: HttpRequest, queryset: QuerySet) -> None: diff --git a/hiss/application/emails.py b/hiss/application/emails.py index 92ddf1df..052c8dcb 100644 --- a/hiss/application/emails.py +++ b/hiss/application/emails.py @@ -1,44 +1,14 @@ +from celery import shared_task import json from io import BytesIO - import pyqrcode -from django.conf import settings from django.core import mail from django.template.loader import render_to_string from django.utils import html - from application.models import Application from application.apple_wallet import get_apple_wallet_pass_url - -import threading -from django.core.mail import EmailMessage - -#create separate threading class for confirmation email since it has a QR code - -class EmailQRThread(threading.Thread): - def __init__(self, subject, msg, html_msg, recipient_email, qr_stream): - self.subject = subject - self.msg = msg - self.html_msg = html_msg - self.recipient_email = recipient_email - self.qr_stream = qr_stream - threading.Thread.__init__(self) - - def run(self): - qr_code = pyqrcode.create(self.qr_content) - qr_stream = BytesIO() - qr_code.png(qr_stream, scale=5) - - email = mail.EmailMultiAlternatives( - self.subject, self.msg, from_email=None, to=[self.recipient_email] - ) - email.attach_alternative(self.html_msg, "text/html") - email.attach("code.png", self.qr_stream.getvalue(), "text/png") - - # if above code is defined directly in function, it will run synchronously - # therefore need to directly define in threading class to run asynchronously - - email.send() +from django.conf import settings +from django.core.mail import EmailMultiAlternatives def send_creation_email(app: Application) -> None: """ @@ -59,37 +29,56 @@ def send_creation_email(app: Application) -> None: # send_html_email is threaded from the User class # see user/models.py - app.user.send_html_email(template_name, context, subject) + app.user.send_html_email.delay(template_name, context, subject) - -def send_confirmation_email(app: Application) -> None: +@shared_task +def send_confirmation_email(app_id: int) -> None: """ Sends a confirmation email to a user, which contains their QR code as well as additional event information. - :param app: The user's application - :type app: Application + :param app_id: The ID of the user's application + :type app_id: int :return: None """ - subject = f"HowdyHack Waitlist: Important Day-Of Information" - email_template = "application/emails/confirmed.html" - context = { - "first_name": app.first_name, - "event_name": settings.EVENT_NAME, - "organizer_name": settings.ORGANIZER_NAME, - "event_year": settings.EVENT_YEAR, - "organizer_email": settings.ORGANIZER_EMAIL, - "apple_wallet_url": get_apple_wallet_pass_url(app.user.email), - } - html_msg = render_to_string(email_template, context) - plain_msg = html.strip_tags(html_msg) - - qr_content = json.dumps( - { + try: + app = Application.objects.get(id=app_id) + subject = f"TAMUhack: Important Day-Of Information" + email_template = "application/emails/confirmed.html" + + if app.status == "E": + subject = f"TAMUhack Waitlist: Important Day-of Information!" + email_template = "application/emails/confirmed-waitlist.html" + + context = { "first_name": app.first_name, - "last_name": app.last_name, - "email": app.user.email, - "university": app.school.name, + "event_name": settings.EVENT_NAME, + "organizer_name": settings.ORGANIZER_NAME, + "event_year": settings.EVENT_YEAR, + "organizer_email": settings.ORGANIZER_EMAIL, + "apple_wallet_url": get_apple_wallet_pass_url(app.user.email), + "event_date_text": settings.EVENT_DATE_TEXT, } - ) - - email_thread = EmailQRThread(subject, plain_msg, html_msg, app.user.email, qr_content) - email_thread.start() + + html_msg = render_to_string(email_template, context) + plain_msg = html.strip_tags(html_msg) + email = mail.EmailMultiAlternatives( + subject, plain_msg, from_email=None, to=[app.user.email] + ) + + email.attach_alternative(html_msg, "text/html") + qr_content = json.dumps( + { + "first_name": app.first_name, + "last_name": app.last_name, + "email": app.user.email, + "university": app.school.name, + } + ) + + qr_code = pyqrcode.create(qr_content) + qr_stream = BytesIO() + qr_code.png(qr_stream, scale=5) + email.attach("code.png", qr_stream.getvalue(), "image/png") + print(f"sending confirmation email to {app.user.email}") + email.send() + except Exception as e: + print(f"Error sending confirmation email: {e}") \ No newline at end of file diff --git a/hiss/application/fixtures/schools.json b/hiss/application/fixtures/schools.json index 8a5fad80..38c267dd 100644 --- a/hiss/application/fixtures/schools.json +++ b/hiss/application/fixtures/schools.json @@ -10984,7 +10984,7 @@ }, { "model": "application.school", - "pk": 2075, + "pk": 2074, "fields": { "name": "Texas A&M University - San Antonio" } @@ -14519,7 +14519,7 @@ }, { "model": "application.school", - "pk": 2074, + "pk": 2075, "fields": { "name": "Other" } diff --git a/hiss/application/meal_groups.py b/hiss/application/meal_groups.py new file mode 100644 index 00000000..ff946ecd --- /dev/null +++ b/hiss/application/meal_groups.py @@ -0,0 +1,59 @@ +from django.db import transaction +from models import Application + +NUM_GROUPS = 4 +RESTRICTED_FRONTLOAD_FACTOR = 1.3 + +def assign_food_groups(): + veg_apps = [] + nobeef_apps = [] + nopork_apps = [] + allergy_apps = [] + othernonveg_apps = [] + + applicants = Application.objects.filter(status__in=['A', 'E', 'C']) + + for app in applicants: + if "Vegetarian" in app.dietary_restrictions or "Vegan" in app.dietary_restrictions: + veg_apps.append(app) + elif "No-Beef" in app.dietary_restrictions: + nobeef_apps.append(app) + elif "No-Pork" in app.dietary_restrictions: + nopork_apps.append(app) + elif "Food-Allergy" in app.dietary_restrictions: + allergy_apps.append(app) + else: + othernonveg_apps.append(app) + + restricted_apps = veg_apps + nobeef_apps + nopork_apps + allergy_apps + num_apps = len(restricted_apps) + len(othernonveg_apps) + + group_size = num_apps // NUM_GROUPS + restricted_percent = len(restricted_apps) / num_apps + restricted_target = restricted_percent * RESTRICTED_FRONTLOAD_FACTOR + restricted_per_group = restricted_target * group_size + + groups = [[] for _ in range(NUM_GROUPS)] + group_restricted_count = [0] * NUM_GROUPS + + # Assign restricted applicants + for i in range(NUM_GROUPS): + groups[i] = restricted_apps[:int(restricted_per_group)] + restricted_apps = restricted_apps[int(restricted_per_group):] + group_restricted_count[i] = len(groups[i]) + + # Assign unrestricted applicants + for i in range(NUM_GROUPS): + groups[i] += othernonveg_apps[:group_size - group_restricted_count[i]] + othernonveg_apps = othernonveg_apps[group_size - group_restricted_count[i]:] + groups[-1] += othernonveg_apps + + # Update database with meal groups + with transaction.atomic(): + for i, group in enumerate(groups): + group_letter = chr(65 + i) + for app in group: + app.meal_group = group_letter + app.save() + + return {f"Group {chr(65 + i)}": len(group) for i, group in enumerate(groups)} diff --git a/hiss/application/models.py b/hiss/application/models.py index 9e2144c8..b5ad624b 100644 --- a/hiss/application/models.py +++ b/hiss/application/models.py @@ -405,7 +405,92 @@ class Application(models.Model): user = models.ForeignKey("user.User", on_delete=models.CASCADE, null=False) status = models.CharField( choices=STATUS_OPTIONS, max_length=1, default=STATUS_PENDING - ) + ) + + def get_next_meal_group(self): + """ + Determines the next meal group considering frontloading for restricted groups + using the RESTRICTED_FRONTLOAD_FACTOR. + """ + RESTRICTED_FRONTLOAD_FACTOR = 1.3 + + meal_groups = ['A', 'B', 'C', 'D'] + group_distribution = {group: 0 for group in meal_groups} + restricted_distribution = {group: 0 for group in meal_groups} + total_confirmed = Application.objects.filter(status='C').exclude(meal_group__isnull=True) + + for group in meal_groups: + group_distribution[group] = total_confirmed.filter(meal_group=group).count() + restricted_distribution[group] = total_confirmed.filter( + meal_group=group, + dietary_restrictions__icontains="Vegetarian" + ).count() + total_confirmed.filter( + meal_group=group, + dietary_restrictions__icontains="No-Beef" + ).count() + total_confirmed.filter( + meal_group=group, + dietary_restrictions__icontains="No-Pork" + ).count() + total_confirmed.filter( + meal_group=group, + dietary_restrictions__icontains="Food-Allergy" + ).count() + + total_apps = sum(group_distribution.values()) + total_restricted = sum(restricted_distribution.values()) + if total_apps == 0: + return meal_groups[0] + + base_restricted_percent = total_restricted / total_apps if total_apps else 0 + target_restricted_percent = { + group: base_restricted_percent * (RESTRICTED_FRONTLOAD_FACTOR if i < len(meal_groups) // 2 else 1.0) + for i, group in enumerate(meal_groups) + } + target_restricted_count = { + group: target_restricted_percent[group] * group_distribution[group] if group_distribution[group] > 0 else 0 + for group in meal_groups + } + + restricted_gap = { + group: target_restricted_count[group] - restricted_distribution[group] + for group in meal_groups + } + prioritized_group = max(restricted_gap, key=restricted_gap.get) + + last_assigned_group = ( + total_confirmed.order_by('-datetime_submitted') + .values_list('meal_group', flat=True) + .first() + ) + if last_assigned_group: + next_index = (meal_groups.index(last_assigned_group) + 1) % len(meal_groups) + else: + next_index = 0 + + # use frontloaded group if it aligns with round-robin or is significantly better + if prioritized_group == meal_groups[next_index]: + return meal_groups[next_index] + elif restricted_gap[prioritized_group] > restricted_gap[meal_groups[next_index]]: + return prioritized_group + else: + return meal_groups[next_index] + + def assign_meal_group(self): + """ + Assigns a meal group based on the current status and dietary restrictions. + """ + if self.status == 'C': # Confirmed + self.meal_group = self.get_next_meal_group() + elif self.status == 'E': # Waitlisted + self.meal_group = 'E' + else: + self.meal_group = None + + def save(self, *args, **kwargs): + """ + Overrides save to ensure meal group assignment logic is applied. + """ + self.assign_meal_group() + super().save(*args, **kwargs) # ABOUT YOU first_name = models.CharField( diff --git a/hiss/application/views.py b/hiss/application/views.py index 128959de..88a116c7 100644 --- a/hiss/application/views.py +++ b/hiss/application/views.py @@ -6,7 +6,7 @@ from django.shortcuts import redirect from django.urls import reverse_lazy from django.views import generic - +from django.db import transaction from application.emails import send_confirmation_email, send_creation_email from application.forms import ApplicationModelForm from application.models import ( @@ -103,9 +103,10 @@ def post(self, request: HttpRequest, *args, **kwargs): raise PermissionDenied( "You can't confirm your application if it hasn't been approved." ) - app.status = STATUS_CONFIRMED - app.save() - send_confirmation_email(app) + with transaction.atomic(): + app.status = STATUS_CONFIRMED + app.save() + send_confirmation_email(app) return redirect(reverse_lazy("status")) diff --git a/hiss/customauth/views.py b/hiss/customauth/views.py index 12b67b69..34e3a6ec 100644 --- a/hiss/customauth/views.py +++ b/hiss/customauth/views.py @@ -7,7 +7,7 @@ from django.http import HttpResponse from django.shortcuts import redirect, render, get_object_or_404 from django.urls import reverse_lazy -from django.utils.encoding import force_bytes, force_text +from django.utils.encoding import force_bytes, force_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.views import generic @@ -26,7 +26,7 @@ def send_confirmation_email(curr_domain: RequestSite, user: User) -> None: "event_name": settings.EVENT_NAME, "organizer_name": settings.ORGANIZER_NAME, } - user.send_html_email(template_name, context, subject) + user.send_html_email.delay(template_name, context, subject) # Create your views here. @@ -58,7 +58,7 @@ class ActivateView(views.View): def get(self, request, *_args, **kwargs): user = None try: - uid = force_text(urlsafe_base64_decode(kwargs["uidb64"])) + uid = force_str(urlsafe_base64_decode(kwargs["uidb64"])) user = get_user_model().objects.get(id=int(uid)) except ( TypeError, diff --git a/hiss/hiss/__init__.py b/hiss/hiss/__init__.py index e69de29b..e0b971ee 100644 --- a/hiss/hiss/__init__.py +++ b/hiss/hiss/__init__.py @@ -0,0 +1,4 @@ +from __future__ import absolute_import, unicode_literals +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/hiss/hiss/celery.py b/hiss/hiss/celery.py new file mode 100644 index 00000000..56a45d05 --- /dev/null +++ b/hiss/hiss/celery.py @@ -0,0 +1,22 @@ +from __future__ import absolute_import, unicode_literals +import os +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hiss.settings.dev') + +app = Celery('hiss') + +# Using a string here so the worker doesn't have to serialize the object to child processes. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + +# Celery configuration for Redis as the broker and result backend +app.conf.broker_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') # Redis as broker +app.conf.result_backend = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') # Redis as result backend + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/hiss/hiss/settings/base.py b/hiss/hiss/settings/base.py index 61cc89fe..cec92994 100644 --- a/hiss/hiss/settings/base.py +++ b/hiss/hiss/settings/base.py @@ -116,7 +116,7 @@ AWS_REGION = "us-east-2" AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") -AWS_S3_BUCKET_NAME = "2024-hh-resumes" +AWS_S3_BUCKET_NAME = "2025-th-resumes" AWS_S3_KEY_PREFIX = "prod" STATIC_URL = "/" + BASE_PATHNAME + "static/" diff --git a/hiss/hiss/settings/customization.py b/hiss/hiss/settings/customization.py index 188bebf3..13da5c92 100644 --- a/hiss/hiss/settings/customization.py +++ b/hiss/hiss/settings/customization.py @@ -1,12 +1,13 @@ from django.utils import timezone MAX_YEARS_ADMISSION = 6 -EVENT_NAME = "HowdyHack" -EVENT_YEAR = "2024" +EVENT_NAME = "TAMUhack" +EVENT_YEAR = "2025" ORGANIZER_NAME = "TAMUhack" ORGANIZER_EMAIL = "hello@tamuhack.com" -EVENT_START_DATETIME = timezone.datetime(2024, 9, 28, hour=9, minute=0, second=0) -EVENT_END_DATETIME = timezone.datetime(2024, 9, 29, hour=12, minute=0, second=0) +EVENT_START_DATETIME = timezone.datetime(2025, 1, 25, hour=9, minute=0, second=0) +EVENT_END_DATETIME = timezone.datetime(2025, 1, 26, hour=12, minute=0, second=0) +EVENT_DATE_TEXT = "January 25-26, 2025" MAX_MEMBERS_PER_TEAM = 4 APPLE_WALLET_S3_BUCKET_URL = "https://hh24-apple-wallet-passes.s3.amazonaws.com" diff --git a/hiss/hiss/settings/dev.py b/hiss/hiss/settings/dev.py index 2d3fd32a..829bfe3c 100644 --- a/hiss/hiss/settings/dev.py +++ b/hiss/hiss/settings/dev.py @@ -4,6 +4,8 @@ # noinspection PyUnresolvedReferences from .customization import * +import os + SECRET_KEY = "development" DEBUG = True # Database @@ -25,3 +27,11 @@ MEDIA_ROOT = "resumes" AWS_S3_KEY_PREFIX = "dev-resumes" + +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'UTC' +CELERY_ENABLE_UTC = True +CELERY_BROKER_URL = os.environ.get('REDIS_URL') +CELERY_RESULT_BACKEND = os.environ.get('REDIS_URL') \ No newline at end of file diff --git a/hiss/requirements.txt b/hiss/requirements.txt index ed3d495d..650e2c59 100644 --- a/hiss/requirements.txt +++ b/hiss/requirements.txt @@ -54,3 +54,5 @@ typed-ast==1.4.0 urllib3==1.25.6 whitenoise==5.2.0 wrapt==1.11.2 +celery==4.4.7 +redis \ No newline at end of file diff --git a/hiss/shared/admin_functions.py b/hiss/shared/admin_functions.py index 85f1de6e..34c4b6c7 100644 --- a/hiss/shared/admin_functions.py +++ b/hiss/shared/admin_functions.py @@ -1,46 +1,19 @@ +from celery import shared_task from django.core.mail import get_connection, EmailMultiAlternatives -import threading -#create separate Threading class for mass emails -class MassEmailThread(threading.Thread): - def __init__(self, subject, text_content, html_content, from_email, recipient_list, connection): - threading.Thread.__init__(self) - self.subject = subject - self.text_content = text_content - self.html_content = html_content - self.from_email = from_email - self.recipient_list = recipient_list - self.connection = connection - self.result = 0 - - def run(self): - email = EmailMultiAlternatives(self.subject, self.text_content, self.from_email, self.recipient_list) - email.attach_alternative(self.html_content, "text/html") - try: - self.result = email.send(fail_silently=False, connection=self.connection) - except Exception as e: - print("Error sending email: ", e) - self.result = 0 - -def send_mass_html_mail(datatuple, fail_silently=False, user=None, password=None, connection=None): +@shared_task +def send_mass_html_mail_task(datatuple, fail_silently=False, user=None, password=None): """ - Sends each message in datatuple (subject, text_content, html_content, from_email, recipient_list). - Returns the number of emails sent. + Celery task to send multiple HTML emails given a datatuple of + (subject, text_content, html_content, from_email, recipient_list). """ - connection = connection or get_connection( + connection = get_connection( username=user, password=password, fail_silently=fail_silently ) - - threads = [] - + messages = [] for subject, text, html, from_email, recipient in datatuple: - email_thread = MassEmailThread(subject, text, html, from_email, recipient, connection) - email_thread.start() - threads.append(email_thread) - - for thread in threads: - thread.join() - - total_sent = sum(thread.result for thread in threads if thread.result) - - return total_sent \ No newline at end of file + message = EmailMultiAlternatives(subject, text, from_email, recipient) + message.attach_alternative(html, "text/html") + messages.append(message) + + return connection.send_messages(messages) diff --git a/hiss/static/style.css b/hiss/static/style.css index fcdbf4f7..d11804e2 100644 --- a/hiss/static/style.css +++ b/hiss/static/style.css @@ -1,3 +1,5 @@ +@import url('//maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'); + html, body { overflow-x: hidden; } @@ -725,8 +727,6 @@ button { color: white; } -@import url('//maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'); - .errorlist > li { color: #721c24; font-size: 14px; diff --git a/hiss/templates/400.html b/hiss/templates/400.html index 11c530e9..0f9eda64 100644 --- a/hiss/templates/400.html +++ b/hiss/templates/400.html @@ -6,7 +6,7 @@
If you think this is a mistake, email us at {{ organizer_email }}
+If you think this is a mistake, email us at {{ ORGANIZER_EMAIL }}
Take Me Back diff --git a/hiss/templates/403.html b/hiss/templates/403.html index fef716ae..ab3ffbef 100644 --- a/hiss/templates/403.html +++ b/hiss/templates/403.html @@ -6,7 +6,7 @@If you think this is a mistake, email us at {{ organizer_email }}
+If you think this is a mistake, email us at {{ ORGANIZER_EMAIL }}
Take Me Back diff --git a/hiss/templates/404.html b/hiss/templates/404.html index 34d929cd..e45a4b48 100644 --- a/hiss/templates/404.html +++ b/hiss/templates/404.html @@ -6,7 +6,7 @@If you think this is a mistake, email us at {{ organizer_email }}
+If you think this is a mistake, email us at {{ ORGANIZER_EMAIL }}
Take Me Back diff --git a/hiss/templates/500.html b/hiss/templates/500.html index 98d74379..56f90f39 100644 --- a/hiss/templates/500.html +++ b/hiss/templates/500.html @@ -6,7 +6,7 @@If you think this is a mistake, email us at {{ organizer_email }}
+If you think this is a mistake, email us at {{ ORGANIZER_EMAIL }}
Take Me Back diff --git a/hiss/templates/application/emails/approved.html b/hiss/templates/application/emails/approved.html index b4431719..adff81cf 100644 --- a/hiss/templates/application/emails/approved.html +++ b/hiss/templates/application/emails/approved.html @@ -53,9 +53,9 @@- The {{ organizer_name }} Team + The {{ ORGANIZER_NAME }} Team diff --git a/hiss/templates/application/emails/confirmed-hh.html b/hiss/templates/application/emails/confirmed-hh.html index 8ab56a1e..bacb2a75 100644 --- a/hiss/templates/application/emails/confirmed-hh.html +++ b/hiss/templates/application/emails/confirmed-hh.html @@ -515,7 +515,7 @@
- Please RSVP below by September 27th, 2024, at 11:59PM or you risk losing your spot. + Please RSVP below by January 24th, 2025, at 11:59PM or you risk losing your spot.