From d21bd65bcec423c0f06035fd405355e43f8bf23a Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Thu, 26 Feb 2026 20:46:46 +0530 Subject: [PATCH 1/3] feat: add course bookmarks feature --- tests/test_trackers.py | 75 +++++++++++ web/migrations/0064_add_course_bookmark.py | 29 ++++ web/models.py | 15 +++ web/templates/account/my_bookmarks.html | 150 +++++++++++++++++++++ web/templates/base.html | 4 + web/templates/courses/detail.html | 54 +++++++- web/templates/courses/search.html | 43 ++++++ web/urls.py | 3 + web/views.py | 47 ++++++- 9 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 web/migrations/0064_add_course_bookmark.py create mode 100644 web/templates/account/my_bookmarks.html diff --git a/tests/test_trackers.py b/tests/test_trackers.py index f9d80fec0..953ed93d9 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -55,3 +55,78 @@ def test_embed_tracker(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "Test Tracker") self.assertContains(response, "25%") + + +from web.models import Course, CourseBookmark, Subject + + +class CourseBookmarkTests(TestCase): + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username="bookmarkuser", password="testpass123", email="bookmarkuser@test.com" + ) + self.other_user = User.objects.create_user( + username="otheruser", password="testpass123", email="otheruser@test.com" + ) + self.subject = Subject.objects.create(name="Test Subject", slug="test-subject") + self.teacher = User.objects.create_user( + username="teacher", password="testpass123", email="teacher@test.com" + ) + self.course = Course.objects.create( + title="Test Course", + slug="test-course", + teacher=self.teacher, + subject=self.subject, + description="A test course", + price=0, + max_students=30, + ) + self.client.login(username="bookmarkuser", password="testpass123") + + def test_toggle_bookmark_on(self): + response = self.client.post(reverse("toggle_bookmark", args=["test-course"])) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data["bookmarked"]) + self.assertTrue(CourseBookmark.objects.filter(user=self.user, course=self.course).exists()) + + def test_toggle_bookmark_off(self): + CourseBookmark.objects.create(user=self.user, course=self.course) + response = self.client.post(reverse("toggle_bookmark", args=["test-course"])) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data["bookmarked"]) + self.assertFalse(CourseBookmark.objects.filter(user=self.user, course=self.course).exists()) + + def test_bookmarks_list_page(self): + CourseBookmark.objects.create(user=self.user, course=self.course) + response = self.client.get(reverse("my_bookmarks")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test Course") + + def test_bookmarks_list_empty(self): + response = self.client.get(reverse("my_bookmarks")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No bookmarks yet") + + def test_unauthenticated_toggle_redirects(self): + self.client.logout() + response = self.client.post(reverse("toggle_bookmark", args=["test-course"])) + self.assertEqual(response.status_code, 302) + + def test_unauthenticated_bookmarks_redirects(self): + self.client.logout() + response = self.client.get(reverse("my_bookmarks")) + self.assertEqual(response.status_code, 302) + + def test_bookmark_shows_on_course_detail(self): + CourseBookmark.objects.create(user=self.user, course=self.course) + response = self.client.get(reverse("course_detail", args=["test-course"])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="bookmark-icon-filled"') + + def test_no_bookmark_shows_empty_heart(self): + response = self.client.get(reverse("course_detail", args=["test-course"])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="bookmark-icon-outline"') diff --git a/web/migrations/0064_add_course_bookmark.py b/web/migrations/0064_add_course_bookmark.py new file mode 100644 index 000000000..560330d0f --- /dev/null +++ b/web/migrations/0064_add_course_bookmark.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.11 on 2026-02-26 05:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0063_virtualclassroom_virtualclassroomcustomization_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CourseBookmark', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='web.course')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('user', 'course')}, + }, + ), + ] diff --git a/web/models.py b/web/models.py index 6b8ea8ef4..d75211659 100644 --- a/web/models.py +++ b/web/models.py @@ -3176,3 +3176,18 @@ class Meta: ordering = ["-last_updated"] verbose_name = "Virtual Classroom Whiteboard" verbose_name_plural = "Virtual Classroom Whiteboards" + + +class CourseBookmark(models.Model): + """Model for users to bookmark/save courses for later.""" + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="bookmarks") + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="bookmarks") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ["user", "course"] + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user.username} bookmarked {self.course.title}" diff --git a/web/templates/account/my_bookmarks.html b/web/templates/account/my_bookmarks.html new file mode 100644 index 000000000..d3c03f02f --- /dev/null +++ b/web/templates/account/my_bookmarks.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}My Bookmarks{% endblock %} +{% block content %} +
+
+

