diff --git a/web/forms.py b/web/forms.py index 5e1e85c1e..8fd6c596a 100644 --- a/web/forms.py +++ b/web/forms.py @@ -40,6 +40,7 @@ SuccessStory, TeamGoal, TeamInvite, + WaitingRoom, ) from .referrals import handle_referral from .widgets import ( @@ -350,6 +351,22 @@ def clean_max_students(self): raise forms.ValidationError(msg) return max_students + def clean_title(self): + title = self.cleaned_data.get("title") + if not title: + raise forms.ValidationError("Title is required") + + # Check if title contains valid characters for slugification + if not re.match(r"^[\w\s-]+$", title): + raise forms.ValidationError("Title can only contain letters, numbers, spaces, and hyphens") + + # Check if a course with this slug already exists + slug = slugify(title) + if Course.objects.filter(slug=slug).exists(): + raise forms.ValidationError("A course with a similar title already exists.") + + return title + class CourseForm(forms.ModelForm): description = MarkdownxFormField( @@ -770,39 +787,39 @@ class Meta: } -class LearnForm(forms.Form): - subject = forms.CharField( - max_length=100, - widget=TailwindInput( - attrs={ - "placeholder": "What would you like to learn?", - "class": "block w-full border rounded p-2 focus:outline-none focus:ring-2 focus:ring-orange-500", - } - ), - ) - email = forms.EmailField( - widget=TailwindEmailInput( - attrs={ - "placeholder": "Your email address", - "class": "block w-full border rounded p-2 focus:outline-none focus:ring-2 focus:ring-orange-500", - } - ) - ) - message = forms.CharField( - widget=TailwindTextarea( - attrs={ - "placeholder": "Tell us more about what you want to learn...", - "rows": 4, - "class": "block w-full border rounded p-2 focus:outline-none focus:ring-2 focus:ring-orange-500", - } - ), - required=False, - ) - captcha = CaptchaField( - widget=TailwindCaptchaTextInput( - attrs={"class": "block w-full border rounded p-2 focus:outline-none focus:ring-2 focus:ring-orange-500"} - ) - ) +class LearnForm(forms.ModelForm): + """Form for creating and editing waiting rooms.""" + + class Meta: + model = WaitingRoom + fields = ["title", "description", "subject", "topics"] + + widgets = { + "title": TailwindInput(attrs={"placeholder": "What would you like to learn?"}), + "description": TailwindTextarea(attrs={"rows": 4, "placeholder": "Describe what you want to learn"}), + "subject": TailwindInput(attrs={"placeholder": "Main subject (e.g., Mathematics, Programming)"}), + "topics": TailwindInput( + attrs={"placeholder": "e.g., Python, Machine Learning, Data Science", "class": "tag-input"} + ), + } + help_texts = { + "title": "Give your waiting room a descriptive title", + "subject": "The main subject area for this waiting room", + "topics": "Enter topics separated by commas", + } + + def clean_topics(self): + """Validate and clean the topics field.""" + topics = self.cleaned_data.get("topics") + if not topics: + raise forms.ValidationError("Please enter at least one topic.") + + # Ensure we have at least one non-empty topic after splitting + topic_list = [t.strip() for t in topics.split(",") if t.strip()] + if not topic_list: + raise forms.ValidationError("Please enter at least one valid topic.") + + return topics class TeachForm(forms.Form): diff --git a/web/migrations/0044_waitingroom_fulfilled_course.py b/web/migrations/0044_waitingroom_fulfilled_course.py new file mode 100644 index 000000000..a7616444b --- /dev/null +++ b/web/migrations/0044_waitingroom_fulfilled_course.py @@ -0,0 +1,63 @@ +# Generated by Django 5.1.6 on 2025-03-24 07:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0043_alter_studygroup_members_studygroupinvite"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="WaitingRoom", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("subject", models.CharField(max_length=100)), + ("topics", models.TextField(help_text="Comma-separated list of topics")), + ( + "status", + models.CharField( + choices=[("open", "Open"), ("closed", "Closed"), ("fulfilled", "Fulfilled")], + default="open", + max_length=10, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "creator", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="created_waiting_rooms", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "participants", + models.ManyToManyField( + blank=True, + related_name="joined_waiting_rooms", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "fulfilled_course", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fulfilled_waiting_rooms", + to="web.course", + ), + ), + ], + options={}, + ), + ] diff --git a/web/models.py b/web/models.py index 03fabd0d3..5e925d4b5 100644 --- a/web/models.py +++ b/web/models.py @@ -2150,6 +2150,28 @@ def created_at(self): return self.start_time +class WaitingRoom(models.Model): + """Model for storing waiting room requests for courses on specific subjects.""" + + STATUS_CHOICES = [("open", "Open"), ("closed", "Closed"), ("fulfilled", "Fulfilled")] + + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + subject = models.CharField(max_length=100) + topics = models.TextField(help_text="Comma-separated list of topics") + creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="created_waiting_rooms") + participants = models.ManyToManyField(User, related_name="joined_waiting_rooms", blank=True) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="open") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + fulfilled_course = models.ForeignKey( + "Course", on_delete=models.SET_NULL, null=True, blank=True, related_name="fulfilled_waiting_rooms" + ) + + def __str__(self): + return self.title + + class GradeableLink(models.Model): """Model for storing links that users want to get grades on.""" @@ -2175,6 +2197,24 @@ class Meta: def __str__(self): return self.title + def participant_count(self): + """Return the number of participants in the waiting room.""" + return self.participants.count() + + def topic_list(self): + """Return the list of topics as a list.""" + return [topic.strip() for topic in self.topics.split(",") if topic.strip()] + + def mark_as_fulfilled(self, course=None): + """Mark the waiting room as fulfilled and notify participants.""" + self.status = "fulfilled" + self.save() + + if course: + from .notifications import notify_waiting_room_fulfilled + + notify_waiting_room_fulfilled(self, course) + def get_absolute_url(self): return reverse("gradeable_link_detail", kwargs={"pk": self.pk}) diff --git a/web/notifications.py b/web/notifications.py index f0258106d..37637a7ae 100644 --- a/web/notifications.py +++ b/web/notifications.py @@ -148,6 +148,58 @@ def send_weekly_progress_updates(): ) +def notify_waiting_room_fulfilled(waiting_room, course): + """ + Notify all participants in a waiting room that a course has been created. + + Args: + waiting_room (WaitingRoom): The waiting room that was fulfilled + course (Course): The course that was created from the waiting room + """ + subject = f"New Course Created: {course.title}" + + # Notify all participants + for participant in waiting_room.participants.all(): + notification_data = { + "title": subject, + "message": f"A new course has been created based on a waiting room you joined: '{waiting_room.title}'. " + f"The course '{course.title}' is now available for enrollment.", + "notification_type": "success", + } + + # Send notification + send_notification(participant, notification_data) + + # Send email with more details + html_message = render_to_string( + "emails/waiting_room_fulfilled.html", + { + "user": participant, + "waiting_room": waiting_room, + "course": course, + "site_url": settings.SITE_URL, + }, + ) + + send_mail( + subject, + "", # Plain text version - we're only sending HTML + settings.DEFAULT_FROM_EMAIL, + [participant.email], + html_message=html_message, + ) + + # Also notify the creator if they're not already a participant + if waiting_room.creator not in waiting_room.participants.all(): + notification_data = { + "title": subject, + "message": f"A new course has been created based on your waiting room: '{waiting_room.title}'. " + f"The course '{course.title}' is now available.", + "notification_type": "success", + } + send_notification(waiting_room.creator, notification_data) + + def send_email(subject, message, recipient_list): """ Send an email to the specified recipients and notify Slack. diff --git a/web/templates/base.html b/web/templates/base.html index 9ea8ad184..e7a4f13a2 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -220,6 +220,10 @@ diff --git a/web/templates/courses/create.html b/web/templates/courses/create.html index 3007ed493..faed93502 100644 --- a/web/templates/courses/create.html +++ b/web/templates/courses/create.html @@ -19,6 +19,26 @@

Create New Course

Fill out the form below to create your new course.

+ + {% if request.session.waiting_room_data %} +
+
+
+ + + +
+
+

+ Creating course for waiting room. Ensure the subject and topics match the waiting room's request. +

+
+
+
+ {% endif %}
-

New Learning Interest

+

New Learning Request

-

Subject Interest: {{ subject }}

+

{{ title }}

+

+ Waiting Room ID: {{ waiting_room_id }} +

+

+ Subject: {{ subject }} +

+

+ Topics: {{ topics }} +

From: {{ email }}

+

Description:

+

{{ description }}

{% if message %} -

Additional Information:

+

Additional Requirements:

{{ message }}

{% endif %} +