Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 82 additions & 9 deletions tests/test_trackers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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"')
5 changes: 4 additions & 1 deletion web/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
29 changes: 29 additions & 0 deletions web/migrations/0064_add_course_bookmark.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
15 changes: 15 additions & 0 deletions web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
150 changes: 150 additions & 0 deletions web/templates/account/my_bookmarks.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
{% extends "base.html" %}
{% load static %}
{% block title %}My Bookmarks{% endblock %}
{% block content %}
<main class="flex-1 w-full max-w-[90rem] mx-auto mt-6 px-4 md:px-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">
<svg class="inline h-6 w-6 text-red-500 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>My Bookmarks
</h1>
<span class="text-gray-600 dark:text-gray-300" id="bookmark-count">{{ courses|length }} saved course{{ courses|length|pluralize }}</span>
</div>

{% if courses %}
<div class="md:w-3/4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
{% for course in courses %}
<div class="rounded-lg p-4 border border-gray-200 dark:border-gray-700" id="bookmark-card-{{ course.slug }}">
<div class="aspect-square w-full relative overflow-hidden rounded-lg mb-3">
{% if course.image %}
<a href="{% url 'course_detail' course.slug %}">
<img src="{{ course.image.url }}" alt="{{ course.title }}" class="w-full h-full object-cover" width="300" height="300" />
</a>
{% else %}
<a href="{% url 'course_detail' course.slug %}">
<img src="{% static 'images/default-course.jpg' %}" alt="{{ course.title }}" class="w-full h-full object-cover" width="300" height="300" />
</a>
{% endif %}
<button data-slug="{{ course.slug }}"
onclick="removeBookmark(this)"
class="absolute top-2 right-2 p-2 rounded-full transition duration-200 bg-red-100 dark:bg-red-900 text-red-500 shadow-md"
aria-label="Remove bookmark">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
<div class="mb-3">
<h3 class="text-lg font-semibold h-14 flex items-start">
<a href="{% url 'course_detail' course.slug %}" class="hover:text-orange-500 transition-colors duration-200 line-clamp-2">{{ course.title }}</a>
</h3>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-teal-600 dark:text-teal-400">{{ course.subject }}</span>
<span class="text-orange-600 dark:text-orange-500">
{% if course.price == 0 %}Free{% else %}${{ course.price }}{% endif %}
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-3 text-sm">
<div class="flex items-center text-gray-600 dark:text-gray-300">
<i class="fas fa-eye mr-2"></i>
<span>{{ course.web_requests.count|default:"0" }} views</span>
</div>
<div class="flex items-center text-gray-600 dark:text-gray-300">
<i class="fas fa-users mr-2"></i>
<span>{{ course.enrollments.count }}/{{ course.max_students }}</span>
</div>
<div class="flex items-center text-gray-600 dark:text-gray-300">
<i class="fas fa-calendar-alt mr-2"></i>
<span>{{ course.sessions.count }} sessions</span>
</div>
<div class="flex items-center text-gray-600 dark:text-gray-300">
<i class="fas fa-star text-yellow-400 mr-2"></i>
<span>{{ course.average_rating|floatformat:1|default:"N/A" }}</span>
</div>
</div>
<div class="mb-3 text-sm text-gray-600 dark:text-gray-300 min-h-[4rem]">
{% with first_session=course.sessions.first last_session=course.sessions.last %}
{% if first_session and last_session %}
<div class="flex items-center mb-1">
<i class="fas fa-calendar-day mr-2"></i>
<span>Starts: {{ first_session.start_time|date:"M j, Y g:i A e" }}</span>
</div>
<div class="flex items-center">
<i class="fas fa-calendar-check mr-2"></i>
<span>Ends: {{ last_session.end_time|date:"M j, Y g:i A e" }}</span>
</div>
{% else %}
<div class="flex items-center mb-1">
<i class="fas fa-calendar-day mr-2"></i>
<span>No sessions scheduled</span>
</div>
{% endif %}
{% endwith %}
</div>
<div class="flex items-center mb-3">
{% if course.teacher.profile.avatar %}
<img src="{{ course.teacher.profile.avatar.url }}" alt="{{ course.teacher.username }}" class="h-12 w-12 rounded-full mr-3" width="48" height="48" />
{% else %}
<img src="{% static 'images/default_teacher.png' %}" alt="{{ course.teacher.username }}" class="h-12 w-12 rounded-full mr-3" width="48" height="48" />
{% endif %}
<div>
<span class="text-sm font-medium">{{ course.teacher.username }}</span>
{% if course.teacher.profile.expertise %}
<p class="text-xs text-gray-600 dark:text-gray-400">{{ course.teacher.profile.expertise }}</p>
{% endif %}
</div>
</div>
<a href="{% url 'course_detail' course.slug %}" class="w-full bg-green-500 hover:bg-green-600 text-white font-semibold px-3 py-2 rounded-lg flex items-center justify-center">
View Course
</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-12 text-center">
<svg class="mx-auto h-16 w-16 text-gray-300 dark:text-gray-600 mb-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<h2 class="text-xl font-bold text-gray-800 dark:text-white mb-2">No bookmarks yet</h2>
<p class="text-gray-600 dark:text-gray-300 mb-4">Browse courses and click the heart icon to save them here.</p>
<a href="{% url 'course_search' %}" class="bg-teal-300 hover:bg-teal-400 text-white px-6 py-2 rounded-lg transition duration-200">
Browse Courses
</a>
</div>
{% endif %}
</main>
{% endblock content %}

{% block extra_js %}
<script>
function removeBookmark(btn) {
var slug = btn.getAttribute('data-slug');
fetch("{% url 'toggle_bookmark' 'PLACEHOLDER' %}".replace('PLACEHOLDER', slug), {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (!data.bookmarked) {
var card = document.getElementById('bookmark-card-' + slug);
if (card) card.remove();
var remaining = document.querySelectorAll('[id^="bookmark-card-"]').length;
var countEl = document.getElementById('bookmark-count');
if (countEl) {
countEl.textContent = remaining + ' saved course' + (remaining !== 1 ? 's' : '');
}
if (remaining === 0) {
location.reload();
}
}
});
}
</script>
{% endblock extra_js %}
9 changes: 9 additions & 0 deletions web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<i class="fas fa-chart-line mr-2"></i> Progress Chart
</a>
<a href="{% url 'my_bookmarks' %}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fas fa-heart mr-2"></i> My Bookmarks
</a>
<form method="post" action="{% url 'account_logout' %}" class="block">
{% csrf_token %}
<button type="submit"
Expand Down Expand Up @@ -820,6 +824,11 @@
<span>System Dashboard</span>
</a>
{% endif %}
<a href="{% url 'my_bookmarks' %}"
class="flex items-center py-2 px-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md">
<i class="fas fa-heart mr-2 text-teal-500"></i>
<span>My Bookmarks</span>
</a>
<form method="post" action="{% url 'account_logout' %}" class="block mt-2">
{% csrf_token %}
<button type="submit"
Expand Down
Loading