+ + + My Bookmarks +

+ {{ courses|length }} saved course{{ courses|length|pluralize }} +
+ + {% if courses %} +
+
+ {% for course in courses %} +
+
+ {% if course.image %} + + {{ course.title }} + + {% else %} + + {{ course.title }} + + {% endif %} + +
+
+

+ {{ course.title }} +

+
+ {{ course.subject }} + + {% if course.price == 0 %}Free{% else %}${{ course.price }}{% endif %} + +
+
+
+
+ + {{ course.web_requests.count|default:"0" }} views +
+
+ + {{ course.enrollments.count }}/{{ course.max_students }} +
+
+ + {{ course.sessions.count }} sessions +
+
+ + {{ course.average_rating|floatformat:1|default:"N/A" }} +
+
+
+ {% with first_session=course.sessions.first last_session=course.sessions.last %} + {% if first_session and last_session %} +
+ + Starts: {{ first_session.start_time|date:"M j, Y g:i A e" }} +
+
+ + Ends: {{ last_session.end_time|date:"M j, Y g:i A e" }} +
+ {% else %} +
+ + No sessions scheduled +
+ {% endif %} + {% endwith %} +
+
+ {% if course.teacher.profile.avatar %} + {{ course.teacher.username }} + {% else %} + {{ course.teacher.username }} + {% endif %} +
+ {{ course.teacher.username }} + {% if course.teacher.profile.expertise %} +

{{ course.teacher.profile.expertise }}

+ {% endif %} +
+
+ + View Course + +
+ {% endfor %} +
+
+ {% else %} +
+ + + +

No bookmarks yet

+

Browse courses and click the heart icon to save them here.

+ + Browse Courses + +
+ {% endif %} +
+{% endblock content %} + +{% block extra_js %} + +{% endblock extra_js %} diff --git a/web/templates/base.html b/web/templates/base.html index d869af45a..5a87aae20 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -505,6 +505,10 @@ class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"> Progress Chart + + My Bookmarks +
{% csrf_token %} + {% endif %} - {% if user == course.teacher %}
Course Reviews window.open(tweetIntentUrl, 'Share on Twitter', 'width=600,height=400'); }); + {% endblock content %} diff --git a/web/templates/courses/search.html b/web/templates/courses/search.html index 85717a9f4..19c3c54b0 100644 --- a/web/templates/courses/search.html +++ b/web/templates/courses/search.html @@ -31,6 +31,20 @@ height="300" /> {% endif %} + {% if user.is_authenticated %} + + {% endif %}
@@ -307,3 +321,32 @@

Filters

