From a170438569e7fd468ade02647d79f5c888e35ac1 Mon Sep 17 00:00:00 2001 From: 10done Date: Sun, 23 Mar 2025 17:07:13 +0530 Subject: [PATCH 1/4] Assignment Reminder Feature --- crontab | 3 + web/forms.py | 11 ++ .../commands/send_assignment_reminders.py | 15 ++ .../0038_coursematerial_due_date_and_more.py | 55 +++++++ web/models.py | 16 ++ web/notifications.py | 137 +++++++++++++----- .../account/notification_preferences.html | 81 +++++++++++ web/templates/base.html | 5 + web/templates/emails/assignment_reminder.html | 31 ++++ web/templates/emails/base_email.html | 10 ++ web/templates/profile.html | 7 + web/tests/test_send_assignment_reminders.py | 110 ++++++++++++++ web/urls.py | 2 + web/views.py | 25 ++++ 14 files changed, 473 insertions(+), 35 deletions(-) create mode 100644 web/management/commands/send_assignment_reminders.py create mode 100644 web/migrations/0038_coursematerial_due_date_and_more.py create mode 100644 web/templates/account/notification_preferences.html create mode 100644 web/templates/emails/assignment_reminder.html create mode 100644 web/templates/emails/base_email.html create mode 100644 web/tests/test_send_assignment_reminders.py diff --git a/crontab b/crontab index 1f9ab4bd9..c13df79f5 100644 --- a/crontab +++ b/crontab @@ -3,3 +3,6 @@ # Send weekly progress updates every Monday at 8 AM 0 8 * * 1 cd /home/a/projects/AlphaOneLabs/education-website && poetry run python manage.py send_weekly_updates + +# Send assignment reminders daily at 8 AM +0 8 * * * cd /home/a/projects/AlphaOneLabs/education-website && poetry run python manage.py send_assignment_reminders >> /home/a/projects/AlphaOneLabs/education-website/reminder_log.log 2>&1 diff --git a/web/forms.py b/web/forms.py index f0630e31d..b0f369c10 100644 --- a/web/forms.py +++ b/web/forms.py @@ -21,6 +21,7 @@ GradeableLink, LinkGrade, Meme, + NotificationPreference, PeerChallenge, PeerChallengeInvitation, ProductImage, @@ -1522,3 +1523,13 @@ def clean(self): self.add_error("comment", "A comment is required for grades below A.") return cleaned_data + + +class NotificationPreferencesForm(forms.ModelForm): + class Meta: + model = NotificationPreference + fields = ["reminder_days_before", "reminder_hours_before", "email_notifications", "in_app_notifications"] + widgets = { + "reminder_days_before": forms.NumberInput(attrs={"min": 1, "max": 14}), + "reminder_hours_before": forms.NumberInput(attrs={"min": 1, "max": 72}), + } diff --git a/web/management/commands/send_assignment_reminders.py b/web/management/commands/send_assignment_reminders.py new file mode 100644 index 000000000..ae3be5750 --- /dev/null +++ b/web/management/commands/send_assignment_reminders.py @@ -0,0 +1,15 @@ +# web/management/commands/send_assignment_reminders.py +from django.core.management.base import BaseCommand + +from web.notifications import send_assignment_reminders + + +class Command(BaseCommand): + help = "Send reminders for upcoming assignment deadlines" + + def handle(self, *args, **options): + try: + send_assignment_reminders() + self.stdout.write(self.style.SUCCESS("Successfully sent assignment reminders")) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error sending assignment reminders: {str(e)}")) diff --git a/web/migrations/0038_coursematerial_due_date_and_more.py b/web/migrations/0038_coursematerial_due_date_and_more.py new file mode 100644 index 000000000..b507020e7 --- /dev/null +++ b/web/migrations/0038_coursematerial_due_date_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 5.1.6 on 2025-03-23 10:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0037_profile_how_did_you_hear_about_us"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="coursematerial", + name="due_date", + field=models.DateTimeField(blank=True, help_text="Deadline for assignment submission", null=True), + ), + migrations.AddField( + model_name="coursematerial", + name="final_reminder_sent", + field=models.BooleanField(default=False, help_text="Whether a final reminder has been sent"), + ), + migrations.AddField( + model_name="coursematerial", + name="reminder_sent", + field=models.BooleanField(default=False, help_text="Whether an early reminder has been sent"), + ), + migrations.CreateModel( + name="NotificationPreference", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "reminder_days_before", + models.IntegerField(default=3, help_text="Days before deadline to send first reminder"), + ), + ( + "reminder_hours_before", + models.IntegerField(default=24, help_text="Hours before deadline to send final reminder"), + ), + ("email_notifications", models.BooleanField(default=True)), + ("in_app_notifications", models.BooleanField(default=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="notification_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/web/models.py b/web/models.py index aaffbbe13..f56f292bf 100644 --- a/web/models.py +++ b/web/models.py @@ -415,6 +415,11 @@ class CourseMaterial(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # New fields for assignment deadlines and reminder tracking + due_date = models.DateTimeField(null=True, blank=True, help_text="Deadline for assignment submission") + reminder_sent = models.BooleanField(default=False, help_text="Whether an early reminder has been sent") + final_reminder_sent = models.BooleanField(default=False, help_text="Whether a final reminder has been sent") + class Meta: ordering = ["order", "created_at"] @@ -2199,3 +2204,14 @@ def complete(self, user_quiz): message=f"{self.participant.username} has completed your challenge: {self.challenge.title}", notification_type="success", ) + + +class NotificationPreference(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="notification_preferences") + reminder_days_before = models.IntegerField(default=3, help_text="Days before deadline to send first reminder") + reminder_hours_before = models.IntegerField(default=24, help_text="Hours before deadline to send final reminder") + email_notifications = models.BooleanField(default=True) + in_app_notifications = models.BooleanField(default=True) + + def __str__(self): + return f"Notification preferences for {self.user.username}" diff --git a/web/notifications.py b/web/notifications.py index 88d5ac998..25f2db1e4 100644 --- a/web/notifications.py +++ b/web/notifications.py @@ -6,7 +6,7 @@ from django.template.loader import render_to_string from django.utils import timezone -from .models import Enrollment, Notification, Session +from .models import CourseMaterial, Enrollment, Notification, NotificationPreference, Session from .slack import send_slack_notification logger = logging.getLogger(__name__) @@ -20,8 +20,6 @@ def send_notification(user, notification_data): message=notification_data["message"], notification_type=notification_data.get("notification_type", "info"), ) - - # Send email notification subject = notification_data["title"] html_message = render_to_string( "emails/notification.html", @@ -37,7 +35,6 @@ def send_notification(user, notification_data): [user.email], html_message=html_message, ) - return notification @@ -62,7 +59,7 @@ def send_enrollment_confirmation(enrollment): ) send_mail( subject, - "", # Plain text version - we're only sending HTML + "", settings.DEFAULT_FROM_EMAIL, [enrollment.student.email], html_message=html_message, @@ -92,7 +89,6 @@ def notify_session_reminder(session): """Send reminder email to enrolled students about upcoming session.""" subject = f"Reminder: Upcoming Session - {session.title}" enrollments = session.course.enrollments.filter(status="approved") - for enrollment in enrollments: html_message = render_to_string( "emails/session_reminder.html", @@ -115,7 +111,6 @@ def notify_course_update(course, update_message): """Notify enrolled students about course updates.""" subject = f"Course Update - {course.title}" enrollments = course.enrollments.filter(status="approved") - for enrollment in enrollments: html_message = render_to_string( "emails/course_update.html", @@ -138,12 +133,10 @@ def send_upcoming_session_reminders(): """Send reminders for sessions happening in the next 24 hours.""" now = timezone.now() reminder_window = now + timedelta(hours=24) - upcoming_sessions = Session.objects.filter( start_time__gt=now, start_time__lte=reminder_window, ) - for session in upcoming_sessions: notify_session_reminder(session) @@ -151,12 +144,10 @@ def send_upcoming_session_reminders(): def send_weekly_progress_updates(): """Send weekly progress updates to enrolled students.""" enrollments = Enrollment.objects.filter(status="approved") - for enrollment in enrollments: progress = enrollment.progress if not progress: continue - subject = f"Weekly Progress Update - {enrollment.course.title}" html_message = render_to_string( "emails/weekly_progress.html", @@ -180,14 +171,6 @@ def send_weekly_progress_updates(): def send_email(subject, message, recipient_list): """ Send an email to the specified recipients and notify Slack. - - Args: - subject (str): The email subject - message (str): The email message body - recipient_list (list): List of email addresses to send to - - Returns: - bool: True if email was sent successfully, False otherwise """ try: send_mail( @@ -197,20 +180,14 @@ def send_email(subject, message, recipient_list): recipient_list=recipient_list, fail_silently=False, ) - - # Send Slack notification slack_message = f"📧 Email sent\nSubject: {subject}\nTo: {', '.join(recipient_list)}" send_slack_notification(slack_message) - return True except Exception as e: error_msg = f"Failed to send email: {str(e)}" logger.error(error_msg) - - # Notify Slack about the failure slack_message = f"❌ Email sending failed\nSubject: {subject}\nTo: {', '.join(recipient_list)}\nError: {str(e)}" send_slack_notification(slack_message) - return False @@ -221,13 +198,10 @@ def notify_team_invite(invite): "message": f"{invite.sender.username} has invited you to join the team goal '{invite.goal.title}'.", "notification_type": "info", } - try: send_notification(invite.recipient, notification_data) except Exception as e: logger.error(f"Failed to send team invite notification: {str(e)}") - - # Send a Slack notification if applicable try: slack_message = ( f"🤝 {invite.sender.username} invited {invite.recipient.username} to team goal '{invite.goal.title}'" @@ -240,19 +214,16 @@ def notify_team_invite(invite): def notify_team_invite_response(invite): """Notify the sender about the response to their team invitation.""" status_text = "accepted" if invite.status == "accepted" else "declined" - notification_data = { "title": f"Team Invitation {status_text.capitalize()}: {invite.goal.title}", - "message": f"{invite.recipient.username} has {status_text} your invite to join goal : '{invite.goal.title}'.", + "message": f"{invite.recipient.username} has {status_text} your invite to join goal: '{invite.goal.title}'.", "notification_type": "success" if invite.status == "accepted" else "info", } - send_notification(invite.sender, notification_data) def notify_team_goal_completion(goal, user): """Notify team members when a user marks their contribution as complete.""" - # Create notification for the team creator if user != goal.creator: notification_data = { "title": f"Team Goal Progress: {goal.title}", @@ -260,14 +231,110 @@ def notify_team_goal_completion(goal, user): "notification_type": "success", } send_notification(goal.creator, notification_data) - - # If goal is now 100% complete, notify all members if goal.completion_percentage == 100: for member in goal.members.all(): - if member.user != user: # Don't notify the user who just completed + if member.user != user: notification_data = { "title": f"Team Goal Completed: {goal.title}", "message": f"The team goal '{goal.title}' has been completed by all members!", "notification_type": "success", } send_notification(member.user, notification_data) + + +def send_assignment_reminders(): + """Send early and final reminders for upcoming assignment deadlines.""" + now = timezone.now() + + # Define reminder windows + early_window = now + timedelta(days=3) # Early reminders: assignments due in next 3 days. + final_window = now + timedelta(hours=24) # Final reminders: assignments due in next 24 hours. + + # Process Early Reminders + early_assignments = CourseMaterial.objects.filter( + material_type="assignment", due_date__gt=now, due_date__lte=early_window, reminder_sent=False + ) + + for assignment in early_assignments: + course = assignment.course + enrollments = course.enrollments.filter(status="approved") + for enrollment in enrollments: + student = enrollment.student + preferences, _ = NotificationPreference.objects.get_or_create(user=student) + days_before_deadline = (assignment.due_date - now).days + if days_before_deadline <= preferences.reminder_days_before: + subject = f"Upcoming Assignment Deadline: {assignment.title}" + html_message = render_to_string( + "emails/assignment_reminder.html", + { + "student": student, + "assignment": assignment, + "course": course, + "due_date": assignment.due_date, + "days_remaining": days_before_deadline, + }, + ) + if preferences.in_app_notifications: + send_notification( + student, + { + "title": subject, + "message": f"Your assignment '{assignment.title}' is due in {days_before_deadline} days.", + "notification_type": "warning", + }, + ) + if preferences.email_notifications: + send_mail( + subject, + "", # Plain text version + settings.DEFAULT_FROM_EMAIL, + [student.email], + html_message=html_message, + ) + assignment.reminder_sent = True + assignment.save() + + # Process Final Reminders + final_assignments = CourseMaterial.objects.filter( + material_type="assignment", due_date__gt=now, due_date__lte=final_window, final_reminder_sent=False + ) + + for assignment in final_assignments: + course = assignment.course + enrollments = course.enrollments.filter(status="approved") + for enrollment in enrollments: + student = enrollment.student + preferences, _ = NotificationPreference.objects.get_or_create(user=student) + hours_remaining = int((assignment.due_date - now).total_seconds() // 3600) + if hours_remaining <= preferences.reminder_hours_before: + subject = f"Final Reminder: Assignment Due Soon: {assignment.title}" + html_message = render_to_string( + "emails/assignment_reminder.html", + { + "student": student, + "assignment": assignment, + "course": course, + "due_date": assignment.due_date, + "hours_remaining": hours_remaining, + }, + ) + if preferences.in_app_notifications: + send_notification( + student, + { + "title": subject, + "message": f"Final reminder: Your assignment '{assignment.title}' \ + is due in {hours_remaining} hours.", + "notification_type": "warning", + }, + ) + if preferences.email_notifications: + send_mail( + subject, + "", # Plain text version + settings.DEFAULT_FROM_EMAIL, + [student.email], + html_message=html_message, + ) + assignment.final_reminder_sent = True + assignment.save() diff --git a/web/templates/account/notification_preferences.html b/web/templates/account/notification_preferences.html new file mode 100644 index 000000000..4147fa98d --- /dev/null +++ b/web/templates/account/notification_preferences.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% load static %} + +{% block title %} + Notification Preferences +{% endblock title %} +{% block content %} +
+
+

