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..7876a426 100644 --- a/hiss/application/admin.py +++ b/hiss/application/admin.py @@ -102,7 +102,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 +115,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..8f1189c7 100644 --- a/hiss/application/emails.py +++ b/hiss/application/emails.py @@ -1,45 +1,15 @@ +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 - +from django.conf import settings #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() - def send_creation_email(app: Application) -> None: """ Sends an email to the user informing them of their newly-created app. @@ -59,37 +29,55 @@ 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) + try: + app = Application.objects.get(id=app_id) - qr_content = json.dumps( - { + subject = f"HowdyHack: Important Day-of Information!" + email_template = "application/emails/confirmed.html" + + if app.status == "E": + subject = f"HowdyHack 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), + "meal_group": app.meal_group, } - ) + html_msg = render_to_string(email_template, context) + msg = html.strip_tags(html_msg) + email = mail.EmailMultiAlternatives( + subject, msg, from_email=None, to=[app.user.email] + ) + email.attach_alternative(html_msg, "text/html") - email_thread = EmailQRThread(subject, plain_msg, html_msg, app.user.email, qr_content) - email_thread.start() + 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(), "text/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 df165475..bb8b9840 100644 --- a/hiss/application/models.py +++ b/hiss/application/models.py @@ -401,7 +401,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/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/user/models.py b/hiss/user/models.py index c0a040fd..a1d0aefd 100644 --- a/hiss/user/models.py +++ b/hiss/user/models.py @@ -7,7 +7,11 @@ from django.template.loader import render_to_string from django.utils import html from rest_framework.authtoken.models import Token -import threading + +from celery import shared_task +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.html import strip_tags class EmailUserManager(auth_models.UserManager): """ @@ -36,23 +40,6 @@ def create_superuser( extra_fields.setdefault("is_active", True) return self._create_user(email, password, **extra_fields) -class EmailThread(threading.Thread): - def __init__(self, subject, plain_message, recipient_email, html_message): - self.subject = subject - self.plain_message = plain_message - self.recipient_email = recipient_email - self.html_message = html_message - threading.Thread.__init__(self) - - def run(self): - mail.send_mail( - subject=self.subject, - message=self.plain_message, - from_email= settings.ORGANIZER_EMAIL, - recipient_list=[self.recipient_email], - html_message=self.html_message, - ) - class User(auth_models.AbstractUser): """ A representation of a user within the registration system. Users are uniquely identified by their email, @@ -87,12 +74,18 @@ class User(auth_models.AbstractUser): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] - def send_html_email(self, template_name, context, subject): - """Send an HTML email to the user.""" + @shared_task + def send_html_email(template_name, context, subject, recipient_email): + """Celery task to send an HTML email.""" html_msg = render_to_string(template_name, context) - plain_msg = html.strip_tags(html_msg) - email_thread = EmailThread(subject, plain_msg, self.email, html_msg) - email_thread.start() + plain_msg = strip_tags(html_msg) + send_mail( + subject=subject, + message=plain_msg, + from_email=settings.ORGANIZER_EMAIL, + recipient_list=[recipient_email], + html_message=html_msg, + ) @receiver(post_save, sender=settings.AUTH_USER_MODEL)