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})