Notification Preferences

+ {% if messages %} +
+ {% for message in messages %} +
+
+ + {{ message }} +
+
+ {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} +
+
+ + {{ form.reminder_days_before }} + {% if form.reminder_days_before.help_text %} +

{{ form.reminder_days_before.help_text }}

+ {% endif %} +
+
+ + {{ form.reminder_hours_before }} + {% if form.reminder_hours_before.help_text %} +

{{ form.reminder_hours_before.help_text }}

+ {% endif %} +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+{% endblock content %} diff --git a/web/templates/base.html b/web/templates/base.html index 799e1892e..76f97ebe5 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -296,6 +296,11 @@ class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"> Profile + + + Notification Preferences + Teacher Dashboard diff --git a/web/templates/emails/assignment_reminder.html b/web/templates/emails/assignment_reminder.html new file mode 100644 index 000000000..41c96eaaa --- /dev/null +++ b/web/templates/emails/assignment_reminder.html @@ -0,0 +1,31 @@ +{% extends "emails/base_email.html" %} + +{% block content %} +

Assignment Reminder

+

Hello {{ student.first_name }},

+

+ This is a reminder that your assignment {{ assignment.title }} for the course {{ course.title }} is due + {% if days_remaining is defined %} + in {{ days_remaining }} day(s). + {% elif hours_remaining is defined %} + in {{ hours_remaining }} hour(s). + {% endif %} +

