From b8d4f21d3e2a6f0d24442f40a8e60f6c4eb79ec0 Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Mon, 2 Mar 2026 23:59:30 +0530 Subject: [PATCH 1/3] feat: Added AI-Powered Learning Analytics & Smart Study Planner --- .env.sample | 4 + tests/test_trackers.py | 274 ++++++++ web/admin.py | 34 + .../0064_add_learning_analytics_models.py | 74 +++ web/models.py | 114 ++++ web/recommendations.py | 619 +++++++++++++++++- web/settings.py | 4 + web/signals.py | 33 +- .../dashboard/learning_analytics.html | 364 ++++++++++ web/templates/dashboard/student.html | 16 +- web/templates/dashboard/study_plan.html | 193 ++++++ web/urls.py | 4 + web/views.py | 62 +- 13 files changed, 1785 insertions(+), 10 deletions(-) create mode 100644 web/migrations/0064_add_learning_analytics_models.py create mode 100644 web/templates/dashboard/learning_analytics.html create mode 100644 web/templates/dashboard/study_plan.html diff --git a/.env.sample b/.env.sample index 275d11499..747950ecf 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/tests/test_trackers.py b/tests/test_trackers.py index f9d80fec0..236b6a74b 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -55,3 +55,277 @@ 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="testpassword") + self.client.login(username="analyticsuser", password="testpassword") + + 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): + """First quiz should set score directly, not weighted average.""" + 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): + """Subsequent quizzes should use 70/30 weighted average.""" + 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): + """Zero max_score should not update anything.""" + 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="testpassword") + self.client.login(username="learner", password="testpassword") + + 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): + """Analytics should return sensible defaults with no quiz/attendance data.""" + 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): + """Ensure all expected keys are in the analytics response.""" + 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): + """Quiz performance should include trend data.""" + 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="testpassword") + self.client.login(username="planner", password="testpassword") + + 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): + """Regenerating should pause the old active plan.""" + 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): + """Users should not be able to complete other users' items.""" + from web.models import StudyPlan, StudyPlanItem + + other_user = User.objects.create_user(username="other", email="other@test.com", password="testpassword") + 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): + """Test that SubjectStrength auto-updates when a quiz is completed.""" + + def setUp(self): + self.user = User.objects.create_user(username="signaluser", email="signal@test.com", password="testpassword") + + 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): + """Completing a quiz should auto-create SubjectStrength for that user+subject.""" + 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): + """Second quiz should use weighted average.""" + 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): + """Incomplete quizzes should not trigger strength update.""" + 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): + """Anonymous quiz attempts should not create strength records.""" + 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()) diff --git a/web/admin.py b/web/admin.py index a0eb4328f..eb7d3dd2a 100644 --- a/web/admin.py +++ b/web/admin.py @@ -55,6 +55,9 @@ VideoRequest, WaitingRoom, WebRequest, + SubjectStrength, + StudyPlan, + StudyPlanItem, ) admin.site.unregister(EmailAddress) @@ -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") diff --git a/web/migrations/0064_add_learning_analytics_models.py b/web/migrations/0064_add_learning_analytics_models.py new file mode 100644 index 000000000..23f4fece1 --- /dev/null +++ b/web/migrations/0064_add_learning_analytics_models.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.11 on 2026-02-27 18:23 + +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='StudyPlan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('completed', 'Completed'), ('paused', 'Paused')], default='active', max_length=10)), + ('daily_goal_minutes', models.PositiveIntegerField(default=30)), + ('weekly_goal_sessions', models.PositiveIntegerField(default=5)), + ('target_completion_date', models.DateField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='study_plans', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='StudyPlanItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('item_type', models.CharField(choices=[('session', 'Attend Session'), ('quiz', 'Take Quiz'), ('review', 'Review Material'), ('practice', 'Practice Exercise'), ('reading', 'Reading Assignment')], default='review', max_length=10)), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('priority', models.CharField(choices=[('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], default='medium', max_length=10)), + ('is_completed', models.BooleanField(default=False)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('due_date', models.DateField(blank=True, null=True)), + ('estimated_minutes', models.PositiveIntegerField(default=30)), + ('order', models.PositiveIntegerField(default=0)), + ('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='web.course')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='web.studyplan')), + ('quiz', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='web.quiz')), + ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='web.session')), + ], + options={ + 'ordering': ['order', '-priority', 'due_date'], + }, + ), + migrations.CreateModel( + name='SubjectStrength', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('strength_score', models.FloatField(default=50.0, help_text='Score from 0-100 indicating mastery level')), + ('total_quizzes', models.PositiveIntegerField(default=0)), + ('total_correct', models.PositiveIntegerField(default=0)), + ('total_questions', models.PositiveIntegerField(default=0)), + ('last_assessed', models.DateTimeField(auto_now=True)), + ('subject', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_strengths', to='web.subject')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subject_strengths', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Subject strengths', + 'ordering': ['strength_score'], + 'unique_together': {('user', 'subject')}, + }, + ), + ] diff --git a/web/models.py b/web/models.py index 6b8ea8ef4..ca4e2182c 100644 --- a/web/models.py +++ b/web/models.py @@ -3176,3 +3176,117 @@ class Meta: ordering = ["-last_updated"] verbose_name = "Virtual Classroom Whiteboard" verbose_name_plural = "Virtual Classroom Whiteboards" + + + +class SubjectStrength(models.Model): + """Tracks a user's strength/weakness per subject based on quiz performance.""" + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="subject_strengths") + subject = models.ForeignKey(Subject, on_delete=models.CASCADE, related_name="user_strengths") + strength_score = models.FloatField(default=50.0, help_text="Score from 0-100 indicating mastery level") + total_quizzes = models.PositiveIntegerField(default=0) + total_correct = models.PositiveIntegerField(default=0) + total_questions = models.PositiveIntegerField(default=0) + last_assessed = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ["user", "subject"] + ordering = ["strength_score"] + verbose_name_plural = "Subject strengths" + + def __str__(self): + return f"{self.user.username} - {self.subject.name}: {self.strength_score:.0f}%" + + def update_from_quiz(self, score, max_score): + """Update strength using weighted moving average (70% historical, 30% new).""" + if max_score > 0: + new_score = (score / max_score) * 100 + if self.total_quizzes == 0: + self.strength_score = new_score + else: + self.strength_score = (0.7 * self.strength_score) + (0.3 * new_score) + self.total_quizzes += 1 + self.total_correct += score + self.total_questions += max_score + self.save() + + +class StudyPlan(models.Model): + """A personalized study plan generated from learning analytics.""" + + STATUS_CHOICES = [ + ("active", "Active"), + ("completed", "Completed"), + ("paused", "Paused"), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="study_plans") + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="active") + daily_goal_minutes = models.PositiveIntegerField(default=30) + weekly_goal_sessions = models.PositiveIntegerField(default=5) + target_completion_date = models.DateField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user.username} - {self.title}" + + @property + def completion_percentage(self): + total = self.items.count() + if total == 0: + return 0 + completed = self.items.filter(is_completed=True).count() + return int((completed / total) * 100) + + @property + def items_remaining(self): + return self.items.filter(is_completed=False).count() + + +class StudyPlanItem(models.Model): + """Individual item within a study plan.""" + + ITEM_TYPE_CHOICES = [ + ("session", "Attend Session"), + ("quiz", "Take Quiz"), + ("review", "Review Material"), + ("practice", "Practice Exercise"), + ("reading", "Reading Assignment"), + ] + PRIORITY_CHOICES = [ + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ] + + plan = models.ForeignKey(StudyPlan, on_delete=models.CASCADE, related_name="items") + item_type = models.CharField(max_length=10, choices=ITEM_TYPE_CHOICES, default="review") + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default="medium") + course = models.ForeignKey(Course, on_delete=models.SET_NULL, null=True, blank=True) + session = models.ForeignKey(Session, on_delete=models.SET_NULL, null=True, blank=True) + quiz = models.ForeignKey("Quiz", on_delete=models.SET_NULL, null=True, blank=True) + is_completed = models.BooleanField(default=False) + completed_at = models.DateTimeField(null=True, blank=True) + due_date = models.DateField(null=True, blank=True) + estimated_minutes = models.PositiveIntegerField(default=30) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order", "-priority", "due_date"] + + def __str__(self): + return f"{self.title} ({'✓' if self.is_completed else '○'})" + + def mark_complete(self): + self.is_completed = True + self.completed_at = timezone.now() + self.save() diff --git a/web/recommendations.py b/web/recommendations.py index ef7638323..64df3ffdc 100644 --- a/web/recommendations.py +++ b/web/recommendations.py @@ -82,4 +82,621 @@ def get_similar_courses(course, limit=3): ) return similar_courses[:limit] - return similar_courses[:limit] + + +def get_learning_analytics(user): + """ + Generate comprehensive learning analytics for a user. + Collects all learning data, computes metrics, and uses AI to generate + personalized insights and coaching advice. + Works for any student — new or experienced. + """ + import json + import logging + from datetime import timedelta + + from django.db.models import Avg, Count, F, Sum + from django.utils import timezone + + from .models import ( + CourseProgress, + Enrollment, + Session, + SessionAttendance, + SubjectStrength, + UserQuiz, + ) + + logger = logging.getLogger(__name__) + now = timezone.now() + thirty_days_ago = now - timedelta(days=30) + ninety_days_ago = now - timedelta(days=90) + + # --- Quiz Performance (all time + recent trend) --- + quiz_attempts = UserQuiz.objects.filter(user=user, completed=True) + total_attempts = quiz_attempts.count() + recent_attempts = quiz_attempts.filter(start_time__gte=thirty_days_ago) + avg_score = 0 + recent_avg_score = 0 + score_trend = "stable" + + if total_attempts > 0: + scores = [] + for attempt in quiz_attempts: + if attempt.max_score > 0: + scores.append((attempt.score / attempt.max_score) * 100) + avg_score = round(sum(scores) / len(scores), 1) if scores else 0 + + if recent_attempts.exists(): + recent_scores = [] + for attempt in recent_attempts: + if attempt.max_score > 0: + recent_scores.append((attempt.score / attempt.max_score) * 100) + recent_avg_score = round(sum(recent_scores) / len(recent_scores), 1) if recent_scores else 0 + if recent_avg_score > avg_score + 5: + score_trend = "improving" + elif recent_avg_score < avg_score - 5: + score_trend = "declining" + + # --- Subject Breakdown --- + subject_strengths = SubjectStrength.objects.filter(user=user).select_related("subject") + strengths = [] + weaknesses = [] + subject_breakdown = [] + for ss in subject_strengths: + entry = { + "subject": ss.subject.name, + "score": round(ss.strength_score, 1), + "quizzes": ss.total_quizzes, + "accuracy": round((ss.total_correct / ss.total_questions) * 100, 1) if ss.total_questions > 0 else 0, + } + subject_breakdown.append(entry) + if ss.strength_score >= 70: + strengths.append(entry) + elif ss.strength_score < 50: + weaknesses.append(entry) + + strengths.sort(key=lambda x: x["score"], reverse=True) + weaknesses.sort(key=lambda x: x["score"]) + + # --- Attendance --- + total_attendances = SessionAttendance.objects.filter(student=user).count() + attended = SessionAttendance.objects.filter(student=user, status__in=["present", "late"]).count() + late_count = SessionAttendance.objects.filter(student=user, status="late").count() + absent_count = SessionAttendance.objects.filter(student=user, status="absent").count() + attendance_rate = round((attended / total_attendances) * 100, 1) if total_attendances > 0 else 0 + + # --- Learning Velocity --- + recent_attendances = SessionAttendance.objects.filter( + student=user, created_at__gte=thirty_days_ago, status__in=["present", "late"] + ).count() + learning_velocity = round(recent_attendances / 4.3, 1) + + # --- Weekly Activity (last 8 weeks) --- + weekly_activity = [] + for i in range(7, -1, -1): + week_start = now - timedelta(weeks=i + 1) + week_end = now - timedelta(weeks=i) + sessions_count = SessionAttendance.objects.filter( + student=user, created_at__gte=week_start, created_at__lt=week_end, status__in=["present", "late"] + ).count() + quizzes_count = UserQuiz.objects.filter( + user=user, completed=True, start_time__gte=week_start, start_time__lt=week_end + ).count() + weekly_activity.append( + { + "week": f"W{8 - i}", + "sessions": sessions_count, + "quizzes": quizzes_count, + "total": sessions_count + quizzes_count, + } + ) + + # --- Course Progress & Risk --- + enrollments = Enrollment.objects.filter(student=user, status__in=["approved", "pending"]).select_related("course") + predicted_completion = [] + risk_courses = [] + for enrollment in enrollments: + try: + progress = CourseProgress.objects.get(enrollment=enrollment) + pct = progress.completion_percentage + except CourseProgress.DoesNotExist: + pct = 0 + + total_sessions = enrollment.course.sessions.count() + completed_sessions = 0 + if hasattr(enrollment, "progress"): + try: + completed_sessions = enrollment.progress.completed_sessions.count() + except Exception: + pass + + remaining = total_sessions - completed_sessions + if learning_velocity > 0 and remaining > 0: + weeks_remaining = remaining / learning_velocity + predicted_date = (now + timedelta(weeks=weeks_remaining)).date() + else: + predicted_date = None + + course_info = { + "course": enrollment.course.title, + "progress": pct, + "predicted_date": predicted_date, + "remaining_sessions": remaining, + "total_sessions": total_sessions, + } + predicted_completion.append(course_info) + + if pct < 30 and total_sessions > 0: + risk_courses.append(course_info) + + total_study_hours = round(attended * 1.5, 1) + + # --- Build student profile for AI --- + student_profile = { + "name": user.get_full_name() or user.username, + "quiz_avg": avg_score, + "recent_quiz_avg": recent_avg_score, + "score_trend": score_trend, + "total_quizzes": total_attempts, + "attendance_rate": attendance_rate, + "late_count": late_count, + "absent_count": absent_count, + "learning_velocity": learning_velocity, + "study_hours": total_study_hours, + "strengths": [{"subject": s["subject"], "score": s["score"]} for s in strengths], + "weaknesses": [{"subject": w["subject"], "score": w["score"]} for w in weaknesses], + "risk_courses": [{"course": r["course"], "progress": r["progress"]} for r in risk_courses], + "enrolled_courses": [{"course": p["course"], "progress": p["progress"]} for p in predicted_completion], + "weekly_totals": [w["total"] for w in weekly_activity], + } + + # --- AI-Powered Insights --- + ai_insights = _generate_ai_insights(student_profile, logger) + + # --- Fallback recommendations (always available) --- + recommendations = ai_insights.get("recommendations", []) + if not recommendations: + recommendations = _generate_fallback_recommendations( + weaknesses, attendance_rate, learning_velocity, risk_courses, total_attempts, avg_score + ) + + return { + "strengths": strengths, + "weaknesses": weaknesses, + "quiz_performance": { + "total_attempts": total_attempts, + "avg_score": avg_score, + "recent_avg": recent_avg_score, + "trend": score_trend, + }, + "attendance_rate": attendance_rate, + "attendance_detail": { + "total": total_attendances, + "attended": attended, + "late": late_count, + "absent": absent_count, + }, + "learning_velocity": learning_velocity, + "predicted_completion": predicted_completion, + "weekly_activity": weekly_activity, + "risk_courses": risk_courses, + "recommendations": recommendations, + "subject_breakdown": subject_breakdown, + "total_study_hours": total_study_hours, + "ai_coaching": ai_insights.get("coaching_message", ""), + "ai_study_tips": ai_insights.get("study_tips", []), + "learning_style_hint": ai_insights.get("learning_style", ""), + "motivation_message": ai_insights.get("motivation", ""), + } + + +def _generate_ai_insights(student_profile, logger): + """ + Use OpenAI to generate personalized learning insights. + Returns a dict with coaching_message, recommendations, study_tips, etc. + Gracefully falls back to empty dict if API is unavailable. + """ + import json + + from django.conf import settings + + api_key = getattr(settings, "OPENAI_API_KEY", "") + if not api_key: + return {} + + try: + import openai + + client = openai.OpenAI(api_key=api_key) + + prompt = f"""You are an expert educational AI tutor analyzing a student's learning data. +Based on this student profile, provide personalized, actionable insights. + +STUDENT DATA: +- Name: {student_profile['name']} +- Overall quiz average: {student_profile['quiz_avg']}% +- Recent 30-day quiz average: {student_profile['recent_quiz_avg']}% +- Score trend: {student_profile['score_trend']} +- Total quizzes completed: {student_profile['total_quizzes']} +- Attendance rate: {student_profile['attendance_rate']}% +- Late arrivals: {student_profile['late_count']}, Absences: {student_profile['absent_count']} +- Learning velocity: {student_profile['learning_velocity']} sessions/week +- Total study hours: {student_profile['study_hours']} +- Strong subjects: {json.dumps(student_profile['strengths'])} +- Weak subjects: {json.dumps(student_profile['weaknesses'])} +- At-risk courses: {json.dumps(student_profile['risk_courses'])} +- Enrolled courses: {json.dumps(student_profile['enrolled_courses'])} +- Weekly activity (last 8 weeks): {student_profile['weekly_totals']} + +Respond with ONLY valid JSON (no markdown, no code blocks) in this exact format: +{{ + "coaching_message": "A warm, personalized 2-3 sentence coaching message addressing the student by name. Be encouraging but honest about areas to improve.", + "recommendations": [ + {{"icon": "fas fa-icon-name", "text": "Specific actionable recommendation", "priority": "high|medium|low"}}, + {{"icon": "fas fa-icon-name", "text": "Another recommendation", "priority": "high|medium|low"}} + ], + "study_tips": [ + "Specific study tip based on their data", + "Another personalized tip" + ], + "learning_style": "Brief observation about their apparent learning style based on the data patterns", + "motivation": "A short motivational message tailored to their current situation" +}} + +Rules: +- Give 3-5 recommendations, prioritized by urgency +- Make study_tips specific to their weak subjects and situation (give 3-4) +- If they have no data yet, be welcoming and suggest getting started +- Use Font Awesome icon names (fas fa-...) for recommendation icons +- Be specific — reference actual subject names, course names, and numbers from their data +- If attendance is low, address it. If scores are declining, address it. +- For a student doing well, suggest stretch goals and peer mentoring""" + + model = getattr(settings, "AI_MODEL", "gpt-4o-mini") + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=800, + ) + + raw = response.choices[0].message.content.strip() + # Strip markdown code fences if present + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + if raw.endswith("```"): + raw = raw[:-3] + if raw.startswith("json"): + raw = raw[4:] + + return json.loads(raw.strip()) + + except Exception as e: + logger.warning(f"AI insights generation failed: {e}") + return {} + + +def _generate_fallback_recommendations(weaknesses, attendance_rate, learning_velocity, risk_courses, total_attempts, avg_score): + """Generate rule-based recommendations when AI is unavailable.""" + recommendations = [] + if weaknesses: + weak_names = ", ".join([w["subject"] for w in weaknesses[:3]]) + recommendations.append( + {"icon": "fas fa-bullseye", "text": f"Focus on improving: {weak_names}", "priority": "high"} + ) + if attendance_rate < 80: + recommendations.append( + { + "icon": "fas fa-calendar-check", + "text": f"Your attendance is {attendance_rate}%. Try to attend more sessions consistently.", + "priority": "high", + } + ) + if learning_velocity < 2: + recommendations.append( + {"icon": "fas fa-tachometer-alt", "text": "Increase your weekly study sessions to build momentum.", "priority": "medium"} + ) + if risk_courses: + for rc in risk_courses[:2]: + recommendations.append( + { + "icon": "fas fa-exclamation-triangle", + "text": f"'{rc['course']}' is at risk ({rc['progress']}% complete). Prioritize it.", + "priority": "high", + } + ) + if total_attempts > 5 and avg_score > 80: + recommendations.append( + { + "icon": "fas fa-star", + "text": "Great quiz performance! Consider helping peers or exploring advanced topics.", + "priority": "low", + } + ) + if not recommendations: + recommendations.append( + {"icon": "fas fa-thumbs-up", "text": "You're on track! Keep up the consistent effort.", "priority": "low"} + ) + return recommendations + + +def generate_study_plan(user): + """ + Generate a truly personalized study plan using AI + learning data. + The AI analyzes the student's full profile and creates a tailored + day-by-day plan with specific, actionable items. + Falls back to intelligent rule-based generation if AI is unavailable. + """ + import json + import logging + from datetime import timedelta + + from django.conf import settings + from django.utils import timezone + + from .models import ( + Enrollment, + Quiz, + Session, + StudyPlan, + StudyPlanItem, + SubjectStrength, + ) + + logger = logging.getLogger(__name__) + now = timezone.now() + + # Deactivate old plans + StudyPlan.objects.filter(user=user, status="active").update(status="paused") + + analytics = get_learning_analytics(user) + + # Gather context for AI + enrollments = Enrollment.objects.filter(student=user, status__in=["approved", "pending"]).select_related("course") + upcoming_sessions = Session.objects.filter( + course__enrollments__in=enrollments, start_time__gt=now, start_time__lte=now + timedelta(days=14) + ).order_by("start_time").select_related("course")[:15] + + weak_subjects = SubjectStrength.objects.filter(user=user, strength_score__lt=50).select_related("subject") + medium_subjects = SubjectStrength.objects.filter( + user=user, strength_score__gte=50, strength_score__lt=70 + ).select_related("subject") + + available_quizzes = [] + for ss in list(weak_subjects) + list(medium_subjects): + quizzes = Quiz.objects.filter(subject=ss.subject, status="published")[:2] + for q in quizzes: + available_quizzes.append({"id": q.id, "title": q.title, "subject": ss.subject.name}) + + session_list = [] + for s in upcoming_sessions: + session_list.append({ + "id": s.id, + "title": s.title, + "course": s.course.title, + "date": s.start_time.strftime("%Y-%m-%d %H:%M"), + }) + + # Try AI-powered plan generation + ai_plan_items = _generate_ai_study_plan( + analytics, session_list, available_quizzes, + [{"subject": ss.subject.name, "score": ss.strength_score} for ss in weak_subjects], + [{"subject": ss.subject.name, "score": ss.strength_score} for ss in medium_subjects], + user.get_full_name() or user.username, + logger, + ) + + plan = StudyPlan.objects.create( + user=user, + title=f"AI Study Plan — {now.strftime('%B %d, %Y')}", + description=ai_plan_items.get("plan_description", "Personalized plan based on your learning analytics."), + daily_goal_minutes=ai_plan_items.get("daily_goal_minutes", 30), + weekly_goal_sessions=ai_plan_items.get("weekly_goal_sessions", 5), + ) + + items = ai_plan_items.get("items", []) + if not items: + # Fallback to rule-based generation + items = _generate_fallback_plan_items( + upcoming_sessions, weak_subjects, medium_subjects, available_quizzes, analytics + ) + + # Create StudyPlanItem objects + session_map = {s.id: s for s in upcoming_sessions} + quiz_map = {q["id"]: q for q in available_quizzes} + course_map = {} + for enrollment in enrollments: + course_map[enrollment.course.title] = enrollment.course + + for idx, item_data in enumerate(items): + item_kwargs = { + "plan": plan, + "item_type": item_data.get("type", "review"), + "title": item_data.get("title", "Study task"), + "description": item_data.get("description", ""), + "priority": item_data.get("priority", "medium"), + "estimated_minutes": item_data.get("minutes", 30), + "order": idx + 1, + } + + # Link to session if referenced + session_id = item_data.get("session_id") + if session_id and session_id in session_map: + item_kwargs["session"] = session_map[session_id] + item_kwargs["course"] = session_map[session_id].course + + # Link to quiz if referenced + quiz_id = item_data.get("quiz_id") + if quiz_id: + try: + item_kwargs["quiz"] = Quiz.objects.get(id=quiz_id) + except Quiz.DoesNotExist: + pass + + # Link to course by name + course_name = item_data.get("course_name") + if course_name and course_name in course_map: + item_kwargs["course"] = course_map[course_name] + + # Parse due date + due = item_data.get("due_date") + if due: + try: + from datetime import datetime + item_kwargs["due_date"] = datetime.strptime(due, "%Y-%m-%d").date() + except (ValueError, TypeError): + pass + + StudyPlanItem.objects.create(**item_kwargs) + + return plan + + +def _generate_ai_study_plan(analytics, sessions, quizzes, weak_subjects, medium_subjects, student_name, logger): + """Use AI to create a structured, personalized study plan.""" + import json + + from django.conf import settings + + api_key = getattr(settings, "OPENAI_API_KEY", "") + if not api_key: + return {"items": []} + + try: + import openai + + client = openai.OpenAI(api_key=api_key) + + prompt = f"""You are an expert educational planner creating a 2-week personalized study plan. + +STUDENT: {student_name} +QUIZ AVERAGE: {analytics['quiz_performance']['avg_score']}% (trend: {analytics['quiz_performance']['trend']}) +ATTENDANCE: {analytics['attendance_rate']}% +VELOCITY: {analytics['learning_velocity']} sessions/week +STUDY HOURS SO FAR: {analytics['total_study_hours']} + +WEAK SUBJECTS (need most work): {json.dumps(weak_subjects)} +MEDIUM SUBJECTS (need reinforcement): {json.dumps(medium_subjects)} +AT-RISK COURSES: {json.dumps([{{'course': r['course'], 'progress': r['progress']}} for r in analytics['risk_courses']])} + +UPCOMING SESSIONS (must attend): +{json.dumps(sessions, indent=2)} + +AVAILABLE QUIZZES FOR PRACTICE: +{json.dumps(quizzes, indent=2)} + +Create a study plan with 10-18 items. Respond with ONLY valid JSON (no markdown): +{{ + "plan_description": "Brief personalized description of this plan's goals", + "daily_goal_minutes": , + "weekly_goal_sessions": , + "items": [ + {{ + "type": "session|quiz|review|practice|reading", + "title": "Clear, specific title", + "description": "Why this matters and how to approach it", + "priority": "high|medium|low", + "minutes": , + "due_date": "YYYY-MM-DD or null", + "session_id": , + "quiz_id": , + "course_name": "course name or null" + }} + ] +}} + +Rules: +- Include ALL upcoming sessions as "session" type items with their session_id +- Add review items for EACH weak subject with specific study strategies in the description +- Add quiz practice items using available quiz IDs for weak/medium subjects +- Add reading/practice items for at-risk courses +- Space items across the 2 weeks realistically +- Descriptions should be specific and helpful (e.g., "Focus on chapters 3-5 covering derivatives" not just "Review math") +- Set daily_goal_minutes based on their current velocity (don't overwhelm a slow learner) +- Prioritize: weak subjects and at-risk courses = high, medium subjects = medium, reinforcement = low +- If student has few weak areas, add stretch goals and advanced practice +- Make it feel achievable, not overwhelming""" + + model = getattr(settings, "AI_MODEL", "gpt-4o-mini") + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=1500, + ) + + raw = response.choices[0].message.content.strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + if raw.endswith("```"): + raw = raw[:-3] + if raw.startswith("json"): + raw = raw[4:] + + return json.loads(raw.strip()) + + except Exception as e: + logger.warning(f"AI study plan generation failed: {e}") + return {"items": []} + + +def _generate_fallback_plan_items(upcoming_sessions, weak_subjects, medium_subjects, available_quizzes, analytics): + """Rule-based fallback when AI is unavailable.""" + items = [] + + # 1. Upcoming sessions + for session in upcoming_sessions: + items.append({ + "type": "session", + "title": f"Attend: {session.title}", + "description": f"Course: {session.course.title}. Prepare by reviewing previous session notes.", + "priority": "high", + "minutes": 90, + "session_id": session.id, + "due_date": session.start_time.strftime("%Y-%m-%d"), + }) + + # 2. Weak subject reviews + for ss in weak_subjects[:5]: + items.append({ + "type": "review", + "title": f"Review: {ss.subject.name}", + "description": f"Your score is {ss.strength_score:.0f}%. Go through the core concepts and rework problems you got wrong.", + "priority": "high", + "minutes": 45, + }) + + # 3. Quiz practice + for q in available_quizzes[:4]: + items.append({ + "type": "quiz", + "title": f"Practice: {q['title']}", + "description": f"Test your {q['subject']} knowledge. Aim for 70%+ to show improvement.", + "priority": "medium", + "minutes": 30, + "quiz_id": q["id"], + }) + + # 4. At-risk course catch-up + for rc in analytics.get("risk_courses", [])[:3]: + items.append({ + "type": "reading", + "title": f"Catch up: {rc['course']}", + "description": f"Only {rc['progress']}% complete. Review missed materials and complete pending assignments.", + "priority": "high", + "minutes": 60, + "course_name": rc["course"], + }) + + # 5. Medium subject reinforcement + for ss in medium_subjects[:3]: + items.append({ + "type": "practice", + "title": f"Reinforce: {ss.subject.name}", + "description": f"Score: {ss.strength_score:.0f}%. Practice with varied problems to solidify understanding.", + "priority": "medium", + "minutes": 30, + }) + + return items diff --git a/web/settings.py b/web/settings.py index 5ca9d0023..00412ac7d 100644 --- a/web/settings.py +++ b/web/settings.py @@ -511,3 +511,7 @@ CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=not DEBUG) SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=not DEBUG) GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") + +# AI / OpenAI Configuration for Learning Analytics +OPENAI_API_KEY = env.str("OPENAI_API_KEY", default="") +AI_MODEL = env.str("AI_MODEL", default="gpt-4o-mini") diff --git a/web/signals.py b/web/signals.py index a24995d54..7a391bdf0 100644 --- a/web/signals.py +++ b/web/signals.py @@ -3,7 +3,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver -from .models import CourseProgress, Enrollment, LearningStreak, Session, SessionAttendance +from .models import CourseProgress, Enrollment, LearningStreak, Session, SessionAttendance, SubjectStrength, UserQuiz from .utils import send_slack_message @@ -67,3 +67,34 @@ def invalidate_session_cache(sender, instance, **kwargs): enrollments = Enrollment.objects.filter(course=instance.course) for enrollment in enrollments: invalidate_progress_cache(enrollment.student) + + +@receiver(post_save, sender=UserQuiz) +def update_subject_strength_on_quiz_complete(sender, instance, **kwargs): + """ + Automatically update SubjectStrength when any student completes a quiz. + This makes the analytics system work for every student without manual setup. + Uses a weighted moving average: 70% historical + 30% new score. + """ + if not instance.completed or not instance.user or not instance.quiz.subject: + return + + if instance.max_score <= 0: + return + + subject = instance.quiz.subject + user = instance.user + + strength, created = SubjectStrength.objects.get_or_create( + user=user, + subject=subject, + defaults={ + "strength_score": (instance.score / instance.max_score) * 100, + "total_quizzes": 1, + "total_correct": instance.score, + "total_questions": instance.max_score, + }, + ) + + if not created: + strength.update_from_quiz(instance.score, instance.max_score) diff --git a/web/templates/dashboard/learning_analytics.html b/web/templates/dashboard/learning_analytics.html new file mode 100644 index 000000000..5b3cf0bed --- /dev/null +++ b/web/templates/dashboard/learning_analytics.html @@ -0,0 +1,364 @@ +{% extends "base.html" %} + +{% block title %}Learning Analytics - Dashboard{% endblock title %} + +{% block content %} +
+
+
+

