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.
+
+
+
+
+
+
+{% 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 %}
-

{% else %}

@@ -158,6 +158,25 @@
+ {% if request.user in group.members.all %}
+
+ {% 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
+
+
+
+
+
+
+
+ {% 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})