+
+

+ Due Date: {{ due_date|date:"F j, Y, g:i a" }} +

+
+

Please ensure you complete and submit your assignment on time.

+
+ View Assignment +
+{% endblock %} diff --git a/web/templates/emails/base_email.html b/web/templates/emails/base_email.html new file mode 100644 index 000000000..130d75361 --- /dev/null +++ b/web/templates/emails/base_email.html @@ -0,0 +1,10 @@ + + + + + Email Notification + + + {% block content %}{% endblock %} + + diff --git a/web/templates/profile.html b/web/templates/profile.html index 166160941..795d43001 100644 --- a/web/templates/profile.html +++ b/web/templates/profile.html @@ -190,6 +190,13 @@

Profile Visibility

+ +
+ + Edit Notification Preferences + +
{% if user.profile.is_teacher %} diff --git a/web/tests/test_send_assignment_reminders.py b/web/tests/test_send_assignment_reminders.py new file mode 100644 index 000000000..b640f1e99 --- /dev/null +++ b/web/tests/test_send_assignment_reminders.py @@ -0,0 +1,110 @@ +from datetime import timedelta +from decimal import Decimal +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone + +from web.models import Course, CourseMaterial, Enrollment, NotificationPreference, Subject +from web.notifications import send_assignment_reminders + + +class SendAssignmentRemindersTest(TestCase): + def setUp(self): + # Create teacher and student accounts. + self.teacher = User.objects.create_user(username="teacher", email="teacher@example.com", password="pass") + self.student = User.objects.create_user(username="testuser", email="testuser@example.com", password="pass") + # Create a subject. + self.subject = Subject.objects.create(name="Test Subject") + # Create a course with required fields. + self.course = Course.objects.create( + title="Test Course", + slug="test-course", + price=Decimal("10.00"), + max_students=100, + teacher=self.teacher, + subject=self.subject, + ) + # Enroll the student with approved status. + Enrollment.objects.create(course=self.course, student=self.student, status="approved") + # Create notification preferences for the student. + NotificationPreference.objects.get_or_create( + user=self.student, + defaults={ + "reminder_days_before": 3, + "reminder_hours_before": 24, + "email_notifications": True, + "in_app_notifications": True, + }, + ) + + @patch("web.notifications.send_mail") + @patch("web.notifications.send_notification") + def test_early_reminder(self, mock_send_notification, mock_send_mail): + """ + Test that an assignment due within the early window triggers early reminder notifications. + """ + assignment = CourseMaterial.objects.create( + course=self.course, + title="Early Reminder Assignment", + material_type="assignment", + due_date=timezone.now() + timedelta(days=2), # Within early window + external_url="http://example.com/assignment", + reminder_sent=False, + final_reminder_sent=False, + ) + send_assignment_reminders() + assignment.refresh_from_db() + self.assertTrue(assignment.reminder_sent, "Early reminder should be marked as sent.") + self.assertTrue(mock_send_notification.called, "In-app notification should be sent for early reminder.") + self.assertTrue(mock_send_mail.called, "Email notification should be sent for early reminder.") + + @patch("web.notifications.send_mail") + @patch("web.notifications.send_notification") + def test_final_reminder(self, mock_send_notification, mock_send_mail): + """ + Test that an assignment due within the final window triggers final reminder notifications. + """ + assignment = CourseMaterial.objects.create( + course=self.course, + title="Final Reminder Assignment", + material_type="assignment", + due_date=timezone.now() + timedelta(hours=20), # Within final window (20 hours from now) + external_url="http://example.com/assignment", + reminder_sent=True, # Early reminder already sent. + final_reminder_sent=False, # Final reminder not yet sent. + ) + mock_send_notification.reset_mock() + mock_send_mail.reset_mock() + send_assignment_reminders() + assignment.refresh_from_db() + self.assertTrue(assignment.final_reminder_sent, "Final reminder should be marked as sent.") + self.assertTrue(mock_send_notification.called, "In-app notification should be sent for final reminder.") + self.assertTrue(mock_send_mail.called, "Email notification should be sent for final reminder.") + + @patch("web.notifications.send_mail") + @patch("web.notifications.send_notification") + def test_no_reminder(self, mock_send_notification, mock_send_mail): + """ + Test that an assignment outside the reminder window does not trigger notifications. + """ + assignment = CourseMaterial.objects.create( + course=self.course, + title="No Reminder Assignment", + material_type="assignment", + due_date=timezone.now() + timedelta(days=5), # Outside early window + external_url="http://example.com/assignment", + reminder_sent=False, + final_reminder_sent=False, + ) + send_assignment_reminders() + assignment.refresh_from_db() + self.assertFalse( + assignment.reminder_sent, "Early reminder should not be marked for assignments outside the window." + ) + self.assertFalse( + assignment.final_reminder_sent, "Final reminder should not be marked for assignments outside the window." + ) + self.assertFalse(mock_send_notification.called, "No in-app notification should be sent.") + self.assertFalse(mock_send_mail.called, "No email notification should be sent.") diff --git a/web/urls.py b/web/urls.py index 702f33c96..6db0b99fe 100644 --- a/web/urls.py +++ b/web/urls.py @@ -13,6 +13,7 @@ GradeableLinkListView, add_goods_to_cart, grade_link, + notification_preferences, sales_analytics, sales_data, streak_detail, @@ -59,6 +60,7 @@ # Authentication URLs path("accounts/signup/", views.signup_view, name="account_signup"), # Our custom signup view path("accounts/", include("allauth.urls")), + path("account/notification-preferences/", notification_preferences, name="notification_preferences"), path("profile/", views.profile, name="profile"), path("accounts/profile/", views.profile, name="accounts_profile"), # Dashboard URLs diff --git a/web/views.py b/web/views.py index 4fc9edc25..f5fc7774e 100644 --- a/web/views.py +++ b/web/views.py @@ -69,6 +69,7 @@ LinkGradeForm, MemeForm, MessageTeacherForm, + NotificationPreferencesForm, ProfileUpdateForm, ProgressTrackerForm, ReviewForm, @@ -112,6 +113,7 @@ LearningStreak, LinkGrade, Meme, + NotificationPreference, Order, OrderItem, PeerConnection, @@ -4695,3 +4697,26 @@ def run_create_test_data(request): messages.error(request, f"Error creating test data: {str(e)}") return redirect("index") + + +@login_required +def notification_preferences(request): + """ + Display and update the notification preferences for the logged-in user. + """ + # Get (or create) the user's notification preferences. + preference, created = NotificationPreference.objects.get_or_create(user=request.user) + + if request.method == "POST": + form = NotificationPreferencesForm(request.POST, instance=preference) + if form.is_valid(): + form.save() + messages.success(request, "Your notification preferences have been updated.") + # Redirect to the profile page after saving + return redirect("profile") + else: + messages.error(request, "There was an error updating your preferences.") + else: + form = NotificationPreferencesForm(instance=preference) + + return render(request, "account/notification_preferences.html", {"form": form}) From 90660c43ed407b4a813f87b1a70f826d3026a668 Mon Sep 17 00:00:00 2001 From: Anubhav Tandon <149326609+10done@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:36:00 +0530 Subject: [PATCH 2/4] Update web/notifications.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- web/notifications.py | 70 +++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/web/notifications.py b/web/notifications.py index 25f2db1e4..6a8b4087a 100644 --- a/web/notifications.py +++ b/web/notifications.py @@ -250,48 +250,52 @@ def send_assignment_reminders(): early_window = now + timedelta(days=3) # Early reminders: assignments due in next 3 days. final_window = now + timedelta(hours=24) # Final reminders: assignments due in next 24 hours. +from django.db import transaction + # Process Early Reminders early_assignments = CourseMaterial.objects.filter( material_type="assignment", due_date__gt=now, due_date__lte=early_window, reminder_sent=False ) for assignment in early_assignments: - course = assignment.course - enrollments = course.enrollments.filter(status="approved") - for enrollment in enrollments: - student = enrollment.student - preferences, _ = NotificationPreference.objects.get_or_create(user=student) - days_before_deadline = (assignment.due_date - now).days - if days_before_deadline <= preferences.reminder_days_before: - subject = f"Upcoming Assignment Deadline: {assignment.title}" - html_message = render_to_string( - "emails/assignment_reminder.html", - { - "student": student, - "assignment": assignment, - "course": course, - "due_date": assignment.due_date, - "days_remaining": days_before_deadline, - }, - ) - if preferences.in_app_notifications: - send_notification( - student, + with transaction.atomic(): + course = assignment.course + enrollments = course.enrollments.filter(status="approved") + for enrollment in enrollments: + student = enrollment.student + preferences, _ = NotificationPreference.objects.get_or_create(user=student) + days_before_deadline = (assignment.due_date - now).days + if days_before_deadline <= preferences.reminder_days_before: + subject = f"Upcoming Assignment Deadline: {assignment.title}" + html_message = render_to_string( + "emails/assignment_reminder.html", { - "title": subject, - "message": f"Your assignment '{assignment.title}' is due in {days_before_deadline} days.", - "notification_type": "warning", + "student": student, + "assignment": assignment, + "course": course, + "due_date": assignment.due_date, + "days_remaining": days_before_deadline, }, ) - if preferences.email_notifications: - send_mail( - subject, - "", # Plain text version - settings.DEFAULT_FROM_EMAIL, - [student.email], - html_message=html_message, - ) - assignment.reminder_sent = True + if preferences.in_app_notifications: + send_notification( + student, + { + "title": subject, + "message": f"Your assignment '{assignment.title}' is due in {days_before_deadline} days.", + "notification_type": "warning", + }, + ) + if preferences.email_notifications: + send_mail( + subject, + "", # Plain text version + settings.DEFAULT_FROM_EMAIL, + [student.email], + html_message=html_message, + ) + assignment.reminder_sent = True + assignment.save() assignment.save() # Process Final Reminders From 3d51440af50ad5936615e6ebeb1ee3bde4038193 Mon Sep 17 00:00:00 2001 From: 10done Date: Sun, 23 Mar 2025 17:50:40 +0530 Subject: [PATCH 3/4] updates --- web/notifications.py | 44 +++++++++++--------------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/web/notifications.py b/web/notifications.py index 6a8b4087a..f0258106d 100644 --- a/web/notifications.py +++ b/web/notifications.py @@ -3,6 +3,7 @@ from django.conf import settings from django.core.mail import send_mail +from django.db import transaction # Moved here from django.template.loader import render_to_string from django.utils import timezone @@ -23,10 +24,7 @@ def send_notification(user, notification_data): subject = notification_data["title"] html_message = render_to_string( "emails/notification.html", - { - "user": user, - "notification": notification, - }, + {"user": user, "notification": notification}, ) send_mail( subject, @@ -51,11 +49,7 @@ def send_enrollment_confirmation(enrollment): subject = f"Welcome to {enrollment.course.title}!" html_message = render_to_string( "emails/enrollment_confirmation.html", - { - "student": enrollment.student, - "course": enrollment.course, - "teacher": enrollment.course.teacher, - }, + {"student": enrollment.student, "course": enrollment.course, "teacher": enrollment.course.teacher}, ) send_mail( subject, @@ -71,10 +65,7 @@ def notify_teacher_new_enrollment(enrollment): subject = f"New Student Enrolled in {enrollment.course.title}" html_message = render_to_string( "emails/new_enrollment_notification.html", - { - "student": enrollment.student, - "course": enrollment.course, - }, + {"student": enrollment.student, "course": enrollment.course}, ) send_mail( subject, @@ -92,11 +83,7 @@ def notify_session_reminder(session): for enrollment in enrollments: html_message = render_to_string( "emails/session_reminder.html", - { - "student": enrollment.student, - "session": session, - "course": session.course, - }, + {"student": enrollment.student, "session": session, "course": session.course}, ) send_mail( subject, @@ -114,11 +101,7 @@ def notify_course_update(course, update_message): for enrollment in enrollments: html_message = render_to_string( "emails/course_update.html", - { - "student": enrollment.student, - "course": course, - "update_message": update_message, - }, + {"student": enrollment.student, "course": course, "update_message": update_message}, ) send_mail( subject, @@ -133,10 +116,7 @@ def send_upcoming_session_reminders(): """Send reminders for sessions happening in the next 24 hours.""" now = timezone.now() reminder_window = now + timedelta(hours=24) - upcoming_sessions = Session.objects.filter( - start_time__gt=now, - start_time__lte=reminder_window, - ) + upcoming_sessions = Session.objects.filter(start_time__gt=now, start_time__lte=reminder_window) for session in upcoming_sessions: notify_session_reminder(session) @@ -250,8 +230,6 @@ def send_assignment_reminders(): early_window = now + timedelta(days=3) # Early reminders: assignments due in next 3 days. final_window = now + timedelta(hours=24) # Final reminders: assignments due in next 24 hours. -from django.db import transaction - # Process Early Reminders early_assignments = CourseMaterial.objects.filter( material_type="assignment", due_date__gt=now, due_date__lte=early_window, reminder_sent=False @@ -282,7 +260,8 @@ def send_assignment_reminders(): student, { "title": subject, - "message": f"Your assignment '{assignment.title}' is due in {days_before_deadline} days.", + "message": f"Your assignment '{assignment.title}'\ + is due in {days_before_deadline} days.", "notification_type": "warning", }, ) @@ -296,7 +275,6 @@ def send_assignment_reminders(): ) assignment.reminder_sent = True assignment.save() - assignment.save() # Process Final Reminders final_assignments = CourseMaterial.objects.filter( @@ -327,8 +305,8 @@ def send_assignment_reminders(): student, { "title": subject, - "message": f"Final reminder: Your assignment '{assignment.title}' \ - is due in {hours_remaining} hours.", + "message": f"Final reminder: Your assignment\ + '{assignment.title}' is due in {hours_remaining} hours.", "notification_type": "warning", }, ) From 57d4637729c61887540070aff670d13aa6b949a6 Mon Sep 17 00:00:00 2001 From: 10done Date: Mon, 24 Mar 2025 12:38:58 +0530 Subject: [PATCH 4/4] StudyGroup --- web/context_processors.py | 7 ++ web/forms.py | 8 ++ ...ter_studygroup_members_studygroupinvite.py | 64 ++++++++++ web/models.py | 46 +++++++- web/settings.py | 1 + web/templates/base.html | 10 ++ web/templates/dashboard/student.html | 14 ++- web/templates/web/study/create_group.html | 37 ++++++ web/templates/web/study/group_detail.html | 27 ++++- web/templates/web/study/invitations.html | 46 ++++++++ web/tests/test_study_groups.py | 57 +++++++++ web/urls.py | 4 + web/views.py | 109 ++++++++++++++++++ 13 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 web/migrations/0043_alter_studygroup_members_studygroupinvite.py create mode 100644 web/templates/web/study/create_group.html create mode 100644 web/templates/web/study/invitations.html create mode 100644 web/tests/test_study_groups.py diff --git a/web/context_processors.py b/web/context_processors.py index 91399f9cc..045963981 100644 --- a/web/context_processors.py +++ b/web/context_processors.py @@ -14,3 +14,10 @@ def last_modified(request): last_modified_time = "Unknown" return {"last_modified": last_modified_time} + + +def invitation_notifications(request): + if request.user.is_authenticated: + pending_invites = request.user.received_group_invites.filter(status="pending").count() + return {"pending_invites_count": pending_invites} + return {} diff --git a/web/forms.py b/web/forms.py index 82896b278..5e1e85c1e 100644 --- a/web/forms.py +++ b/web/forms.py @@ -35,6 +35,7 @@ Review, Session, Storefront, + StudyGroup, Subject, SuccessStory, TeamGoal, @@ -1622,3 +1623,10 @@ class Meta: "reminder_days_before": forms.NumberInput(attrs={"min": 1, "max": 14}), "reminder_hours_before": forms.NumberInput(attrs={"min": 1, "max": 72}), } + + +class StudyGroupForm(forms.ModelForm): + class Meta: + model = StudyGroup + # You might exclude fields that are set automatically. + fields = ["name", "description", "course", "max_members", "is_private"] diff --git a/web/migrations/0043_alter_studygroup_members_studygroupinvite.py b/web/migrations/0043_alter_studygroup_members_studygroupinvite.py new file mode 100644 index 000000000..ebfb10a1c --- /dev/null +++ b/web/migrations/0043_alter_studygroup_members_studygroupinvite.py @@ -0,0 +1,64 @@ +# Generated by Django 5.1.6 on 2025-03-24 06:45 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0042_profile_avatar_accessories_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="studygroup", + name="members", + field=models.ManyToManyField(blank=True, related_name="joined_groups", to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name="StudyGroupInvite", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("responded_at", models.DateTimeField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[("pending", "Pending"), ("accepted", "Accepted"), ("declined", "Declined")], + default="pending", + max_length=20, + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="invites", to="web.studygroup" + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_group_invites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "sender", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sent_group_invites", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("group", "recipient")}, + }, + ), + ] diff --git a/web/models.py b/web/models.py index e5b216bff..6b58e3c96 100644 --- a/web/models.py +++ b/web/models.py @@ -801,7 +801,7 @@ class StudyGroup(models.Model): description = models.TextField() course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="study_groups") creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="created_groups") - members = models.ManyToManyField(User, related_name="joined_groups") + members = models.ManyToManyField(User, related_name="joined_groups", blank=True) max_members = models.IntegerField(default=10) is_private = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) @@ -810,6 +810,50 @@ class StudyGroup(models.Model): def __str__(self): return self.name + def can_add_memeber(self): + return self.members.count() < self.max_members + + def add_member(self, user): + if self.can_add_memeber(): + self.members.add(user) + return True + return False + + def is_full(self): + return self.members.count() >= self.max_members + + +class StudyGroupInvite(models.Model): + """Invitations to join study groups.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + group = models.ForeignKey(StudyGroup, on_delete=models.CASCADE, related_name="invites") + sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sent_group_invites") + recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name="received_group_invites") + created_at = models.DateTimeField(auto_now_add=True) + responded_at = models.DateTimeField(null=True, blank=True) + STATUS_CHOICES = [("pending", "Pending"), ("accepted", "Accepted"), ("declined", "Declined")] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + + class Meta: + unique_together = ["group", "recipient"] + + def __str__(self): + return f"Invitation to {self.group.name} for {self.recipient.username}" + + def accept(self): + """Accept the invitation and add the recipient to the study group.""" + self.status = "accepted" + self.responded_at = timezone.now() + self.save() + self.group.add_member(self.recipient) + + def decline(self): + """Decline the invitation.""" + self.status = "declined" + self.responded_at = timezone.now() + self.save() + @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): diff --git a/web/settings.py b/web/settings.py index f75d7bc3e..7110b3913 100644 --- a/web/settings.py +++ b/web/settings.py @@ -125,6 +125,7 @@ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "web.context_processors.last_modified", + "web.context_processors.invitation_notifications", ], }, }, diff --git a/web/templates/base.html b/web/templates/base.html index eaa8a737d..9ea8ad184 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -341,6 +341,16 @@ {% endif %} + + + + {% if pending_invites_count %} + + {{ pending_invites_count }} + + {% endif %} +
- +