Learning Analytics

+

AI-powered insights into your learning journey

+
+ +
+ + + {% if analytics.ai_coaching %} +
+
+
+ +
+
+

+ AI Coach +

+

{{ analytics.ai_coaching }}

+ {% if analytics.learning_style_hint %} +

+ {{ analytics.learning_style_hint }} +

+ {% endif %} +
+
+
+ {% endif %} + + +
+
+
+
+

Quiz Average

+

{{ analytics.quiz_performance.avg_score }}%

+
+
+ +
+
+
+ {% if analytics.quiz_performance.trend == "improving" %} + + Improving ({{ analytics.quiz_performance.recent_avg }}% recent) + {% elif analytics.quiz_performance.trend == "declining" %} + + Declining ({{ analytics.quiz_performance.recent_avg }}% recent) + {% else %} + + {{ analytics.quiz_performance.total_attempts }} quizzes taken + {% endif %} +
+
+
+
+
+

Attendance Rate

+

{{ analytics.attendance_rate }}%

+
+
+ +
+
+

+ {{ analytics.attendance_detail.attended }} attended, {{ analytics.attendance_detail.late }} late, {{ analytics.attendance_detail.absent }} absent +

+
+
+
+
+

Weekly Velocity

+

{{ analytics.learning_velocity }}

