diff --git a/tests/test_trackers.py b/tests/test_trackers.py
index f9d80fec0..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)
@@ -55,3 +56,75 @@ def test_embed_tracker(self):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Test Tracker")
self.assertContains(response, "25%")
+
+
+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/forms.py b/web/forms.py
index 96489d524..e7ec26040 100644
--- a/web/forms.py
+++ b/web/forms.py
@@ -300,7 +300,10 @@ def save(self, request):
# Ensure email verification is sent
from allauth.account.models import EmailAddress
- email_address = EmailAddress.objects.get_for_user(user, user.email)
+ email_address, _created = EmailAddress.objects.get_or_create(
+ user=user, email=user.email,
+ defaults={"primary": True, "verified": False},
+ )
if not email_address.verified:
email_address.send_confirmation(request)
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 %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+
+
+
+
+ {{ 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 %}
+

+ {% else %}
+

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