Your Learning Streak

{% if streak %} @@ -139,7 +146,6 @@

Your Learni View Detailed Streak Info

-

Your Achievements

{% if achievements %} @@ -167,7 +173,6 @@

{{ achievement.title }}

You haven't earned any achievements yet. Keep learning!

{% endif %}
-

Your Certificates

@@ -186,7 +191,6 @@

Your Certif {% else %}

{% endif %} - {% for enrollment in enrollments %} {% if enrollment.status|lower == "completed" %}
diff --git a/web/templates/web/study/create_group.html b/web/templates/web/study/create_group.html new file mode 100644 index 000000000..6fe66612e --- /dev/null +++ b/web/templates/web/study/create_group.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% load static %} + +{% block title %} + Create Study Group +{% endblock title %} +{% block content %} +
+
+
+

Create a Study Group

+

Fill in the details below to get started.

+
+ + +
+ {% csrf_token %} +
{{ form.as_p }}
+
+ +
+
+
+
+{% endblock content %} diff --git a/web/templates/web/study/group_detail.html b/web/templates/web/study/group_detail.html index 4432e1f6f..96942aabd 100644 --- a/web/templates/web/study/group_detail.html +++ b/web/templates/web/study/group_detail.html @@ -129,15 +129,15 @@

{% for member in group.members.all %}
- {% if member.user.profile.avatar %} - {{ member.user.get_full_name|default:member.user.username }} {% else %} {{ member.user.get_full_name|default:member.user.username }} @@ -158,6 +158,25 @@