+
+
+ +
+
+

Sessions per week

+
+
+
+
+

Study Hours

+

{{ analytics.total_study_hours }}

+
+
+ +
+
+

Estimated total

+
+
+ +
+ +
+

+ Subject Mastery +

+ {% if analytics.subject_breakdown %} +
+ {% for subject in analytics.subject_breakdown %} +
+
+ {{ subject.subject }} +
+ {{ subject.accuracy }}% accuracy + + {{ subject.score }}% + +
+
+
+
+
+

{{ subject.quizzes }} quizzes taken

+
+ {% endfor %} +
+ {% else %} +

Take some quizzes to see your subject mastery breakdown.

+ {% endif %} +
+ + +
+
+

+ Strengths +

+ {% if analytics.strengths %} +
    + {% for s in analytics.strengths %} +
  • + {{ s.subject }} + {{ s.score }}% +
  • + {% endfor %} +
+ {% else %} +

Keep taking quizzes to identify your strengths.

+ {% endif %} +
+
+

+ Needs Improvement +

+ {% if analytics.weaknesses %} +
    + {% for w in analytics.weaknesses %} +
  • + {{ w.subject }} + {{ w.score }}% +
  • + {% endfor %} +
+ {% else %} +

No weak areas detected. Great job!

+ {% endif %} +
+
+
+ + +
+ +
+

