from django.contrib.auth.decorators import login_required from django.contrib import messages from datetime import timedelta from django.db import transaction from django.db.models import Avg, DurationField, ExpressionWrapper, F from django.shortcuts import redirect, render from django.utils import timezone from django.views.decorators.http import require_POST from exams.models import StudentAnswer, StudentAttempt from .access import has_lifetime_premium from .leaderboard import build_leaderboard from .models import DailyMissionClaim, StudentProfile from .forms import StudentSettingsForm DAILY_MISSION_DEFINITIONS = { "complete_test": { "title": "Finish a practice test", "description": "Complete any available IELTS test today.", "target": 1, "xp": 30, "icon": "bi-clipboard2-check", }, "answer_questions": { "title": "Answer 10 questions", "description": "Build momentum with objective practice today.", "target": 10, "xp": 20, "icon": "bi-ui-checks-grid", }, "record_speaking": { "title": "Record a speaking response", "description": "Practise speaking aloud and submit one recording.", "target": 1, "xp": 25, "icon": "bi-mic", }, } def premium(request): return render( request, "accounts/premium.html", {"has_lifetime_premium": has_lifetime_premium(request.user)}, ) @login_required def leaderboard(request): period = request.GET.get("period", "week") skill = request.GET.get("skill", "overall") country = request.GET.get("country", "") if period not in {"week", "month", "all"}: period = "week" if skill not in {"overall", "listening", "reading", "writing", "speaking"}: skill = "overall" profile, _ = StudentProfile.objects.get_or_create(user=request.user) rows, current_row, countries = build_leaderboard( period=period, skill=skill, country=country, current_user=request.user, ) next_row = None improvement_needed = None if current_row and current_row["rank"] > 1: next_row = rows[current_row["rank"] - 2] improvement_needed = max( 0.1, round(next_row["band"] - current_row["band"] + 0.1, 1) ) completed_tests = StudentAttempt.objects.filter( student=request.user, is_complete=True ).count() rank_progress = ( round((len(rows) - current_row["rank"] + 1) / len(rows) * 100) if current_row and rows else 0 ) achievements = [ { "icon": "bi-trophy", "title": "Top 10", "description": "Reached the top 10 this month", "earned": bool(current_row and current_row["rank"] <= 10), }, { "icon": "bi-fire", "title": f"{profile.streak}-Day Streak", "description": f"Practiced for {profile.streak} consecutive days", "earned": profile.streak >= 7, }, { "icon": "bi-bullseye", "title": "Band 7 Achieved", "description": "Scored Band 7.0 or higher", "earned": bool(current_row and current_row["band"] >= 7), }, { "icon": "bi-graph-up-arrow", "title": "Big Improvement", "description": "Improved your score by 1.0 band", "earned": False, }, { "icon": "bi-award", "title": "10 Tests Completed", "description": "Completed 10 IELTS mock tests", "earned": completed_tests >= 10, }, ] return render( request, "accounts/leaderboard.html", { "rows": rows, "top_three": rows[:3], "current_row": current_row, "next_row": next_row, "improvement_needed": improvement_needed, "countries": countries, "selected_period": period, "selected_skill": skill, "selected_country": country, "profile": profile, "completed_tests": completed_tests, "rank_progress": rank_progress, "achievements": achievements, "has_demo_rows": any(row["demo"] for row in rows), }, ) def get_daily_missions(user): """Return claimable, data-driven missions for the user's local day.""" today = timezone.localdate() completed_attempts = StudentAttempt.objects.filter( student=user, is_complete=True, submitted_at__date=today ) answers_today = StudentAnswer.objects.filter(attempt__in=completed_attempts) progress_by_key = { "complete_test": completed_attempts.count(), "answer_questions": answers_today.count(), "record_speaking": answers_today.exclude(audio_response="").filter( audio_response__isnull=False ).count(), } claimed_keys = set( DailyMissionClaim.objects.filter(user=user, completed_on=today).values_list( "mission_key", flat=True ) ) missions = [] for key, definition in DAILY_MISSION_DEFINITIONS.items(): progress = min(definition["target"], progress_by_key[key]) missions.append( { "key": key, **definition, "progress": progress, "progress_percent": round(100 * progress / definition["target"]), "complete": progress >= definition["target"], "claimed": key in claimed_keys, } ) return missions @login_required def student_dashboard(request): """Display progress metrics derived from the signed-in student's data.""" profile, _ = StudentProfile.objects.get_or_create(user=request.user) attempts = StudentAttempt.objects.filter( student=request.user, is_complete=True, ).select_related("exam_set").prefetch_related("answers__question__section").order_by("-submitted_at", "-started_at") tests_taken = attempts.count() duration_expression = ExpressionWrapper( F("submitted_at") - F("started_at"), output_field=DurationField(), ) average_duration = ( attempts.exclude(submitted_at__isnull=True) .annotate(duration=duration_expression) .aggregate(average=Avg("duration"))["average"] ) if average_duration: total_minutes = int(average_duration.total_seconds() // 60) hours, minutes = divmod(total_minutes, 60) average_time = f"{hours}h {minutes}m" if hours else f"{minutes}m" else: average_time = "No data" graded_answers = StudentAnswer.objects.filter( attempt__student=request.user, attempt__is_complete=True, is_correct__isnull=False, ) total_answers = graded_answers.count() correct_answers = graded_answers.filter(is_correct=True).count() accuracy = round((correct_answers / total_answers) * 100) if total_answers else 0 def objective_skill_accuracy(section_type): answers = graded_answers.filter(question__section__section_type=section_type) total = answers.count() correct = answers.filter(is_correct=True).count() return round((correct / total) * 100) if total else 0 def reviewed_skill_score(section_type): average = StudentAnswer.objects.filter( attempt__student=request.user, attempt__is_complete=True, question__section__section_type=section_type, manual_score__isnull=False, ).aggregate(average=Avg("manual_score"))["average"] return round((average / 9) * 100) if average is not None else 0 skills = [ {"name": "Reading", "value": objective_skill_accuracy("reading"), "tone": "green", "icon": "bi-book"}, {"name": "Listening", "value": objective_skill_accuracy("listening"), "tone": "blue", "icon": "bi-headphones"}, {"name": "Writing", "value": reviewed_skill_score("writing"), "tone": "orange", "icon": "bi-pencil"}, {"name": "Speaking", "value": reviewed_skill_score("speaking"), "tone": "purple", "icon": "bi-mic"}, ] total_study_minutes = 0 trend_attempts = [] completed_dates = set() for attempt in reversed(list(attempts)): if attempt.submitted_at: completed_dates.add(timezone.localtime(attempt.submitted_at).date()) total_study_minutes += max( 0, int((attempt.submitted_at - attempt.started_at).total_seconds() // 60) ) objective_answers = [ answer for answer in attempt.answers.all() if answer.is_correct is not None ] manual_answers = [ answer for answer in attempt.answers.all() if answer.manual_score is not None ] if objective_answers: attempt_value = round( 100 * sum(answer.is_correct for answer in objective_answers) / len(objective_answers) ) attempt_label = f"{attempt_value}%" elif manual_answers: average_band = sum(answer.manual_score for answer in manual_answers) / len(manual_answers) attempt_value = round((average_band / 9) * 100) attempt_label = f"Band {average_band:.1f}" else: continue trend_attempts.append( { "label": timezone.localtime(attempt.submitted_at).strftime("%b %d") if attempt.submitted_at else "Completed", "value": attempt_value, "display": attempt_label, "title": attempt.exam_set.title, } ) trend_attempts = trend_attempts[-7:] for trend in trend_attempts: trend["height"] = max(9, trend["value"]) trend_change = None if len(trend_attempts) > 1: trend_change = trend_attempts[-1]["value"] - trend_attempts[0]["value"] question_type_labels = { "mcq": "Multiple choice", "gap": "Gap fill", "matching": "Matching", } question_type_accuracy = [] for question_type, label in question_type_labels.items(): answers = graded_answers.filter(question__question_type=question_type) answer_total = answers.count() if answer_total: question_type_accuracy.append( { "name": label, "value": round(100 * answers.filter(is_correct=True).count() / answer_total), } ) measured_skills = [skill for skill in skills if skill["value"] or ( graded_answers.filter(question__section__section_type=skill["name"].lower()).exists() )] weakest_skill = min(measured_skills, key=lambda skill: skill["value"], default=None) today = timezone.localdate() streak_days = [ { "label": (today - timedelta(days=offset)).strftime("%a"), "active": (today - timedelta(days=offset)) in completed_dates, } for offset in range(6, -1, -1) ] recent_attempts = [] for attempt in attempts[:5]: answers = attempt.answers.exclude(is_correct=None) total = answers.count() correct = answers.filter(is_correct=True).count() score = round((correct / total) * 100) if total else None duration = "—" if attempt.submitted_at: minutes = max( 0, int((attempt.submitted_at - attempt.started_at).total_seconds() // 60), ) hours, remaining_minutes = divmod(minutes, 60) duration = ( f"{hours}h {remaining_minutes}m" if hours else f"{remaining_minutes}m" ) recent_attempts.append( {"attempt": attempt, "score": score, "duration": duration} ) xp_goal = 1000 xp_progress = min(100, round((profile.xp / xp_goal) * 100)) if profile.xp > 0 else 0 daily_missions = get_daily_missions(request.user) return render( request, "accounts/student_dashboard.html", { "profile": profile, "tests_taken": tests_taken, "avg_time": average_time, "accuracy": accuracy, "skills": skills, "recent_attempts": recent_attempts, "xp_goal": xp_goal, "xp_progress": xp_progress, "total_study_minutes": total_study_minutes, "trend_attempts": trend_attempts, "trend_change": trend_change, "question_type_accuracy": question_type_accuracy, "weakest_skill": weakest_skill, "streak_days": streak_days, "daily_missions": daily_missions, }, ) @login_required @require_POST def claim_daily_mission(request, mission_key): if mission_key not in DAILY_MISSION_DEFINITIONS: messages.error(request, "That daily mission is not available.") return redirect("student_dashboard") mission = next( mission for mission in get_daily_missions(request.user) if mission["key"] == mission_key ) if not mission["complete"]: messages.error(request, "Complete this mission before claiming its XP.") return redirect("student_dashboard") if mission["claimed"]: messages.info(request, "You already claimed this mission today.") return redirect("student_dashboard") with transaction.atomic(): claim, created = DailyMissionClaim.objects.get_or_create( user=request.user, mission_key=mission_key, completed_on=timezone.localdate(), defaults={"xp_awarded": mission["xp"]}, ) if created: profile = StudentProfile.objects.select_for_update().get(user=request.user) profile.xp += claim.xp_awarded profile.save(update_fields=["xp"]) messages.success(request, f"Mission complete! You earned {claim.xp_awarded} XP.") else: messages.info(request, "You already claimed this mission today.") return redirect("student_dashboard") @login_required def student_settings(request): """Allow students to manage the personal settings shown on the dashboard.""" profile, _ = StudentProfile.objects.get_or_create(user=request.user) form = StudentSettingsForm( request.POST or None, user=request.user, profile=profile, ) if request.method == "POST" and form.is_valid(): form.save() messages.success(request, "Your profile settings have been updated.") return redirect("student_settings") return render( request, "accounts/student_settings.html", {"form": form, "profile": profile}, )