+ {% if request.user in group.members.all %} +
+
+

Invite Friends

+
+ {% csrf_token %} +
+ + +
+
+
+
+ {% endif %}

{% endblock content %} diff --git a/web/templates/web/study/invitations.html b/web/templates/web/study/invitations.html new file mode 100644 index 000000000..148a76009 --- /dev/null +++ b/web/templates/web/study/invitations.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %} + Study Group Invitations +{% endblock title %} +{% block content %} +
+
+

Study Group Invitations

+

Manage your study group invitations

+
+ {% if invitations %} + {% for invite in invitations %} +
+
+
+

{{ invite.group.name }}

+

{{ invite.group.description }}

+

Invited by {{ invite.sender.username }} – {{ invite.created_at|timesince }} ago

+
+
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+
+
+ {% endfor %} + {% else %} +

You don't have any pending invitations.

+ {% endif %} +
+
+
+{% endblock content %} diff --git a/web/tests/test_study_groups.py b/web/tests/test_study_groups.py new file mode 100644 index 000000000..3606089fb --- /dev/null +++ b/web/tests/test_study_groups.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from web.models import Course, StudyGroup, Subject + + +class StudyGroupInviteTests(TestCase): + def setUp(self): + # Create two users: user1 will be the teacher/creator, user2 will be invited. + self.user1 = User.objects.create_user(username="user1", email="user1@example.com", password="pass") + self.user2 = User.objects.create_user(username="user2", email="user2@example.com", password="pass") + + # Create a Subject instance (required by Course). + self.subject = Subject.objects.create(name="Test Subject", slug="test-subject") + + # Create a Course instance with all required fields. + self.course = Course.objects.create( + title="Test Course", + slug="test-course", + teacher=self.user1, # Must supply a teacher + description="Test course description", + learning_objectives="Test learning objectives", + prerequisites="", # Optional field + price=10.00, + allow_individual_sessions=False, + invite_only=False, + status="published", + max_students=50, # Provide a valid number + subject=self.subject, + level="beginner", + tags="", + is_featured=False, + ) + + # Create a StudyGroup using the above Course. + self.group = StudyGroup.objects.create( + name="Test Group", + description="Test group description", + course=self.course, + creator=self.user1, + max_members=2, # Limit group size for testing purposes + ) + self.group.members.add(self.user1) + self.client.login(username="user1", password="pass") + + @patch("web.views.Notification.objects.create") + def test_invite_user_already_member(self, mock_notification_create): + # Add user2 as a member first. + self.group.members.add(self.user2) + # Use follow=True so that we get the final response after redirect. + response = self.client.post( + reverse("invite_to_study_group", args=[self.group.id]), {"email_or_username": "user2"}, follow=True + ) + self.assertContains(response, "is already a member of this group.") diff --git a/web/urls.py b/web/urls.py index ccd3ee86c..aeb1cbe07 100644 --- a/web/urls.py +++ b/web/urls.py @@ -190,6 +190,10 @@ path("groups//", views.study_group_detail, name="study_group_detail"), path("sessions//", views.session_detail, name="session_detail"), path("sitemap/", views.sitemap, name="sitemap"), + path("groups//invite/", views.invite_to_study_group, name="invite_to_study_group"), + path("invitations/", views.user_invitations, name="user_invitations"), + path("invitations//respond/", views.respond_to_invitation, name="respond_to_invitation"), + path("groups/create/", views.create_study_group, name="create_study_group"), # Cart URLs path("cart/", views.cart_view, name="cart_view"), path("cart/add/course//", views.add_course_to_cart, name="add_course_to_cart"), diff --git a/web/views.py b/web/views.py index b2e3ad5cc..6d7e79936 100644 --- a/web/views.py +++ b/web/views.py @@ -79,6 +79,7 @@ SessionForm, StorefrontForm, StudentEnrollmentForm, + StudyGroupForm, SuccessStoryForm, TeacherSignupForm, TeachForm, @@ -118,6 +119,7 @@ LinkGrade, Meme, NoteHistory, + Notification, NotificationPreference, Order, OrderItem, @@ -132,6 +134,7 @@ SessionEnrollment, Storefront, StudyGroup, + StudyGroupInvite, Subject, SuccessStory, TeamGoal, @@ -5129,3 +5132,109 @@ def notification_preferences(request): form = NotificationPreferencesForm(instance=preference) return render(request, "account/notification_preferences.html", {"form": form}) + + +@login_required +def invite_to_study_group(request, group_id): + """Invite a user to a study group.""" + group = get_object_or_404(StudyGroup, id=group_id) + + # Only allow invitations from current group members. + if request.user not in group.members.all(): + messages.error(request, "You must be a member of the group to invite others.") + return redirect("study_group_detail", group_id=group.id) + + if request.method == "POST": + email_or_username = request.POST.get("email_or_username") + # Search by email or username. + recipient = User.objects.filter(Q(email=email_or_username) | Q(username=email_or_username)).first() + if not recipient: + messages.error(request, f"No user found with email or username: {email_or_username}") + return redirect("study_group_detail", group_id=group.id) + + # Prevent duplicate invitations or inviting existing members. + if recipient in group.members.all(): + messages.warning(request, f"{recipient.username} is already a member of this group.") + return redirect("study_group_detail", group_id=group.id) + + if StudyGroupInvite.objects.filter(group=group, recipient=recipient, status="pending").exists(): + messages.warning(request, f"An invitation has already been sent to {recipient.username}.") + return redirect("study_group_detail", group_id=group.id) + + if group.is_full(): + messages.error(request, "The study group is full. No new members can be added.") + return redirect("study_group_detail", group_id=group.id) + + # Create a notification for the recipient. + notification_url = request.build_absolute_uri(reverse("user_invitations")) + notification_text = ( + f"{request.user.username} has invited you to join the study group: {group.name}. " + f"View invitations here: {notification_url}" + ) + Notification.objects.create( + user=recipient, title="Study Group Invitation", message=notification_text, notification_type="info" + ) + + messages.success(request, f"Invitation sent to {recipient.username}.") + return redirect("study_group_detail", group_id=group.id) + + return redirect("study_group_detail", group_id=group.id) + + +@login_required +def user_invitations(request): + """Display pending study group invitations for the user.""" + invitations = StudyGroupInvite.objects.filter(recipient=request.user, status="pending").select_related( + "group", "sender" + ) + return render(request, "web/study/invitations.html", {"invitations": invitations}) + + +@login_required +def respond_to_invitation(request, invite_id): + """Accept or decline a study group invitation.""" + invite = get_object_or_404(StudyGroupInvite, id=invite_id, recipient=request.user) + if request.method == "POST": + response = request.POST.get("response") + if response == "accept": + if invite.group.is_full(): + messages.error(request, "The study group is full. Cannot join.") + return redirect("user_invitations") + invite.accept() + study_group_url = request.build_absolute_uri(reverse("study_group_detail", args=[invite.group.id])) + notification_text = f"{request.user.username} has accepted your invitation to join {invite.group.name}.\ + View group details here: {study_group_url}" + Notification.objects.create( + user=invite.sender, title="Invitation Accepted", message=notification_text, notification_type="success" + ) + messages.success(request, f"You have joined {invite.group.name}.") + return redirect("user_invitations") + elif response == "decline": + invite.decline() + study_group_url = request.build_absolute_uri(reverse("study_group_detail", args=[invite.group.id])) + notification_text = f"{request.user.username} has declined your invitation to join {invite.group.name}.\ + View group details here: {study_group_url}" + Notification.objects.create( + user=invite.sender, title="Invitation Declined", message=notification_text, notification_type="warning" + ) + messages.info(request, f"You have declined the invitation to {invite.group.name}.") + return redirect("user_invitations") + + return redirect("user_invitations") + + +@login_required +def create_study_group(request): + if request.method == "POST": + form = StudyGroupForm(request.POST) + if form.is_valid(): + study_group = form.save(commit=False) + study_group.creator = request.user + study_group.save() + # Automatically add the creator as a member + study_group.members.add(request.user) + messages.success(request, "Study group created successfully!") + return redirect("study_group_detail", group_id=study_group.id) + else: + form = StudyGroupForm() + return render(request, "web/study/create_group.html", {"form": form})