Initial commit
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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},
|
||||
)
|
||||
Reference in New Issue
Block a user