+ AI Study Tips +

+ {% if analytics.ai_study_tips %} +
    + {% for tip in analytics.ai_study_tips %} +
  • + {{ forloop.counter }} +

    {{ tip }}

    +
  • + {% endfor %} +
+ {% else %} +
+
+ 1 +

Take quizzes regularly to track your progress and identify areas for improvement.

+
+
+ 2 +

Attend sessions consistently — even showing up late is better than missing entirely.

+
+
+ 3 +

Generate a study plan to get a personalized roadmap for the next 2 weeks.

+
+
+ {% endif %} +
+ + +
+

+ At-Risk Courses +

+ {% if analytics.risk_courses %} +
+ {% for rc in analytics.risk_courses %} +
+
+

{{ rc.course }}

+ {{ rc.progress }}% +
+
+
+
+

{{ rc.remaining_sessions }} of {{ rc.total_sessions }} sessions remaining

+
+ {% endfor %} +
+ {% else %} +
+ +

All courses are on track!

+
+ {% endif %} +
+
+ +
+ +
+

+ Weekly Activity (Last 8 Weeks) +

+ {% if analytics.weekly_activity %} +
+ {% for week in analytics.weekly_activity %} +
+
+ {% if week.total > 0 %} + {{ week.total }} + + {% else %} +
+ {% endif %} +
+ {{ week.week }} +
+ {% endfor %} +
+ {% else %} +

No activity data yet.

+ {% endif %} +
+ + +
+

+ Smart Recommendations +

+
+ {% for rec in analytics.recommendations %} +
+ +
+

{{ rec.text }}

+ {{ rec.priority }} priority +
+
+ {% endfor %} +
+
+
+ + + {% if analytics.predicted_completion %} +
+

+ Course Progress & Predicted Completion +

+
+ {% for pc in analytics.predicted_completion %} +
+

{{ pc.course }}

+
+
+
+
+ {{ pc.progress }}% complete + {% if pc.predicted_date %} + Est. {{ pc.predicted_date|date:"M d, Y" }} + {% else %} + {{ pc.remaining_sessions }} sessions left + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if analytics.motivation_message %} +
+ +

{{ analytics.motivation_message }}

+
+ {% endif %} + + {% if not active_plan %} +
+ +

Ready to level up?

+

Generate an AI-powered study plan tailored to your strengths, weaknesses, and schedule.

+
+ {% csrf_token %} + +
+
+ {% else %} +
+

You have an active study plan ({{ active_plan.completion_percentage }}% complete)

+ + View Study Plan + +
+ {% endif %} +
+ + +{% endblock content %} diff --git a/web/templates/dashboard/student.html b/web/templates/dashboard/student.html index fd0e078bd..b1fdf30c1 100644 --- a/web/templates/dashboard/student.html +++ b/web/templates/dashboard/student.html @@ -7,11 +7,17 @@
diff --git a/web/templates/dashboard/study_plan.html b/web/templates/dashboard/study_plan.html new file mode 100644 index 000000000..6eade4b09 --- /dev/null +++ b/web/templates/dashboard/study_plan.html @@ -0,0 +1,193 @@ +{% extends "base.html" %} + +{% block title %}Study Plan - Dashboard{% endblock title %} + +{% block content %} +
+
+
+

Study Plan

+

Your personalized learning roadmap

+
+
+ + Analytics + +
+ {% csrf_token %} + +
+
+
+ + {% if plan %} + +
+
+
+

{{ plan.title }}

+

{{ plan.description }}

+
+
+ + {{ plan.daily_goal_minutes }}min/day + + + {{ plan.weekly_goal_sessions }} sessions/week + +
+
+
+
+
+
+ {{ plan.completion_percentage }}% +
+

{{ plan.items_remaining }} items remaining