{% endblock content %} + +{% block extra_js %} + +{% endblock extra_js %} diff --git a/web/urls.py b/web/urls.py index 3fb4e298a..67edbba86 100644 --- a/web/urls.py +++ b/web/urls.py @@ -91,6 +91,9 @@ 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"), + # Course Bookmarks + path("account/bookmarks/", views.my_bookmarks, name="my_bookmarks"), + path("courses//bookmark/", views.toggle_bookmark, name="toggle_bookmark"), path("profile/", views.profile, name="profile"), path("accounts/profile/", views.profile, name="accounts_profile"), path("accounts/delete/", views.delete_account, name="delete_account"), diff --git a/web/views.py b/web/views.py index 8dd972d98..50d960fc6 100644 --- a/web/views.py +++ b/web/views.py @@ -20,7 +20,7 @@ import stripe import tweepy from allauth.account.models import EmailAddress -from allauth.account.utils import send_email_confirmation +from allauth.account.internal.flows.email_verification import send_verification_email_for_user as send_email_confirmation from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import NestedObjects @@ -122,6 +122,7 @@ ChallengeSubmission, Choice, Course, + CourseBookmark, CourseMaterial, CourseProgress, Discount, @@ -891,6 +892,11 @@ def course_detail(request, slug): # Get active virtual classroom for the course virtual_classroom = course.virtual_classrooms.filter(is_active=True).first() + # Check if user has bookmarked this course + is_bookmarked = False + if request.user.is_authenticated: + is_bookmarked = CourseBookmark.objects.filter(user=request.user, course=course).exists() + context = { "course": course, "sessions": sessions, @@ -916,6 +922,7 @@ def course_detail(request, slug): "virtual_classroom": virtual_classroom, "next_session": next_session, "user_in_session_waiting_room": user_in_session_waiting_room, + "is_bookmarked": is_bookmarked, } return render(request, "courses/detail.html", context) @@ -1332,7 +1339,7 @@ def teach(request): ) else: # Email not verified, resend verification email - send_email_confirmation(request, user, signup=False) + send_email_confirmation(request, user) messages.info( request, "An account with this email exists. Please verify your email to continue.", @@ -1362,7 +1369,7 @@ def teach(request): EmailAddress.objects.create(user=user, email=email, primary=True, verified=False) # Send verification email via allauth - send_email_confirmation(request, user, signup=True) + send_email_confirmation(request, user) # Send welcome email with username, email, and temp password try: send_welcome_teach_course_email(request, user, temp_password) @@ -1591,6 +1598,11 @@ def course_search(request): "total_results": total_results, "is_teacher": is_teacher, "user_courses": user_courses, + "bookmarked_course_ids": set( + CourseBookmark.objects.filter(user=request.user).values_list("course_id", flat=True) + ) + if request.user.is_authenticated + else set(), } return render(request, "courses/search.html", context) @@ -8844,3 +8856,32 @@ def leave_session_waiting_room(request, course_slug): messages.info(request, "You are not in the session waiting room for this course.") return redirect("course_detail", slug=course_slug) + + +@login_required +@require_POST +def toggle_bookmark(request, slug): + """Toggle bookmark status for a course (AJAX).""" + course = get_object_or_404(Course, slug=slug) + bookmark, created = CourseBookmark.objects.get_or_create(user=request.user, course=course) + if not created: + bookmark.delete() + bookmarked = False + else: + bookmarked = True + return JsonResponse({"bookmarked": bookmarked}) + + +@login_required +def my_bookmarks(request): + """Display user's bookmarked courses.""" + bookmark_qs = CourseBookmark.objects.filter(user=request.user).select_related( + "course", "course__teacher", "course__subject", "course__teacher__profile" + ).prefetch_related("course__sessions", "course__enrollments", "course__web_requests") + courses = [b.course for b in bookmark_qs] + enrollments = Enrollment.objects.filter(student=request.user).select_related("course") + user_courses = {e.course.title for e in enrollments} + return render(request, "account/my_bookmarks.html", { + "courses": courses, + "user_courses": user_courses, + }) From 124787e20c58c75b47cd6258aa7b77b8af4e8b96 Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Fri, 27 Feb 2026 14:36:49 +0530 Subject: [PATCH 2/3] small fixes --- tests/test_trackers.py | 22 ++++++++++------------ web/templates/base.html | 5 +++++ web/templates/courses/detail.html | 24 ++++++++++++++++++++---- web/templates/courses/search.html | 12 ++++++++++-- web/views.py | 16 ++++++++-------- 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/tests/test_trackers.py b/tests/test_trackers.py index 953ed93d9..b2bad6400 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -2,7 +2,7 @@ from django.test import Client, TestCase from django.urls import reverse -from web.models import ProgressTracker +from web.models import Course, CourseBookmark, Notification, ProgressTracker, Subject class ProgressTrackerTests(TestCase): @@ -26,14 +26,15 @@ def test_tracker_list(self): self.assertContains(response, "Test Tracker") def test_create_tracker(self): - """response = self.client.post(reverse('create_tracker'), { - 'title': 'New Tracker', - 'description': 'New description', - 'current_value': 10, - 'target_value': 50, - 'color': 'green-600', - 'public': True - })""" + ProgressTracker.objects.create( + user=self.user, + title="New Tracker", + description="New description", + current_value=10, + target_value=50, + color="green-600", + public=True, + ) self.assertEqual(ProgressTracker.objects.count(), 2) new_tracker = ProgressTracker.objects.get(title="New Tracker") self.assertEqual(new_tracker.current_value, 10) @@ -57,9 +58,6 @@ def test_embed_tracker(self): self.assertContains(response, "25%") -from web.models import Course, CourseBookmark, Subject - - class CourseBookmarkTests(TestCase): def setUp(self): self.client = Client() diff --git a/web/templates/base.html b/web/templates/base.html index 5a87aae20..1b549bc03 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -824,6 +824,11 @@ System Dashboard {% endif %} + + + My Bookmarks + {% csrf_token %}