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
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ GIT_REPO=your_git_repo_to_deploy
GIT_BRANCH=your_repo_branch
ALLOWED_HOSTS=example.com,www.example.com,127.0.0.1,localhost
CSRF_TRUSTED_ORIGINS=http://domain.com

# AI / OpenAI Configuration (for Learning Analytics & Study Planner)
OPENAI_API_KEY=your-openai-api-key-here
AI_MODEL=gpt-4o-mini
287 changes: 277 additions & 10 deletions tests/test_trackers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.crypto import get_random_string

from web.models import ProgressTracker

TEST_PASSWORD = get_random_string(12) # noqa: S105


class ProgressTrackerTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="testuser", password="testpassword")
self.client.login(username="testuser", password="testpassword")
self.user = User.objects.create_user(username="testuser", password=TEST_PASSWORD)
self.client.login(username="testuser", password=TEST_PASSWORD)
self.tracker = ProgressTracker.objects.create(
user=self.user,
title="Test Tracker",
Expand All @@ -26,14 +29,17 @@ 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
})"""
response = self.client.post(
reverse("create_tracker"),
{
"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 +61,264 @@ def test_embed_tracker(self):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Test Tracker")
self.assertContains(response, "25%")


class SubjectStrengthTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="analyticsuser", email="analytics@test.com", password=TEST_PASSWORD)
self.client.login(username="analyticsuser", password=TEST_PASSWORD)

from web.models import Subject, SubjectStrength

self.subject = Subject.objects.create(name="Mathematics", slug="mathematics")
self.strength = SubjectStrength.objects.create(
user=self.user,
subject=self.subject,
strength_score=50.0,
total_quizzes=0,
total_correct=0,
total_questions=0,
)

def test_initial_strength_score(self):
self.assertEqual(self.strength.strength_score, 50.0)

def test_update_from_quiz_first_attempt(self):
self.strength.update_from_quiz(8, 10)
self.strength.refresh_from_db()
self.assertEqual(self.strength.strength_score, 80.0)
self.assertEqual(self.strength.total_quizzes, 1)
self.assertEqual(self.strength.total_correct, 8)
self.assertEqual(self.strength.total_questions, 10)

def test_update_from_quiz_weighted_average(self):
self.strength.update_from_quiz(8, 10) # Sets to 80
self.strength.refresh_from_db()
self.strength.update_from_quiz(6, 10) # 70% of 80 + 30% of 60 = 56 + 18 = 74
self.strength.refresh_from_db()
self.assertAlmostEqual(self.strength.strength_score, 74.0, places=1)
self.assertEqual(self.strength.total_quizzes, 2)

def test_update_from_quiz_zero_max_score(self):
original_score = self.strength.strength_score
self.strength.update_from_quiz(0, 0)
self.strength.refresh_from_db()
self.assertEqual(self.strength.strength_score, original_score)
self.assertEqual(self.strength.total_quizzes, 0)

def test_unique_together_constraint(self):
from django.db import IntegrityError

from web.models import SubjectStrength

with self.assertRaises(IntegrityError):
SubjectStrength.objects.create(
user=self.user,
subject=self.subject,
strength_score=60.0,
)


class LearningAnalyticsTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="learner", email="learner@test.com", password=TEST_PASSWORD)
self.client.login(username="learner", password=TEST_PASSWORD)

def test_analytics_dashboard_loads(self):
response = self.client.get(reverse("learning_analytics"))
self.assertEqual(response.status_code, 200)

def test_analytics_dashboard_requires_login(self):
self.client.logout()
response = self.client.get(reverse("learning_analytics"))
self.assertEqual(response.status_code, 302)

def test_analytics_with_no_data(self):
from web.recommendations import get_learning_analytics

analytics = get_learning_analytics(self.user)
self.assertEqual(analytics["quiz_performance"]["total_attempts"], 0)
self.assertEqual(analytics["quiz_performance"]["avg_score"], 0)
self.assertEqual(analytics["attendance_rate"], 0)
self.assertEqual(analytics["learning_velocity"], 0.0)
self.assertEqual(analytics["total_study_hours"], 0.0)
self.assertIsInstance(analytics["recommendations"], list)
self.assertTrue(len(analytics["recommendations"]) > 0)

def test_analytics_context_keys(self):
from web.recommendations import get_learning_analytics

analytics = get_learning_analytics(self.user)
expected_keys = [
"strengths",
"weaknesses",
"quiz_performance",
"attendance_rate",
"attendance_detail",
"learning_velocity",
"predicted_completion",
"weekly_activity",
"risk_courses",
"recommendations",
"subject_breakdown",
"total_study_hours",
"ai_coaching",
"ai_study_tips",
"learning_style_hint",
"motivation_message",
]
for key in expected_keys:
self.assertIn(key, analytics)

def test_analytics_quiz_trend(self):
from web.recommendations import get_learning_analytics

analytics = get_learning_analytics(self.user)
self.assertIn("trend", analytics["quiz_performance"])
self.assertIn("recent_avg", analytics["quiz_performance"])
self.assertIn(analytics["quiz_performance"]["trend"], ["stable", "improving", "declining"])


class StudyPlanTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="planner", email="planner@test.com", password=TEST_PASSWORD)
self.client.login(username="planner", password=TEST_PASSWORD)

def test_study_plan_view_loads(self):
response = self.client.get(reverse("study_plan"))
self.assertEqual(response.status_code, 200)

def test_study_plan_requires_login(self):
self.client.logout()
response = self.client.get(reverse("study_plan"))
self.assertEqual(response.status_code, 302)

def test_generate_study_plan(self):
response = self.client.post(reverse("generate_study_plan"))
self.assertEqual(response.status_code, 302) # Redirects to study_plan

from web.models import StudyPlan

plan = StudyPlan.objects.filter(user=self.user, status="active").first()
self.assertIsNotNone(plan)

def test_regenerate_pauses_old_plan(self):
from web.models import StudyPlan

# Generate first plan
self.client.post(reverse("generate_study_plan"))
first_plan = StudyPlan.objects.filter(user=self.user, status="active").first()
self.assertIsNotNone(first_plan)

# Generate second plan
self.client.post(reverse("generate_study_plan"))
first_plan.refresh_from_db()
self.assertEqual(first_plan.status, "paused")

active_plans = StudyPlan.objects.filter(user=self.user, status="active")
self.assertEqual(active_plans.count(), 1)

def test_complete_study_plan_item(self):
from web.models import StudyPlan, StudyPlanItem

plan = StudyPlan.objects.create(user=self.user, title="Test Plan")
item = StudyPlanItem.objects.create(
plan=plan,
item_type="review",
title="Review Math",
priority="high",
order=1,
)
response = self.client.post(
reverse("complete_study_plan_item", args=[item.id]),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
item.refresh_from_db()
self.assertTrue(item.is_completed)
self.assertIsNotNone(item.completed_at)

def test_complete_item_wrong_user(self):
from web.models import StudyPlan, StudyPlanItem

other_user = User.objects.create_user(username="other", email="other@test.com", password=TEST_PASSWORD)
plan = StudyPlan.objects.create(user=other_user, title="Other Plan")
item = StudyPlanItem.objects.create(
plan=plan,
item_type="review",
title="Other Review",
priority="medium",
order=1,
)
response = self.client.post(
reverse("complete_study_plan_item", args=[item.id]),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)

def test_plan_completion_percentage(self):
from web.models import StudyPlan, StudyPlanItem

plan = StudyPlan.objects.create(user=self.user, title="Test Plan")
item1 = StudyPlanItem.objects.create(plan=plan, title="Item 1", order=1)
StudyPlanItem.objects.create(plan=plan, title="Item 2", order=2)
self.assertEqual(plan.completion_percentage, 0)

item1.mark_complete()
self.assertEqual(plan.completion_percentage, 50)


class SubjectStrengthSignalTests(TestCase):

def setUp(self):
self.user = User.objects.create_user(username="signaluser", email="signal@test.com", password=TEST_PASSWORD)

from web.models import Quiz, Subject

self.subject = Subject.objects.create(name="Physics", slug="physics")
self.quiz = Quiz.objects.create(
title="Physics Quiz",
subject=self.subject,
status="published",
creator=self.user,
)

def test_signal_creates_strength_on_first_quiz(self):
from web.models import SubjectStrength, UserQuiz

UserQuiz.objects.create(
user=self.user,
quiz=self.quiz,
score=7,
max_score=10,
completed=True,
)
strength = SubjectStrength.objects.filter(user=self.user, subject=self.subject).first()
self.assertIsNotNone(strength)
self.assertAlmostEqual(strength.strength_score, 70.0, places=1)
self.assertEqual(strength.total_quizzes, 1)

def test_signal_updates_strength_on_subsequent_quiz(self):
from web.models import SubjectStrength, UserQuiz

UserQuiz.objects.create(user=self.user, quiz=self.quiz, score=8, max_score=10, completed=True)
UserQuiz.objects.create(user=self.user, quiz=self.quiz, score=6, max_score=10, completed=True)
strength = SubjectStrength.objects.get(user=self.user, subject=self.subject)
# First: 80, then weighted: 0.7*80 + 0.3*60 = 56+18 = 74
self.assertAlmostEqual(strength.strength_score, 74.0, places=1)
self.assertEqual(strength.total_quizzes, 2)

def test_signal_ignores_incomplete_quiz(self):
from web.models import SubjectStrength, UserQuiz

UserQuiz.objects.create(user=self.user, quiz=self.quiz, score=5, max_score=10, completed=False)
self.assertFalse(SubjectStrength.objects.filter(user=self.user, subject=self.subject).exists())

def test_signal_ignores_anonymous_quiz(self):
from web.models import SubjectStrength, UserQuiz

UserQuiz.objects.create(user=None, quiz=self.quiz, score=5, max_score=10, completed=True, anonymous_id="abc123")
self.assertFalse(SubjectStrength.objects.filter(subject=self.subject).exists())
34 changes: 34 additions & 0 deletions web/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
VideoRequest,
WaitingRoom,
WebRequest,
SubjectStrength,
StudyPlan,
StudyPlanItem,
)

admin.site.unregister(EmailAddress)
Expand Down Expand Up @@ -889,3 +892,34 @@ class VideoRequestAdmin(admin.ModelAdmin):
list_display = ("title", "status", "category", "requester", "created_at")
list_filter = ("status", "category")
search_fields = ("title", "description", "requester__username")


@admin.register(SubjectStrength)
class SubjectStrengthAdmin(admin.ModelAdmin):
list_display = ("user", "subject", "strength_score", "total_quizzes", "last_assessed")
list_filter = ("subject", "last_assessed")
search_fields = ("user__username", "subject__name")
raw_id_fields = ("user", "subject")


class StudyPlanItemInline(admin.TabularInline):
model = StudyPlanItem
extra = 0
readonly_fields = ("completed_at",)


@admin.register(StudyPlan)
class StudyPlanAdmin(admin.ModelAdmin):
list_display = ("user", "title", "status", "completion_percentage", "created_at")
list_filter = ("status", "created_at")
search_fields = ("user__username", "title")
raw_id_fields = ("user",)
inlines = [StudyPlanItemInline]


@admin.register(StudyPlanItem)
class StudyPlanItemAdmin(admin.ModelAdmin):
list_display = ("title", "plan", "item_type", "priority", "is_completed", "due_date")
list_filter = ("item_type", "priority", "is_completed")
search_fields = ("title", "plan__user__username")
raw_id_fields = ("plan", "course", "session", "quiz")
Loading