+
+ + +
+ {% for item in plan.items.all %} +
+ +
+ {% if item.is_completed %} +
+ +
+ {% else %} + + {% endif %} +
+ +
+
+ + {{ item.get_item_type_display }} + + + {{ item.priority }} + +
+

{{ item.title }}

+ {% if item.description %} +

{{ item.description }}

+ {% endif %} +
+ +
+ {% if item.due_date %} +

{{ item.due_date|date:"M d" }}

+ {% endif %} +

{{ item.estimated_minutes }}min

+ {% if item.is_completed and item.completed_at %} +

+ {{ item.completed_at|date:"M d" }} +

+ {% endif %} +
+
+ {% empty %} +
+ +

This plan has no items yet.

+
+ {% endfor %} +
+ + + {% if past_plans %} +
+

Previous Plans

+
+ {% for pp in past_plans %} +
+

{{ pp.title }}

+
+ {{ pp.created_at|date:"M d, Y" }} + + {{ pp.get_status_display }} - {{ pp.completion_percentage }}% + +
+
+ {% endfor %} +
+
+ {% endif %} + + {% else %} + +
+ +

No Active Study Plan

+

+ Generate an AI-powered study plan tailored to your quiz performance, attendance, and course progress. The AI analyzes your strengths and weaknesses to create a realistic 2-week roadmap. +

+
+ {% csrf_token %} + +
+
+ {% endif %} +
+ + +{% endblock content %} diff --git a/web/urls.py b/web/urls.py index 3fb4e298a..ddea80c96 100644 --- a/web/urls.py +++ b/web/urls.py @@ -98,6 +98,10 @@ path("dashboard/student/", views.student_dashboard, name="student_dashboard"), path("dashboard/teacher/", views.teacher_dashboard, name="teacher_dashboard"), path("dashboard/content/", views.content_dashboard, name="content_dashboard"), + path("dashboard/analytics/", views.learning_analytics_dashboard, name="learning_analytics"), + path("dashboard/study-plan/", views.study_plan_view, name="study_plan"), + path("dashboard/study-plan/generate/", views.generate_study_plan_view, name="generate_study_plan"), + path("dashboard/study-plan/complete//", views.complete_study_plan_item, name="complete_study_plan_item"), # SURVEY URLs # Course Management path("courses/create/", views.create_course, name="create_course"), diff --git a/web/views.py b/web/views.py index 8dd972d98..1e4789cc9 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 from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import NestedObjects @@ -179,6 +179,9 @@ WaitingRoom, WebRequest, default_valid_until, + StudyPlan, + StudyPlanItem, + SubjectStrength, ) from .notifications import ( notify_session_reminder, @@ -1332,7 +1335,7 @@ def teach(request): ) else: # Email not verified, resend verification email - send_email_confirmation(request, user, signup=False) + send_verification_email_for_user(request, user) messages.info( request, "An account with this email exists. Please verify your email to continue.", @@ -1362,7 +1365,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_verification_email_for_user(request, user) # Send welcome email with username, email, and temp password try: send_welcome_teach_course_email(request, user, temp_password) @@ -8844,3 +8847,56 @@ 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 +def learning_analytics_dashboard(request): + """Dashboard showing AI-powered learning analytics and insights.""" + from .recommendations import get_learning_analytics + + analytics = get_learning_analytics(request.user) + active_plan = StudyPlan.objects.filter(user=request.user, status="active").first() + context = { + "analytics": analytics, + "active_plan": active_plan, + } + return render(request, "dashboard/learning_analytics.html", context) + + +@login_required +def study_plan_view(request): + """View the user's active study plan.""" + plan = StudyPlan.objects.filter(user=request.user, status="active").first() + past_plans = StudyPlan.objects.filter(user=request.user).exclude(status="active").order_by("-created_at")[:5] + context = { + "plan": plan, + "past_plans": past_plans, + } + return render(request, "dashboard/study_plan.html", context) + + +@login_required +@require_POST +def generate_study_plan_view(request): + """Generate or regenerate a study plan.""" + from .recommendations import generate_study_plan + + generate_study_plan(request.user) + messages.success(request, "Your study plan has been generated!") + return redirect("study_plan") + + +@login_required +@require_POST +def complete_study_plan_item(request, item_id): + """Mark a study plan item as complete (AJAX endpoint).""" + item = get_object_or_404(StudyPlanItem, id=item_id, plan__user=request.user) + item.mark_complete() + plan = item.plan + return JsonResponse( + { + "success": True, + "completion_percentage": plan.completion_percentage, + "items_remaining": plan.items_remaining, + } + ) From 0a7a5c806ddc36c205eccc069861368f8e1d8968 Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Tue, 3 Mar 2026 00:13:35 +0530 Subject: [PATCH 2/3] small fixes --- tests/test_trackers.py | 13 ------- web/models.py | 4 +-- web/recommendations.py | 34 ++++++------------- web/signals.py | 6 +--- .../dashboard/learning_analytics.html | 22 ++++++------ web/templates/dashboard/study_plan.html | 4 +-- web/views.py | 8 ++--- 7 files changed, 29 insertions(+), 62 deletions(-) diff --git a/tests/test_trackers.py b/tests/test_trackers.py index 236b6a74b..2bbbe80ff 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -79,7 +79,6 @@ def test_initial_strength_score(self): self.assertEqual(self.strength.strength_score, 50.0) def test_update_from_quiz_first_attempt(self): - """First quiz should set score directly, not weighted average.""" self.strength.update_from_quiz(8, 10) self.strength.refresh_from_db() self.assertEqual(self.strength.strength_score, 80.0) @@ -88,7 +87,6 @@ def test_update_from_quiz_first_attempt(self): self.assertEqual(self.strength.total_questions, 10) def test_update_from_quiz_weighted_average(self): - """Subsequent quizzes should use 70/30 weighted average.""" 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 @@ -97,7 +95,6 @@ def test_update_from_quiz_weighted_average(self): self.assertEqual(self.strength.total_quizzes, 2) def test_update_from_quiz_zero_max_score(self): - """Zero max_score should not update anything.""" original_score = self.strength.strength_score self.strength.update_from_quiz(0, 0) self.strength.refresh_from_db() @@ -133,7 +130,6 @@ def test_analytics_dashboard_requires_login(self): self.assertEqual(response.status_code, 302) def test_analytics_with_no_data(self): - """Analytics should return sensible defaults with no quiz/attendance data.""" from web.recommendations import get_learning_analytics analytics = get_learning_analytics(self.user) @@ -146,7 +142,6 @@ def test_analytics_with_no_data(self): self.assertTrue(len(analytics["recommendations"]) > 0) def test_analytics_context_keys(self): - """Ensure all expected keys are in the analytics response.""" from web.recommendations import get_learning_analytics analytics = get_learning_analytics(self.user) @@ -172,7 +167,6 @@ def test_analytics_context_keys(self): self.assertIn(key, analytics) def test_analytics_quiz_trend(self): - """Quiz performance should include trend data.""" from web.recommendations import get_learning_analytics analytics = get_learning_analytics(self.user) @@ -206,7 +200,6 @@ def test_generate_study_plan(self): self.assertIsNotNone(plan) def test_regenerate_pauses_old_plan(self): - """Regenerating should pause the old active plan.""" from web.models import StudyPlan # Generate first plan @@ -243,7 +236,6 @@ def test_complete_study_plan_item(self): self.assertIsNotNone(item.completed_at) def test_complete_item_wrong_user(self): - """Users should not be able to complete other users' items.""" from web.models import StudyPlan, StudyPlanItem other_user = User.objects.create_user(username="other", email="other@test.com", password="testpassword") @@ -274,7 +266,6 @@ def test_plan_completion_percentage(self): class SubjectStrengthSignalTests(TestCase): - """Test that SubjectStrength auto-updates when a quiz is completed.""" def setUp(self): self.user = User.objects.create_user(username="signaluser", email="signal@test.com", password="testpassword") @@ -290,7 +281,6 @@ def setUp(self): ) def test_signal_creates_strength_on_first_quiz(self): - """Completing a quiz should auto-create SubjectStrength for that user+subject.""" from web.models import SubjectStrength, UserQuiz UserQuiz.objects.create( @@ -306,7 +296,6 @@ def test_signal_creates_strength_on_first_quiz(self): self.assertEqual(strength.total_quizzes, 1) def test_signal_updates_strength_on_subsequent_quiz(self): - """Second quiz should use weighted average.""" from web.models import SubjectStrength, UserQuiz UserQuiz.objects.create(user=self.user, quiz=self.quiz, score=8, max_score=10, completed=True) @@ -317,14 +306,12 @@ def test_signal_updates_strength_on_subsequent_quiz(self): self.assertEqual(strength.total_quizzes, 2) def test_signal_ignores_incomplete_quiz(self): - """Incomplete quizzes should not trigger strength update.""" 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): - """Anonymous quiz attempts should not create strength records.""" from web.models import SubjectStrength, UserQuiz UserQuiz.objects.create(user=None, quiz=self.quiz, score=5, max_score=10, completed=True, anonymous_id="abc123") diff --git a/web/models.py b/web/models.py index ca4e2182c..1f0d8d409 100644 --- a/web/models.py +++ b/web/models.py @@ -3199,7 +3199,7 @@ def __str__(self): return f"{self.user.username} - {self.subject.name}: {self.strength_score:.0f}%" def update_from_quiz(self, score, max_score): - """Update strength using weighted moving average (70% historical, 30% new).""" + """Update strength score from a quiz result.""" if max_score > 0: new_score = (score / max_score) * 100 if self.total_quizzes == 0: @@ -3213,7 +3213,6 @@ def update_from_quiz(self, score, max_score): class StudyPlan(models.Model): - """A personalized study plan generated from learning analytics.""" STATUS_CHOICES = [ ("active", "Active"), @@ -3251,7 +3250,6 @@ def items_remaining(self): class StudyPlanItem(models.Model): - """Individual item within a study plan.""" ITEM_TYPE_CHOICES = [ ("session", "Attend Session"), diff --git a/web/recommendations.py b/web/recommendations.py index 64df3ffdc..cce46adde 100644 --- a/web/recommendations.py +++ b/web/recommendations.py @@ -85,12 +85,7 @@ def get_similar_courses(course, limit=3): def get_learning_analytics(user): - """ - Generate comprehensive learning analytics for a user. - Collects all learning data, computes metrics, and uses AI to generate - personalized insights and coaching advice. - Works for any student — new or experienced. - """ + """Return learning analytics data for the given user.""" import json import logging from datetime import timedelta @@ -251,7 +246,7 @@ def get_learning_analytics(user): "weekly_totals": [w["total"] for w in weekly_activity], } - # --- AI-Powered Insights --- + # --- Insights --- ai_insights = _generate_ai_insights(student_profile, logger) # --- Fallback recommendations (always available) --- @@ -292,11 +287,7 @@ def get_learning_analytics(user): def _generate_ai_insights(student_profile, logger): - """ - Use OpenAI to generate personalized learning insights. - Returns a dict with coaching_message, recommendations, study_tips, etc. - Gracefully falls back to empty dict if API is unavailable. - """ + """Call OpenAI for learning insights. Returns empty dict on failure.""" import json from django.conf import settings @@ -378,7 +369,7 @@ def _generate_ai_insights(student_profile, logger): def _generate_fallback_recommendations(weaknesses, attendance_rate, learning_velocity, risk_courses, total_attempts, avg_score): - """Generate rule-based recommendations when AI is unavailable.""" + """Rule-based recommendations fallback.""" recommendations = [] if weaknesses: weak_names = ", ".join([w["subject"] for w in weaknesses[:3]]) @@ -422,12 +413,7 @@ def _generate_fallback_recommendations(weaknesses, attendance_rate, learning_vel def generate_study_plan(user): - """ - Generate a truly personalized study plan using AI + learning data. - The AI analyzes the student's full profile and creates a tailored - day-by-day plan with specific, actionable items. - Falls back to intelligent rule-based generation if AI is unavailable. - """ + """Generate a study plan for the user based on their analytics data.""" import json import logging from datetime import timedelta @@ -478,7 +464,7 @@ def generate_study_plan(user): "date": s.start_time.strftime("%Y-%m-%d %H:%M"), }) - # Try AI-powered plan generation + # Try plan generation via OpenAI ai_plan_items = _generate_ai_study_plan( analytics, session_list, available_quizzes, [{"subject": ss.subject.name, "score": ss.strength_score} for ss in weak_subjects], @@ -489,8 +475,8 @@ def generate_study_plan(user): plan = StudyPlan.objects.create( user=user, - title=f"AI Study Plan — {now.strftime('%B %d, %Y')}", - description=ai_plan_items.get("plan_description", "Personalized plan based on your learning analytics."), + title=f"Study Plan — {now.strftime('%B %d, %Y')}", + description=ai_plan_items.get("plan_description", "Plan based on your learning analytics."), daily_goal_minutes=ai_plan_items.get("daily_goal_minutes", 30), weekly_goal_sessions=ai_plan_items.get("weekly_goal_sessions", 5), ) @@ -554,7 +540,7 @@ def generate_study_plan(user): def _generate_ai_study_plan(analytics, sessions, quizzes, weak_subjects, medium_subjects, student_name, logger): - """Use AI to create a structured, personalized study plan.""" + """Call OpenAI to generate study plan items. Returns empty on failure.""" import json from django.conf import settings @@ -642,7 +628,7 @@ def _generate_ai_study_plan(analytics, sessions, quizzes, weak_subjects, medium_ def _generate_fallback_plan_items(upcoming_sessions, weak_subjects, medium_subjects, available_quizzes, analytics): - """Rule-based fallback when AI is unavailable.""" + """Rule-based study plan item generation.""" items = [] # 1. Upcoming sessions diff --git a/web/signals.py b/web/signals.py index 7a391bdf0..710f92d26 100644 --- a/web/signals.py +++ b/web/signals.py @@ -71,11 +71,7 @@ def invalidate_session_cache(sender, instance, **kwargs): @receiver(post_save, sender=UserQuiz) def update_subject_strength_on_quiz_complete(sender, instance, **kwargs): - """ - Automatically update SubjectStrength when any student completes a quiz. - This makes the analytics system work for every student without manual setup. - Uses a weighted moving average: 70% historical + 30% new score. - """ + """Update SubjectStrength when a quiz is completed.""" if not instance.completed or not instance.user or not instance.quiz.subject: return diff --git a/web/templates/dashboard/learning_analytics.html b/web/templates/dashboard/learning_analytics.html index 5b3cf0bed..cba9f1a3e 100644 --- a/web/templates/dashboard/learning_analytics.html +++ b/web/templates/dashboard/learning_analytics.html @@ -7,7 +7,7 @@

Learning Analytics

-

AI-powered insights into your learning journey

+

Insights into your learning progress

- + {% if analytics.ai_coaching %}
@@ -30,7 +30,7 @@

Learning Analytics<

- AI Coach + Coach

{{ analytics.ai_coaching }}

{% if analytics.learning_style_hint %} @@ -180,12 +180,12 @@

- +
- +

- AI Study Tips + Study Tips

{% if analytics.ai_study_tips %}
    @@ -208,7 +208,7 @@

3 -

Generate a study plan to get a personalized roadmap for the next 2 weeks.

+

Generate a study plan to get a roadmap for the next 2 weeks.

{% endif %} @@ -273,10 +273,10 @@

{% endif %}

- +

- Smart Recommendations + Recommendations

{% for rec in analytics.recommendations %} @@ -331,12 +331,12 @@

{{ pc.cour

Ready to level up?

-

Generate an AI-powered study plan tailored to your strengths, weaknesses, and schedule.

+

Generate a study plan based on your strengths, weaknesses, and schedule.

{% csrf_token %}
diff --git a/web/templates/dashboard/study_plan.html b/web/templates/dashboard/study_plan.html index 6eade4b09..0e92799f2 100644 --- a/web/templates/dashboard/study_plan.html +++ b/web/templates/dashboard/study_plan.html @@ -7,7 +7,7 @@

No Active Study Plan

- Generate an AI-powered study plan tailored to your quiz performance, attendance, and course progress. The AI analyzes your strengths and weaknesses to create a realistic 2-week roadmap. + Generate a study plan based on your quiz scores, attendance, and course progress.

{% csrf_token %} diff --git a/web/views.py b/web/views.py index 1e4789cc9..c6debc8c5 100644 --- a/web/views.py +++ b/web/views.py @@ -8851,7 +8851,7 @@ def leave_session_waiting_room(request, course_slug): @login_required def learning_analytics_dashboard(request): - """Dashboard showing AI-powered learning analytics and insights.""" + """Learning analytics dashboard view.""" from .recommendations import get_learning_analytics analytics = get_learning_analytics(request.user) @@ -8865,7 +8865,7 @@ def learning_analytics_dashboard(request): @login_required def study_plan_view(request): - """View the user's active study plan.""" + """Active study plan view.""" plan = StudyPlan.objects.filter(user=request.user, status="active").first() past_plans = StudyPlan.objects.filter(user=request.user).exclude(status="active").order_by("-created_at")[:5] context = { @@ -8878,7 +8878,7 @@ def study_plan_view(request): @login_required @require_POST def generate_study_plan_view(request): - """Generate or regenerate a study plan.""" + """Generate a study plan.""" from .recommendations import generate_study_plan generate_study_plan(request.user) @@ -8889,7 +8889,7 @@ def generate_study_plan_view(request): @login_required @require_POST def complete_study_plan_item(request, item_id): - """Mark a study plan item as complete (AJAX endpoint).""" + """Mark a study plan item as complete.""" item = get_object_or_404(StudyPlanItem, id=item_id, plan__user=request.user) item.mark_complete() plan = item.plan From bd95f93f77bccc5074d83c56c56fd9b7779b8c0c Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Tue, 3 Mar 2026 00:30:30 +0530 Subject: [PATCH 3/3] fix: address code review findings for learning analytics feature --- tests/test_trackers.py | 42 +-- .../0065_studyplanitem_priority_rank.py | 31 +++ ...0066_studyplan_unique_active_constraint.py | 20 ++ web/models.py | 25 +- web/recommendations.py | 244 ++++++++++-------- web/signals.py | 21 +- .../dashboard/learning_analytics.html | 18 +- web/templates/dashboard/student.html | 2 +- web/templates/dashboard/study_plan.html | 11 +- web/views.py | 26 +- 10 files changed, 285 insertions(+), 155 deletions(-) create mode 100644 web/migrations/0065_studyplanitem_priority_rank.py create mode 100644 web/migrations/0066_studyplan_unique_active_constraint.py diff --git a/tests/test_trackers.py b/tests/test_trackers.py index 2bbbe80ff..d70306848 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -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", @@ -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) @@ -60,8 +66,8 @@ def test_embed_tracker(self): class SubjectStrengthTests(TestCase): def setUp(self): self.client = Client() - self.user = User.objects.create_user(username="analyticsuser", email="analytics@test.com", password="testpassword") - self.client.login(username="analyticsuser", password="testpassword") + 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 @@ -117,8 +123,8 @@ def test_unique_together_constraint(self): class LearningAnalyticsTests(TestCase): def setUp(self): self.client = Client() - self.user = User.objects.create_user(username="learner", email="learner@test.com", password="testpassword") - self.client.login(username="learner", password="testpassword") + 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")) @@ -178,8 +184,8 @@ def test_analytics_quiz_trend(self): class StudyPlanTests(TestCase): def setUp(self): self.client = Client() - self.user = User.objects.create_user(username="planner", email="planner@test.com", password="testpassword") - self.client.login(username="planner", password="testpassword") + 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")) @@ -238,7 +244,7 @@ def test_complete_study_plan_item(self): 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="testpassword") + 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, @@ -268,7 +274,7 @@ def test_plan_completion_percentage(self): class SubjectStrengthSignalTests(TestCase): def setUp(self): - self.user = User.objects.create_user(username="signaluser", email="signal@test.com", password="testpassword") + self.user = User.objects.create_user(username="signaluser", email="signal@test.com", password=TEST_PASSWORD) from web.models import Quiz, Subject diff --git a/web/migrations/0065_studyplanitem_priority_rank.py b/web/migrations/0065_studyplanitem_priority_rank.py new file mode 100644 index 000000000..b4cbbb6ef --- /dev/null +++ b/web/migrations/0065_studyplanitem_priority_rank.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +PRIORITY_RANK_MAP = {"high": 3, "medium": 2, "low": 1} + + +def backfill_priority_rank(apps, schema_editor): + StudyPlanItem = apps.get_model("web", "StudyPlanItem") + for item in StudyPlanItem.objects.all().iterator(): + item.priority_rank = PRIORITY_RANK_MAP.get(item.priority, 2) + item.save(update_fields=["priority_rank"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0064_add_learning_analytics_models"), + ] + + operations = [ + migrations.AddField( + model_name="studyplanitem", + name="priority_rank", + field=models.PositiveIntegerField(default=2), + ), + migrations.RunPython(backfill_priority_rank, migrations.RunPython.noop), + migrations.AlterModelOptions( + name="studyplanitem", + options={"ordering": ["order", "-priority_rank", "due_date"]}, + ), + ] diff --git a/web/migrations/0066_studyplan_unique_active_constraint.py b/web/migrations/0066_studyplan_unique_active_constraint.py new file mode 100644 index 000000000..31a913f3c --- /dev/null +++ b/web/migrations/0066_studyplan_unique_active_constraint.py @@ -0,0 +1,20 @@ +from django.db import migrations, models +from django.db.models import Q + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0065_studyplanitem_priority_rank"), + ] + + operations = [ + migrations.AddConstraint( + model_name="studyplan", + constraint=models.UniqueConstraint( + fields=["user"], + condition=Q(status="active"), + name="unique_active_studyplan_per_user", + ), + ), + ] diff --git a/web/models.py b/web/models.py index 1f0d8d409..79f554f36 100644 --- a/web/models.py +++ b/web/models.py @@ -15,7 +15,7 @@ from django.core.mail import send_mail from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Avg +from django.db.models import Avg, Q from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse @@ -3201,13 +3201,14 @@ def __str__(self): def update_from_quiz(self, score, max_score): """Update strength score from a quiz result.""" if max_score > 0: - new_score = (score / max_score) * 100 + clamped_score = max(0, min(score, max_score)) + new_score = (clamped_score / max_score) * 100 if self.total_quizzes == 0: self.strength_score = new_score else: self.strength_score = (0.7 * self.strength_score) + (0.3 * new_score) self.total_quizzes += 1 - self.total_correct += score + self.total_correct += clamped_score self.total_questions += max_score self.save() @@ -3232,6 +3233,13 @@ class StudyPlan(models.Model): class Meta: ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["user"], + condition=Q(status="active"), + name="unique_active_studyplan_per_user", + ), + ] def __str__(self): return f"{self.user.username} - {self.title}" @@ -3264,11 +3272,14 @@ class StudyPlanItem(models.Model): ("low", "Low"), ] + PRIORITY_RANK_MAP = {"high": 3, "medium": 2, "low": 1} + plan = models.ForeignKey(StudyPlan, on_delete=models.CASCADE, related_name="items") item_type = models.CharField(max_length=10, choices=ITEM_TYPE_CHOICES, default="review") title = models.CharField(max_length=200) description = models.TextField(blank=True) priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default="medium") + priority_rank = models.PositiveIntegerField(default=2) course = models.ForeignKey(Course, on_delete=models.SET_NULL, null=True, blank=True) session = models.ForeignKey(Session, on_delete=models.SET_NULL, null=True, blank=True) quiz = models.ForeignKey("Quiz", on_delete=models.SET_NULL, null=True, blank=True) @@ -3279,12 +3290,18 @@ class StudyPlanItem(models.Model): order = models.PositiveIntegerField(default=0) class Meta: - ordering = ["order", "-priority", "due_date"] + ordering = ["order", "-priority_rank", "due_date"] + + def save(self, *args, **kwargs): + self.priority_rank = self.PRIORITY_RANK_MAP.get(self.priority, 2) + super().save(*args, **kwargs) def __str__(self): return f"{self.title} ({'✓' if self.is_completed else '○'})" def mark_complete(self): + if self.is_completed: + return self.is_completed = True self.completed_at = timezone.now() self.save() diff --git a/web/recommendations.py b/web/recommendations.py index cce46adde..71732fbab 100644 --- a/web/recommendations.py +++ b/web/recommendations.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +import logging +from typing import Any + from django.db.models import Avg, Count, Q from .models import Course @@ -105,7 +110,6 @@ def get_learning_analytics(user): logger = logging.getLogger(__name__) now = timezone.now() thirty_days_ago = now - timedelta(days=30) - ninety_days_ago = now - timedelta(days=90) # --- Quiz Performance (all time + recent trend) --- quiz_attempts = UserQuiz.objects.filter(user=user, completed=True) @@ -203,8 +207,8 @@ def get_learning_analytics(user): if hasattr(enrollment, "progress"): try: completed_sessions = enrollment.progress.completed_sessions.count() - except Exception: - pass + except AttributeError as e: + logger.warning("Could not read completed_sessions for enrollment %s: %s", enrollment.id, e) remaining = total_sessions - completed_sessions if learning_velocity > 0 and remaining > 0: @@ -286,7 +290,7 @@ def get_learning_analytics(user): } -def _generate_ai_insights(student_profile, logger): +def _generate_ai_insights(student_profile: dict[str, Any], logger: logging.Logger) -> dict[str, Any]: """Call OpenAI for learning insights. Returns empty dict on failure.""" import json @@ -299,7 +303,7 @@ def _generate_ai_insights(student_profile, logger): try: import openai - client = openai.OpenAI(api_key=api_key) + client = openai.OpenAI(api_key=api_key, timeout=20.0) prompt = f"""You are an expert educational AI tutor analyzing a student's learning data. Based on this student profile, provide personalized, actionable insights. @@ -368,7 +372,14 @@ def _generate_ai_insights(student_profile, logger): return {} -def _generate_fallback_recommendations(weaknesses, attendance_rate, learning_velocity, risk_courses, total_attempts, avg_score): +def _generate_fallback_recommendations( + weaknesses: list[dict[str, Any]], + attendance_rate: float, + learning_velocity: float, + risk_courses: list[dict[str, Any]], + total_attempts: int, + avg_score: float, +) -> list[dict[str, Any]]: """Rule-based recommendations fallback.""" recommendations = [] if weaknesses: @@ -433,113 +444,120 @@ def generate_study_plan(user): logger = logging.getLogger(__name__) now = timezone.now() - # Deactivate old plans - StudyPlan.objects.filter(user=user, status="active").update(status="paused") - - analytics = get_learning_analytics(user) - - # Gather context for AI - enrollments = Enrollment.objects.filter(student=user, status__in=["approved", "pending"]).select_related("course") - upcoming_sessions = Session.objects.filter( - course__enrollments__in=enrollments, start_time__gt=now, start_time__lte=now + timedelta(days=14) - ).order_by("start_time").select_related("course")[:15] - - weak_subjects = SubjectStrength.objects.filter(user=user, strength_score__lt=50).select_related("subject") - medium_subjects = SubjectStrength.objects.filter( - user=user, strength_score__gte=50, strength_score__lt=70 - ).select_related("subject") - - available_quizzes = [] - for ss in list(weak_subjects) + list(medium_subjects): - quizzes = Quiz.objects.filter(subject=ss.subject, status="published")[:2] - for q in quizzes: - available_quizzes.append({"id": q.id, "title": q.title, "subject": ss.subject.name}) - - session_list = [] - for s in upcoming_sessions: - session_list.append({ - "id": s.id, - "title": s.title, - "course": s.course.title, - "date": s.start_time.strftime("%Y-%m-%d %H:%M"), - }) - - # Try plan generation via OpenAI - ai_plan_items = _generate_ai_study_plan( - analytics, session_list, available_quizzes, - [{"subject": ss.subject.name, "score": ss.strength_score} for ss in weak_subjects], - [{"subject": ss.subject.name, "score": ss.strength_score} for ss in medium_subjects], - user.get_full_name() or user.username, - logger, - ) - - plan = StudyPlan.objects.create( - user=user, - title=f"Study Plan — {now.strftime('%B %d, %Y')}", - description=ai_plan_items.get("plan_description", "Plan based on your learning analytics."), - daily_goal_minutes=ai_plan_items.get("daily_goal_minutes", 30), - weekly_goal_sessions=ai_plan_items.get("weekly_goal_sessions", 5), - ) - - items = ai_plan_items.get("items", []) - if not items: - # Fallback to rule-based generation - items = _generate_fallback_plan_items( - upcoming_sessions, weak_subjects, medium_subjects, available_quizzes, analytics + from django.db import transaction + + with transaction.atomic(): + # Deactivate old plans + StudyPlan.objects.select_for_update().filter(user=user, status="active").update(status="paused") + + analytics = get_learning_analytics(user) + + # Gather context for AI + enrollments = Enrollment.objects.filter(student=user, status__in=["approved", "pending"]).select_related("course") + upcoming_sessions = Session.objects.filter( + course__enrollments__in=enrollments, start_time__gt=now, start_time__lte=now + timedelta(days=14) + ).order_by("start_time").select_related("course")[:15] + + weak_subjects = SubjectStrength.objects.filter(user=user, strength_score__lt=50).select_related("subject") + medium_subjects = SubjectStrength.objects.filter( + user=user, strength_score__gte=50, strength_score__lt=70 + ).select_related("subject") + + available_quizzes = [] + for ss in list(weak_subjects) + list(medium_subjects): + quizzes = Quiz.objects.filter(subject=ss.subject, status="published")[:2] + for q in quizzes: + available_quizzes.append({"id": q.id, "title": q.title, "subject": ss.subject.name}) + + session_list = [] + for s in upcoming_sessions: + session_list.append({ + "id": s.id, + "title": s.title, + "course": s.course.title, + "date": s.start_time.strftime("%Y-%m-%d %H:%M"), + }) + + # Try plan generation via OpenAI + ai_plan_items = _generate_ai_study_plan( + analytics, session_list, available_quizzes, + [{"subject": ss.subject.name, "score": ss.strength_score} for ss in weak_subjects], + [{"subject": ss.subject.name, "score": ss.strength_score} for ss in medium_subjects], + user.get_full_name() or user.username, + logger, ) - # Create StudyPlanItem objects - session_map = {s.id: s for s in upcoming_sessions} - quiz_map = {q["id"]: q for q in available_quizzes} - course_map = {} - for enrollment in enrollments: - course_map[enrollment.course.title] = enrollment.course - - for idx, item_data in enumerate(items): - item_kwargs = { - "plan": plan, - "item_type": item_data.get("type", "review"), - "title": item_data.get("title", "Study task"), - "description": item_data.get("description", ""), - "priority": item_data.get("priority", "medium"), - "estimated_minutes": item_data.get("minutes", 30), - "order": idx + 1, - } - - # Link to session if referenced - session_id = item_data.get("session_id") - if session_id and session_id in session_map: - item_kwargs["session"] = session_map[session_id] - item_kwargs["course"] = session_map[session_id].course - - # Link to quiz if referenced - quiz_id = item_data.get("quiz_id") - if quiz_id: - try: - item_kwargs["quiz"] = Quiz.objects.get(id=quiz_id) - except Quiz.DoesNotExist: - pass - - # Link to course by name - course_name = item_data.get("course_name") - if course_name and course_name in course_map: - item_kwargs["course"] = course_map[course_name] - - # Parse due date - due = item_data.get("due_date") - if due: - try: - from datetime import datetime - item_kwargs["due_date"] = datetime.strptime(due, "%Y-%m-%d").date() - except (ValueError, TypeError): - pass - - StudyPlanItem.objects.create(**item_kwargs) + plan = StudyPlan.objects.create( + user=user, + title=f"Study Plan — {now.strftime('%B %d, %Y')}", + description=ai_plan_items.get("plan_description", "Plan based on your learning analytics."), + daily_goal_minutes=ai_plan_items.get("daily_goal_minutes", 30), + weekly_goal_sessions=ai_plan_items.get("weekly_goal_sessions", 5), + ) - return plan + items = ai_plan_items.get("items", []) + if not items: + items = _generate_fallback_plan_items( + upcoming_sessions, weak_subjects, medium_subjects, available_quizzes, analytics + ) + # Create StudyPlanItem objects + session_map = {s.id: s for s in upcoming_sessions} + quiz_map = {q["id"]: q for q in available_quizzes} + course_map = {} + for enrollment in enrollments: + course_map[enrollment.course.title] = enrollment.course + + for idx, item_data in enumerate(items): + item_kwargs = { + "plan": plan, + "item_type": item_data.get("type", "review"), + "title": item_data.get("title", "Study task"), + "description": item_data.get("description", ""), + "priority": item_data.get("priority", "medium"), + "estimated_minutes": item_data.get("minutes", 30), + "order": idx + 1, + } -def _generate_ai_study_plan(analytics, sessions, quizzes, weak_subjects, medium_subjects, student_name, logger): + session_id = item_data.get("session_id") + if session_id and session_id in session_map: + item_kwargs["session"] = session_map[session_id] + item_kwargs["course"] = session_map[session_id].course + + quiz_id = item_data.get("quiz_id") + if quiz_id and quiz_id in quiz_map: + try: + from .models import Quiz + item_kwargs["quiz"] = Quiz.objects.get(id=quiz_id) + except Quiz.DoesNotExist: + pass + + course_name = item_data.get("course_name") + if course_name and course_name in course_map: + item_kwargs["course"] = course_map[course_name] + + due = item_data.get("due_date") + if due: + try: + from datetime import datetime + item_kwargs["due_date"] = datetime.strptime(due, "%Y-%m-%d").date() + except (ValueError, TypeError): + pass + + StudyPlanItem.objects.create(**item_kwargs) + + return plan + + +def _generate_ai_study_plan( + analytics: dict[str, Any], + sessions: list[dict[str, Any]], + quizzes: list[dict[str, Any]], + weak_subjects: list[dict[str, Any]], + medium_subjects: list[dict[str, Any]], + student_name: str, + logger: logging.Logger, +) -> dict[str, Any]: """Call OpenAI to generate study plan items. Returns empty on failure.""" import json @@ -552,7 +570,7 @@ def _generate_ai_study_plan(analytics, sessions, quizzes, weak_subjects, medium_ try: import openai - client = openai.OpenAI(api_key=api_key) + client = openai.OpenAI(api_key=api_key, timeout=20.0) prompt = f"""You are an expert educational planner creating a 2-week personalized study plan. @@ -627,7 +645,13 @@ def _generate_ai_study_plan(analytics, sessions, quizzes, weak_subjects, medium_ return {"items": []} -def _generate_fallback_plan_items(upcoming_sessions, weak_subjects, medium_subjects, available_quizzes, analytics): +def _generate_fallback_plan_items( + upcoming_sessions: Any, + weak_subjects: Any, + medium_subjects: Any, + available_quizzes: list[dict[str, Any]], + analytics: dict[str, Any], +) -> list[dict[str, Any]]: """Rule-based study plan item generation.""" items = [] diff --git a/web/signals.py b/web/signals.py index 710f92d26..a3c80cdaa 100644 --- a/web/signals.py +++ b/web/signals.py @@ -1,6 +1,6 @@ from allauth.account.signals import user_signed_up from django.core.cache import cache -from django.db.models.signals import m2m_changed, post_delete, post_save +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save from django.dispatch import receiver from .models import CourseProgress, Enrollment, LearningStreak, Session, SessionAttendance, SubjectStrength, UserQuiz @@ -69,10 +69,25 @@ def invalidate_session_cache(sender, instance, **kwargs): invalidate_progress_cache(enrollment.student) +@receiver(pre_save, sender=UserQuiz) +def cache_previous_completed_state(sender, instance, **kwargs): + if instance.pk: + try: + instance._prev_completed = UserQuiz.objects.filter(pk=instance.pk).values_list("completed", flat=True).first() + except UserQuiz.DoesNotExist: + instance._prev_completed = None + else: + instance._prev_completed = None + + @receiver(post_save, sender=UserQuiz) -def update_subject_strength_on_quiz_complete(sender, instance, **kwargs): +def update_subject_strength_on_quiz_complete(sender, instance, created, **kwargs): """Update SubjectStrength when a quiz is completed.""" - if not instance.completed or not instance.user or not instance.quiz.subject: + was_completed = getattr(instance, "_prev_completed", None) + if not (created and instance.completed) and not (not created and instance.completed and not was_completed): + return + + if not instance.user or not instance.quiz.subject: return if instance.max_score <= 0: diff --git a/web/templates/dashboard/learning_analytics.html b/web/templates/dashboard/learning_analytics.html index cba9f1a3e..dcaf647f5 100644 --- a/web/templates/dashboard/learning_analytics.html +++ b/web/templates/dashboard/learning_analytics.html @@ -88,8 +88,8 @@

{{ analytics.attend

Weekly Velocity

{{ analytics.learning_velocity }}

-
- +
+

Sessions per week

@@ -100,8 +100,8 @@

{{ analytics.learni

Study Hours

{{ analytics.total_study_hours }}

-
- +
+

Estimated total

@@ -228,7 +228,7 @@

{{ rc.course }}

{{ rc.progress }}%
-
+

{{ rc.remaining_sessions }} of {{ rc.total_sessions }} sessions remaining

@@ -303,7 +303,7 @@

@@ -45,7 +45,7 @@

{{ plan.title }}
+ data-progress="{{ plan.completion_percentage }}">
{{ plan.completion_percentage }}% @@ -155,6 +155,13 @@

No Active Stud