Initial commit
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from .models import PremiumEntitlement
|
||||
|
||||
|
||||
def has_lifetime_premium(user):
|
||||
"""Return whether a user may access Lifetime Premium content."""
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
return PremiumEntitlement.objects.filter(
|
||||
user=user,
|
||||
revoked_at__isnull=True,
|
||||
).exists()
|
||||
|
||||
|
||||
def can_access_exam(user, exam_set):
|
||||
return (
|
||||
exam_set.access_level == exam_set.ACCESS_FREE
|
||||
or has_lifetime_premium(user)
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.contrib import admin
|
||||
from django.utils import timezone
|
||||
from unfold.admin import ModelAdmin
|
||||
|
||||
from .access import has_lifetime_premium
|
||||
from .models import DailyMissionClaim, PremiumEntitlement, StudentProfile
|
||||
|
||||
|
||||
@admin.register(StudentProfile)
|
||||
class StudentProfileAdmin(ModelAdmin):
|
||||
list_display = ("user", "premium_status", "target_band", "daily_goal", "xp", "streak")
|
||||
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name")
|
||||
list_filter = ("target_band", "daily_goal")
|
||||
actions = ("grant_lifetime_premium", "revoke_lifetime_premium")
|
||||
|
||||
@admin.display(description="Membership", boolean=True)
|
||||
def premium_status(self, obj):
|
||||
return has_lifetime_premium(obj.user)
|
||||
|
||||
@admin.action(description="Grant Lifetime Premium")
|
||||
def grant_lifetime_premium(self, request, queryset):
|
||||
granted = 0
|
||||
for profile in queryset.select_related("user"):
|
||||
entitlement, created = PremiumEntitlement.objects.get_or_create(
|
||||
user=profile.user,
|
||||
defaults={"source": PremiumEntitlement.SOURCE_ADMIN},
|
||||
)
|
||||
if entitlement.revoked_at is not None:
|
||||
entitlement.revoked_at = None
|
||||
entitlement.save(update_fields=["revoked_at"])
|
||||
if created or entitlement.is_active:
|
||||
granted += 1
|
||||
self.message_user(request, f"Lifetime Premium is active for {granted} user(s).")
|
||||
|
||||
@admin.action(description="Revoke Lifetime Premium")
|
||||
def revoke_lifetime_premium(self, request, queryset):
|
||||
updated = PremiumEntitlement.objects.filter(
|
||||
user__studentprofile__in=queryset,
|
||||
revoked_at__isnull=True,
|
||||
).update(revoked_at=timezone.now())
|
||||
self.message_user(request, f"Revoked Lifetime Premium for {updated} user(s).")
|
||||
|
||||
|
||||
@admin.register(PremiumEntitlement)
|
||||
class PremiumEntitlementAdmin(ModelAdmin):
|
||||
list_display = ("user", "status", "source", "order_reference", "granted_at", "revoked_at")
|
||||
list_filter = ("source", "revoked_at", "granted_at")
|
||||
search_fields = ("user__username", "user__email", "order_reference")
|
||||
readonly_fields = ("granted_at",)
|
||||
list_select_related = ("user",)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def status(self, obj):
|
||||
return "Active" if obj.is_active else "Revoked"
|
||||
|
||||
|
||||
@admin.register(DailyMissionClaim)
|
||||
class DailyMissionClaimAdmin(ModelAdmin):
|
||||
list_display = ("user", "mission_key", "completed_on", "xp_awarded", "claimed_at")
|
||||
list_filter = ("mission_key", "completed_on")
|
||||
search_fields = ("user__username", "user__email")
|
||||
readonly_fields = ("claimed_at",)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
|
||||
def ready(self):
|
||||
import accounts.signals
|
||||
@@ -0,0 +1,65 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
|
||||
from .models import StudentProfile
|
||||
|
||||
|
||||
class StudentSettingsForm(forms.Form):
|
||||
first_name = forms.CharField(max_length=150, required=False)
|
||||
last_name = forms.CharField(max_length=150, required=False)
|
||||
display_name = forms.CharField(
|
||||
max_length=40,
|
||||
required=False,
|
||||
help_text="Shown on the public leaderboard instead of your full name.",
|
||||
)
|
||||
country = forms.CharField(max_length=80, required=False)
|
||||
target_band = forms.DecimalField(
|
||||
min_value=4,
|
||||
max_value=9,
|
||||
decimal_places=1,
|
||||
widget=forms.NumberInput(attrs={"min": "4", "max": "9", "step": "0.5"}),
|
||||
)
|
||||
daily_goal = forms.IntegerField(min_value=10, max_value=300)
|
||||
|
||||
def __init__(self, *args, user, profile, **kwargs):
|
||||
self.user = user
|
||||
self.profile = profile
|
||||
kwargs.setdefault(
|
||||
"initial",
|
||||
{
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"display_name": profile.display_name,
|
||||
"country": profile.country,
|
||||
"target_band": profile.target_band,
|
||||
"daily_goal": profile.daily_goal,
|
||||
},
|
||||
)
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs["class"] = "settings-input"
|
||||
|
||||
def clean_target_band(self):
|
||||
target_band = self.cleaned_data["target_band"]
|
||||
if target_band % Decimal("0.5"):
|
||||
raise forms.ValidationError(
|
||||
"Choose an IELTS band score in 0.5 increments."
|
||||
)
|
||||
return target_band
|
||||
|
||||
@transaction.atomic
|
||||
def save(self):
|
||||
self.user.first_name = self.cleaned_data["first_name"].strip()
|
||||
self.user.last_name = self.cleaned_data["last_name"].strip()
|
||||
self.user.save(update_fields=["first_name", "last_name"])
|
||||
|
||||
self.profile.target_band = float(self.cleaned_data["target_band"])
|
||||
self.profile.daily_goal = self.cleaned_data["daily_goal"]
|
||||
self.profile.display_name = self.cleaned_data["display_name"].strip()
|
||||
self.profile.country = self.cleaned_data["country"].strip()
|
||||
self.profile.save(
|
||||
update_fields=["target_band", "daily_goal", "display_name", "country"]
|
||||
)
|
||||
return self.user
|
||||
@@ -0,0 +1,130 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import StudentProfile
|
||||
|
||||
|
||||
DEMO_STUDENTS = (
|
||||
{"name": "Alex", "band": 8.5, "tests": 12, "country": "United Kingdom", "trend": "up"},
|
||||
{"name": "Sarah", "band": 8.0, "tests": 10, "country": "Australia", "trend": "up"},
|
||||
{"name": "John", "band": 8.0, "tests": 15, "country": "Canada", "trend": "same"},
|
||||
{"name": "Emma", "band": 7.5, "tests": 8, "country": "United Kingdom", "trend": "up"},
|
||||
{"name": "Daniel", "band": 7.5, "tests": 11, "country": "Germany", "trend": "down"},
|
||||
{"name": "Mina", "band": 7.0, "tests": 9, "country": "South Korea", "trend": "up"},
|
||||
{"name": "Omar", "band": 7.0, "tests": 7, "country": "United Arab Emirates", "trend": "same"},
|
||||
{"name": "Layla", "band": 6.5, "tests": 13, "country": "Turkey", "trend": "up"},
|
||||
)
|
||||
|
||||
|
||||
def _rounded_half(value):
|
||||
return float(
|
||||
(Decimal(str(value)) * 2).quantize(Decimal("1"), rounding=ROUND_HALF_UP) / 2
|
||||
)
|
||||
|
||||
|
||||
def _objective_band(correct, total):
|
||||
if not total:
|
||||
return None
|
||||
percentage = correct / total
|
||||
boundaries = (
|
||||
(0.90, 9.0), (0.85, 8.5), (0.80, 8.0), (0.75, 7.5),
|
||||
(0.68, 7.0), (0.60, 6.5), (0.52, 6.0), (0.45, 5.5),
|
||||
(0.38, 5.0), (0.30, 4.5), (0.22, 4.0), (0, 3.5),
|
||||
)
|
||||
return next(band for minimum, band in boundaries if percentage >= minimum)
|
||||
|
||||
|
||||
def _attempt_band(attempt, skill="overall"):
|
||||
answers = list(attempt.answers.all())
|
||||
if skill != "overall":
|
||||
answers = [
|
||||
answer
|
||||
for answer in answers
|
||||
if answer.question.section.section_type == skill
|
||||
]
|
||||
manual = [answer.manual_score for answer in answers if answer.manual_score is not None]
|
||||
objective = [answer for answer in answers if answer.is_correct is not None]
|
||||
components = []
|
||||
if objective:
|
||||
components.append(
|
||||
_objective_band(sum(answer.is_correct for answer in objective), len(objective))
|
||||
)
|
||||
if manual:
|
||||
components.append(sum(manual) / len(manual))
|
||||
return _rounded_half(sum(components) / len(components)) if components else None
|
||||
|
||||
|
||||
def _period_start(period):
|
||||
today = timezone.localdate()
|
||||
if period == "week":
|
||||
return today - timedelta(days=today.weekday())
|
||||
if period == "month":
|
||||
return today.replace(day=1)
|
||||
return None
|
||||
|
||||
|
||||
def build_leaderboard(period="week", skill="overall", country="", current_user=None):
|
||||
start = _period_start(period)
|
||||
users = User.objects.filter(studentattempt__is_complete=True).distinct().select_related(
|
||||
"studentprofile"
|
||||
).prefetch_related(
|
||||
"studentattempt_set__answers__question__section"
|
||||
)
|
||||
rows = []
|
||||
for user in users:
|
||||
attempts = [
|
||||
attempt
|
||||
for attempt in user.studentattempt_set.all()
|
||||
if attempt.is_complete
|
||||
and attempt.submitted_at
|
||||
and (not start or timezone.localdate(attempt.submitted_at) >= start)
|
||||
]
|
||||
bands = [
|
||||
band for attempt in attempts if (band := _attempt_band(attempt, skill)) is not None
|
||||
]
|
||||
if not bands:
|
||||
continue
|
||||
try:
|
||||
profile = user.studentprofile
|
||||
except StudentProfile.DoesNotExist:
|
||||
profile, _ = StudentProfile.objects.get_or_create(user=user)
|
||||
user_country = profile.country or "Not set"
|
||||
if country and user_country != country:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"name": profile.display_name
|
||||
or user.get_full_name()
|
||||
or user.username,
|
||||
"band": _rounded_half(sum(bands) / len(bands)),
|
||||
"tests": len(bands),
|
||||
"country": user_country,
|
||||
"trend": "up",
|
||||
"demo": False,
|
||||
}
|
||||
)
|
||||
|
||||
if len(rows) < 8:
|
||||
used_names = {row["name"].casefold() for row in rows}
|
||||
for demo in DEMO_STUDENTS:
|
||||
if demo["name"].casefold() not in used_names and (
|
||||
not country or demo["country"] == country
|
||||
):
|
||||
rows.append({**demo, "user_id": None, "demo": True})
|
||||
|
||||
rows.sort(key=lambda row: (-row["band"], -row["tests"], row["name"].casefold()))
|
||||
for index, row in enumerate(rows, start=1):
|
||||
row["rank"] = index
|
||||
row["initial"] = row["name"][:1].upper()
|
||||
row["is_current"] = bool(
|
||||
current_user and row["user_id"] == current_user.id
|
||||
)
|
||||
current_row = next((row for row in rows if row["is_current"]), None)
|
||||
countries = sorted(
|
||||
{row["country"] for row in rows if row["country"] != "Not set"}
|
||||
)
|
||||
return rows, current_row, countries
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from accounts.models import PremiumEntitlement
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Grant permanent Lifetime Premium access to a user."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("identifier", help="Username or email address")
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
choices=[choice[0] for choice in PremiumEntitlement.SOURCE_CHOICES],
|
||||
default=PremiumEntitlement.SOURCE_ADMIN,
|
||||
)
|
||||
parser.add_argument("--order-reference", default="")
|
||||
parser.add_argument("--notes", default="")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
identifier = options["identifier"].strip()
|
||||
user_model = get_user_model()
|
||||
user = user_model.objects.filter(username=identifier).first()
|
||||
if user is None:
|
||||
user = user_model.objects.filter(email__iexact=identifier).first()
|
||||
if user is None:
|
||||
raise CommandError(f"No user found for {identifier!r}.")
|
||||
|
||||
entitlement, created = PremiumEntitlement.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
"source": options["source"],
|
||||
"order_reference": options["order_reference"],
|
||||
"notes": options["notes"],
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
entitlement.source = options["source"]
|
||||
entitlement.order_reference = (
|
||||
options["order_reference"] or entitlement.order_reference
|
||||
)
|
||||
entitlement.notes = options["notes"] or entitlement.notes
|
||||
entitlement.revoked_at = None
|
||||
entitlement.save(
|
||||
update_fields=["source", "order_reference", "notes", "revoked_at"]
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Lifetime Premium is active for {user}.")
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-13 12:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StudentProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('xp', models.IntegerField(default=0)),
|
||||
('streak', models.IntegerField(default=0)),
|
||||
('daily_goal', models.IntegerField(default=60)),
|
||||
('target_band', models.FloatField(default=7.5)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-17 12:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='studentprofile',
|
||||
name='last_activity_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0002_studentprofile_last_activity_date")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DailyMissionClaim",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("mission_key", models.CharField(max_length=40)),
|
||||
("completed_on", models.DateField()),
|
||||
("xp_awarded", models.PositiveIntegerField()),
|
||||
("claimed_at", models.DateTimeField(auto_now_add=True)),
|
||||
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="daily_mission_claims", to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={"ordering": ["-completed_on", "-claimed_at"]},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="dailymissionclaim",
|
||||
constraint=models.UniqueConstraint(fields=("user", "mission_key", "completed_on"), name="unique_daily_mission_claim"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0003_dailymissionclaim"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PremiumEntitlement",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("admin", "Admin grant"),
|
||||
("payment", "One-time payment"),
|
||||
("promotion", "Promotion"),
|
||||
],
|
||||
default="admin",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("order_reference", models.CharField(blank=True, max_length=120)),
|
||||
("granted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("revoked_at", models.DateTimeField(blank=True, null=True)),
|
||||
("notes", models.TextField(blank=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="premium_entitlement",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0004_premiumentitlement"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="studentprofile",
|
||||
name="display_name",
|
||||
field=models.CharField(blank=True, max_length=40),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="studentprofile",
|
||||
name="country",
|
||||
field=models.CharField(blank=True, max_length=80),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class StudentProfile(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
xp = models.IntegerField(default=0)
|
||||
streak = models.IntegerField(default=0)
|
||||
last_activity_date = models.DateField(null=True, blank=True)
|
||||
daily_goal = models.IntegerField(default=60)
|
||||
|
||||
target_band = models.FloatField(default=7.5)
|
||||
display_name = models.CharField(max_length=40, blank=True)
|
||||
country = models.CharField(max_length=80, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username
|
||||
|
||||
|
||||
class PremiumEntitlement(models.Model):
|
||||
SOURCE_ADMIN = "admin"
|
||||
SOURCE_PAYMENT = "payment"
|
||||
SOURCE_PROMOTION = "promotion"
|
||||
SOURCE_CHOICES = [
|
||||
(SOURCE_ADMIN, "Admin grant"),
|
||||
(SOURCE_PAYMENT, "One-time payment"),
|
||||
(SOURCE_PROMOTION, "Promotion"),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="premium_entitlement",
|
||||
)
|
||||
source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default=SOURCE_ADMIN)
|
||||
order_reference = models.CharField(max_length=120, blank=True)
|
||||
granted_at = models.DateTimeField(auto_now_add=True)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.revoked_at is None
|
||||
|
||||
def __str__(self):
|
||||
state = "active" if self.is_active else "revoked"
|
||||
return f"{self.user} · Lifetime Premium ({state})"
|
||||
|
||||
|
||||
class DailyMissionClaim(models.Model):
|
||||
"""Records one XP reward per student, mission, and calendar day."""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="daily_mission_claims")
|
||||
mission_key = models.CharField(max_length=40)
|
||||
completed_on = models.DateField()
|
||||
xp_awarded = models.PositiveIntegerField()
|
||||
claimed_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "mission_key", "completed_on"],
|
||||
name="unique_daily_mission_claim",
|
||||
),
|
||||
]
|
||||
ordering = ["-completed_on", "-claimed_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} · {self.mission_key} · {self.completed_on}"
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from .models import StudentProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_student_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
StudentProfile.objects.create(user=instance)
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Leaderboard | Testpoint{% endblock %}
|
||||
{% block meta_description %}Compare IELTS mock-test progress, track your rank, and build a consistent study habit.{% endblock %}
|
||||
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/dashboard.css' %}?v=20260727.2"><link rel="stylesheet" href="{% static 'css/leaderboard.css' %}?v=20260727.2">{% endblock %}
|
||||
{% block content %}
|
||||
<div class="dashboard-page leaderboard-page">
|
||||
<div class="dashboard-layout">
|
||||
<aside class="dashboard-sidebar" aria-label="Dashboard navigation">
|
||||
<div class="dashboard-profile"><span class="dashboard-avatar dashboard-avatar--default" role="img" aria-label="Smiling default profile avatar"><i class="bi bi-emoji-smile-fill"></i></span><div><strong>{{ profile.display_name|default:request.user.get_full_name|default:request.user.username }}</strong><span>IELTS student</span></div></div>
|
||||
<nav class="dashboard-menu">
|
||||
<a href="{% url 'student_dashboard' %}"><i class="bi bi-grid"></i>Overview</a>
|
||||
<a href="{% url 'exam_list' %}"><i class="bi bi-journal-check"></i>All tests</a>
|
||||
<a class="is-active" href="{% url 'leaderboard' %}" aria-current="page"><i class="bi bi-trophy"></i>Leaderboard</a>
|
||||
<a href="{% url 'student_settings' %}"><i class="bi bi-person-gear"></i>Profile settings</a>
|
||||
</nav>
|
||||
<div class="dashboard-target"><span>Target band</span><strong>{{ profile.target_band|floatformat:1 }}</strong><p>Your leaderboard name and country can be changed in Profile settings.</p></div>
|
||||
</aside>
|
||||
|
||||
<section class="leaderboard-content">
|
||||
<header class="leaderboard-heading">
|
||||
<div><h1>Leaderboard</h1><p>See how you rank, track your progress, and challenge yourself to reach your target band.</p></div>
|
||||
</header>
|
||||
|
||||
<section class="rank-summary" aria-labelledby="your-rank-title">
|
||||
<div class="rank-summary__primary"><span id="your-rank-title">Your rank</span><strong>{% if current_row %}#{{ current_row.rank }}{% else %}—{% endif %}</strong><small>{% if current_row %}out of {{ rows|length }} students{% else %}Complete a scored test to join{% endif %}</small></div>
|
||||
<div class="rank-summary__band"><span>Band score</span><strong>{% if current_row %}{{ current_row.band|floatformat:1 }}{% else %}—{% endif %}</strong><small><i class="bi bi-arrow-up"></i> Keep improving each week</small></div>
|
||||
<dl><div><dt>Tests completed</dt><dd>{{ completed_tests }}</dd></div><div><dt>Current streak</dt><dd>{{ profile.streak }} day{{ profile.streak|pluralize }}</dd></div></dl>
|
||||
<div class="rank-summary__message"><p>{% if current_row %}You're making progress. Keep practicing to climb the leaderboard.{% else %}Your first completed mock test will establish your rank.{% endif %}</p><div class="rank-progress"><span style="width:{{ rank_progress }}%"></span></div></div>
|
||||
</section>
|
||||
|
||||
<section class="leaderboard-board" aria-labelledby="standings-title">
|
||||
<div class="leaderboard-controls">
|
||||
<nav aria-label="Ranking period">
|
||||
<a class="{% if selected_period == 'week' %}is-active{% endif %}" href="?period=week">This Week</a>
|
||||
<a class="{% if selected_period == 'month' %}is-active{% endif %}" href="?period=month">This Month</a>
|
||||
<a class="{% if selected_period == 'all' %}is-active{% endif %}" href="?period=all">All Time</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="leaderboard-table-wrap">
|
||||
<table class="leaderboard-table">
|
||||
<caption id="standings-title">Current standings</caption>
|
||||
<thead><tr><th>Rank</th><th>Student</th><th>Band score</th><th>Tests</th><th>Trend</th></tr></thead>
|
||||
<tbody>
|
||||
{% for student in rows %}
|
||||
<tr class="{% if student.is_current %}is-current{% endif %}">
|
||||
<td data-label="Rank">#{{ student.rank }}</td>
|
||||
<td data-label="Student"><span class="table-avatar">{{ student.initial }}</span><strong>{{ student.name }}{% if student.is_current %} <em>(You)</em>{% endif %}</strong>{% if student.demo %}<small>Preview</small>{% endif %}</td>
|
||||
<td data-label="Band score"><b>{{ student.band|floatformat:1 }}</b></td>
|
||||
<td data-label="Tests">{{ student.tests }}</td>
|
||||
<td data-label="Trend"><span class="trend trend--{{ student.trend }}"><i class="bi {% if student.trend == 'up' %}bi-arrow-up{% elif student.trend == 'down' %}bi-arrow-down{% else %}bi-dash{% endif %}"></i><span class="visually-hidden">{{ student.trend }}</span></span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Lifetime Premium | Testpoint{% endblock %}
|
||||
{% block meta_description %}Unlock every current and future Testpoint practice test with one payment and no subscription.{% endblock %}
|
||||
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/premium.css' %}?v=20260727.1">{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="premium-page">
|
||||
<section class="premium-shell" aria-labelledby="premium-title">
|
||||
<div class="premium-copy">
|
||||
<a class="premium-back" href="{% url 'exam_list' %}"><i class="bi bi-arrow-left"></i> Back to tests</a>
|
||||
{% if has_lifetime_premium %}
|
||||
<div class="premium-status premium-status--active"><i class="bi bi-patch-check-fill"></i> Lifetime Premium is active</div>
|
||||
{% else %}
|
||||
<div class="premium-status"><i class="bi bi-stars"></i> One-time access</div>
|
||||
{% endif %}
|
||||
<h1 id="premium-title">One payment.<br>Every test, forever.</h1>
|
||||
<p>Unlock the complete Testpoint library—including every Premium test we add in the future. No subscription, renewal date, or recurring fee.</p>
|
||||
<ul class="premium-benefits">
|
||||
<li><i class="bi bi-check2"></i><span><strong>Complete test library</strong>Access Reading, Listening, Writing, Speaking, and Full Mock tests.</span></li>
|
||||
<li><i class="bi bi-check2"></i><span><strong>Unlimited practice</strong>Repeat tests and review your results whenever you need.</span></li>
|
||||
<li><i class="bi bi-check2"></i><span><strong>Future content included</strong>New Premium tests are added to your account automatically.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="premium-purchase" aria-label="Lifetime Premium access">
|
||||
<div class="premium-purchase__heading"><span>Lifetime Premium</span><i class="bi bi-infinity" aria-hidden="true"></i></div>
|
||||
<h2>Permanent access</h2>
|
||||
<p>Pay once and keep your access for the lifetime of your account.</p>
|
||||
<div class="premium-includes">
|
||||
<span><i class="bi bi-check-circle-fill"></i> All current Premium tests</span>
|
||||
<span><i class="bi bi-check-circle-fill"></i> All future Premium tests</span>
|
||||
<span><i class="bi bi-check-circle-fill"></i> Unlimited attempts and retakes</span>
|
||||
<span><i class="bi bi-check-circle-fill"></i> No recurring payments</span>
|
||||
</div>
|
||||
{% if has_lifetime_premium %}
|
||||
<a class="premium-action" href="{% url 'exam_list' %}">Browse all tests <i class="bi bi-arrow-right"></i></a>
|
||||
<small>Your Lifetime Premium entitlement is active on this account.</small>
|
||||
{% elif request.user.is_authenticated %}
|
||||
<a class="premium-action" href="{% url 'contact' %}?subject=Lifetime%20Premium">Get Lifetime Premium <i class="bi bi-arrow-right"></i></a>
|
||||
<small>Our team will confirm your one-time payment and activate permanent access.</small>
|
||||
{% else %}
|
||||
<a class="premium-action" href="{% url 'account_signup' %}">Create your account <i class="bi bi-arrow-right"></i></a>
|
||||
<small>Create an account first so Lifetime Premium can be linked to you permanently.</small>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</section>
|
||||
<section class="premium-assurance" aria-label="Premium assurance">
|
||||
<div><i class="bi bi-receipt"></i><span><strong>One purchase</strong>No subscription contract</span></div>
|
||||
<div><i class="bi bi-person-check"></i><span><strong>Account-linked</strong>Your access follows your account</span></div>
|
||||
<div><i class="bi bi-shield-check"></i><span><strong>Admin verified</strong>Every activation is recorded</span></div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Dashboard | Testpoint{% endblock %}
|
||||
{% block meta_description %}Track your IELTS practice progress, skill accuracy, and recent mock tests.{% endblock %}
|
||||
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/dashboard.css' %}?v=20260727.2"><link rel="stylesheet" href="{% static 'css/progress-analytics.css' %}?v=20260718.1"><link rel="stylesheet" href="{% static 'css/daily-missions.css' %}?v=20260718.1">{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-page">
|
||||
<div class="dashboard-layout">
|
||||
<aside class="dashboard-sidebar" aria-label="Dashboard navigation">
|
||||
<div class="dashboard-profile">
|
||||
<span class="dashboard-avatar dashboard-avatar--default" role="img" aria-label="Smiling default profile avatar"><i class="bi bi-emoji-smile-fill" aria-hidden="true"></i></span>
|
||||
<div>
|
||||
<strong>{{ request.user.get_full_name|default:request.user.username }}</strong>
|
||||
<span>IELTS student</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="dashboard-menu">
|
||||
<a class="is-active" href="{% url 'student_dashboard' %}" aria-current="page"><i class="bi bi-grid"></i>Overview</a>
|
||||
<a href="{% url 'exam_list' %}"><i class="bi bi-journal-check"></i>All tests</a>
|
||||
<a href="{% url 'exam_list' %}?skill=reading"><i class="bi bi-book"></i>Reading</a>
|
||||
<a href="{% url 'exam_list' %}?skill=listening"><i class="bi bi-headphones"></i>Listening</a>
|
||||
<a href="{% url 'exam_list' %}?skill=writing"><i class="bi bi-pencil"></i>Writing</a>
|
||||
<a href="{% url 'exam_list' %}?skill=speaking"><i class="bi bi-mic"></i>Speaking</a>
|
||||
<a href="{% url 'exam_list' %}?skill=full"><i class="bi bi-layers"></i>Full Mock Test</a>
|
||||
<a href="{% url 'leaderboard' %}"><i class="bi bi-trophy"></i>Leaderboard</a>
|
||||
<a href="#skills"><i class="bi bi-bar-chart"></i>Skill progress</a>
|
||||
<a href="#analytics"><i class="bi bi-graph-up-arrow"></i>Progress analytics</a>
|
||||
<a href="#recent-tests"><i class="bi bi-clock-history"></i>Test history</a>
|
||||
<a href="{% url 'student_settings' %}"><i class="bi bi-person-gear"></i>Profile settings</a>
|
||||
<a href="{% url 'contact' %}"><i class="bi bi-question-circle"></i>Support</a>
|
||||
</nav>
|
||||
<div class="dashboard-target">
|
||||
<span>Target band</span>
|
||||
<strong>{{ profile.target_band|floatformat:1 }}</strong>
|
||||
<p>Keep practicing consistently to reach your target.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="dashboard-content">
|
||||
<header class="dashboard-heading">
|
||||
<div>
|
||||
<div class="dashboard-welcome-meta"><time class="dashboard-date" datetime="{% now 'Y-m-d' %}">{% now "l, j F" %}</time><a href="{% url 'premium' %}" class="{% if has_lifetime_premium %}is-premium{% endif %}"><i class="bi bi-{% if has_lifetime_premium %}patch-check-fill{% else %}stars{% endif %}"></i>{% if has_lifetime_premium %}Lifetime Premium{% else %}Unlock Premium{% endif %}</a></div>
|
||||
<h1>Welcome back, {{ request.user.first_name|default:request.user.username }}!</h1>
|
||||
<p>Here’s where your practice stands today.</p>
|
||||
</div>
|
||||
<a class="dashboard-primary-btn" href="{% url 'exam_list' %}">Browse tests <i class="bi bi-arrow-right"></i></a>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-stats" aria-label="Practice summary">
|
||||
<article class="dashboard-stat-card">
|
||||
<span class="stat-icon stat-icon--blue"><i class="bi bi-journal-check"></i></span>
|
||||
<div><span>Tests completed</span><strong>{{ tests_taken }}</strong></div>
|
||||
</article>
|
||||
<article class="dashboard-stat-card">
|
||||
<span class="stat-icon stat-icon--green"><i class="bi bi-bullseye"></i></span>
|
||||
<div><span>Answer accuracy</span><strong>{{ accuracy }}%</strong></div>
|
||||
</article>
|
||||
<article class="dashboard-stat-card">
|
||||
<span class="stat-icon stat-icon--orange"><i class="bi bi-stopwatch"></i></span>
|
||||
<div><span>Average test time</span><strong>{{ avg_time }}</strong></div>
|
||||
</article>
|
||||
<article class="dashboard-stat-card">
|
||||
<span class="stat-icon stat-icon--purple"><i class="bi bi-fire"></i></span>
|
||||
<div><span>Current streak</span><strong>{{ profile.streak }} day{{ profile.streak|pluralize }}</strong></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="daily-missions" aria-labelledby="missions-title">
|
||||
<div class="daily-missions__heading"><div><span>Today’s study path</span><h2 id="missions-title">Daily missions</h2><p>Complete missions and claim bonus XP for consistent practice.</p></div><span><i class="bi bi-lightning-charge-fill"></i> Resets daily</span></div>
|
||||
<div class="daily-missions__grid">
|
||||
{% for mission in daily_missions %}
|
||||
<article class="daily-mission{% if mission.claimed %} is-claimed{% elif mission.complete %} is-complete{% endif %}">
|
||||
<span class="daily-mission__icon"><i class="bi {{ mission.icon }}"></i></span>
|
||||
<div class="daily-mission__body"><div><h3>{{ mission.title }}</h3><b>+{{ mission.xp }} XP</b></div><p>{{ mission.description }}</p><div class="daily-mission__progress"><span><i style="width:{{ mission.progress_percent }}%"></i></span><small>{{ mission.progress }} / {{ mission.target }}</small></div></div>
|
||||
{% if mission.claimed %}<span class="daily-mission__claimed"><i class="bi bi-check2"></i> Claimed</span>{% elif mission.complete %}<form method="post" action="{% url 'claim_daily_mission' mission.key %}">{% csrf_token %}<button type="submit">Claim XP <i class="bi bi-arrow-right"></i></button></form>{% else %}<span class="daily-mission__pending">In progress</span>{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<section class="dashboard-panel" id="skills">
|
||||
<div class="panel-heading">
|
||||
<div><span>Performance</span><h2>Skills overview</h2></div>
|
||||
<small>Based on graded answers</small>
|
||||
</div>
|
||||
<div class="skills-list">
|
||||
{% for skill in skills %}
|
||||
<div class="skill-row">
|
||||
<span class="skill-icon skill-icon--{{ skill.tone }}"><i class="bi {{ skill.icon }}"></i></span>
|
||||
<div class="skill-details">
|
||||
<div><strong>{{ skill.name }}</strong><span>{{ skill.value }}%</span></div>
|
||||
<div class="skill-track" aria-label="{{ skill.name }} accuracy: {{ skill.value }} percent">
|
||||
<span class="skill-fill skill-fill--{{ skill.tone }}" style="width: {{ skill.value }}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="dashboard-panel study-panel">
|
||||
<div class="panel-heading"><div><span>Study plan</span><h2>Your goals</h2></div></div>
|
||||
<div class="goal-item"><span><i class="bi bi-clock"></i>Daily practice</span><strong>{{ profile.daily_goal }} minutes</strong></div>
|
||||
<div class="goal-item"><span><i class="bi bi-lightning-charge"></i>Experience</span><strong>{{ profile.xp }} / {{ xp_goal }} XP</strong></div>
|
||||
<div class="xp-track" aria-label="Experience progress: {{ xp_progress }} percent"><span style="width: {{ xp_progress }}%"></span></div>
|
||||
<p class="goal-note">Your XP reflects activity recorded on this account.</p>
|
||||
<a class="dashboard-secondary-btn" href="{% url 'exam_list' %}">Continue practicing</a>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="analytics-section" id="analytics" aria-labelledby="analytics-title">
|
||||
<div class="analytics-heading"><div><span>Progress analytics</span><h2 id="analytics-title">Learn from every attempt</h2><p>These insights are calculated from your completed tests and graded answers.</p></div><span class="analytics-study-time"><i class="bi bi-clock-history"></i> {{ total_study_minutes }} minute{{ total_study_minutes|pluralize }} studied</span></div>
|
||||
<div class="analytics-grid">
|
||||
<article class="analytics-card analytics-card--trend">
|
||||
<div class="analytics-card__heading"><div><span>Performance trend</span><h3>Recent results</h3></div>{% if trend_change is not None %}<b class="{% if trend_change >= 0 %}is-positive{% else %}is-negative{% endif %}"><i class="bi bi-{% if trend_change >= 0 %}arrow-up-right{% else %}arrow-down-right{% endif %}"></i> {{ trend_change|floatformat:0 }}%</b>{% endif %}</div>
|
||||
{% if trend_attempts %}<div class="trend-chart" role="img" aria-label="Recent test performance chart">{% for point in trend_attempts %}<div class="trend-chart__item" title="{{ point.title }}: {{ point.display }}"><span class="trend-chart__value">{{ point.display }}</span><div class="trend-chart__bar-wrap"><span class="trend-chart__bar" style="height: {{ point.height }}%"></span></div><small>{{ point.label }}</small></div>{% endfor %}</div>{% else %}<div class="analytics-empty"><i class="bi bi-graph-up"></i><p>Complete graded tests to see your performance trend.</p></div>{% endif %}
|
||||
</article>
|
||||
<article class="analytics-card">
|
||||
<div class="analytics-card__heading"><div><span>Question types</span><h3>Accuracy breakdown</h3></div><i class="bi bi-pie-chart"></i></div>
|
||||
{% if question_type_accuracy %}<div class="question-type-list">{% for item in question_type_accuracy %}<div><span>{{ item.name }}</span><strong>{{ item.value }}%</strong><div><i style="width:{{ item.value }}%"></i></div></div>{% endfor %}</div>{% else %}<div class="analytics-empty"><i class="bi bi-ui-checks-grid"></i><p>Objective answers will appear here once they are graded.</p></div>{% endif %}
|
||||
</article>
|
||||
<article class="analytics-card">
|
||||
<div class="analytics-card__heading"><div><span>Focus area</span><h3>Where to improve</h3></div><i class="bi bi-bullseye"></i></div>
|
||||
{% if weakest_skill %}<div class="weak-area"><span class="skill-icon skill-icon--{{ weakest_skill.tone }}"><i class="bi {{ weakest_skill.icon }}"></i></span><div><strong>{{ weakest_skill.name }}</strong><p>{{ weakest_skill.value }}% current score</p></div></div><p class="weak-area__note">Prioritise {{ weakest_skill.name|lower }} practice to lift your overall result.</p><a href="{% url 'exam_list' %}?skill={{ weakest_skill.name|lower }}">Practice {{ weakest_skill.name }} <i class="bi bi-arrow-right"></i></a>{% else %}<div class="analytics-empty"><i class="bi bi-lightbulb"></i><p>Your focus area will appear after graded answers are available.</p></div>{% endif %}
|
||||
</article>
|
||||
<article class="analytics-card">
|
||||
<div class="analytics-card__heading"><div><span>Consistency</span><h3>Study streak</h3></div><strong class="streak-total">{{ profile.streak }} <small>day{{ profile.streak|pluralize }}</small></strong></div>
|
||||
<div class="streak-week" aria-label="Last seven days of completed practice">{% for day in streak_days %}<span class="{% if day.active %}is-active{% endif %}"><i class="bi bi-check"></i><small>{{ day.label }}</small></span>{% endfor %}</div><p class="streak-note">Complete a test today to keep your practice habit active.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel recent-panel" id="recent-tests">
|
||||
<div class="panel-heading">
|
||||
<div><span>History</span><h2>Recent tests</h2></div>
|
||||
<a href="{% url 'exam_list' %}">View all <i class="bi bi-arrow-right"></i></a>
|
||||
</div>
|
||||
{% if recent_attempts %}
|
||||
<div class="recent-list">
|
||||
{% for item in recent_attempts %}
|
||||
<article class="recent-item">
|
||||
<span class="recent-icon"><i class="bi bi-file-earmark-text"></i></span>
|
||||
<div class="recent-main"><strong>{{ item.attempt.exam_set.title }}</strong><span>Completed {{ item.attempt.submitted_at|date:"M j, Y" }}</span></div>
|
||||
<div class="recent-meta"><span>Time</span><strong>{{ item.duration }}</strong></div>
|
||||
<div class="recent-meta"><span>Score</span><strong>{% if item.score is not None %}{{ item.score }}%{% else %}Pending{% endif %}</strong></div>
|
||||
<a class="recent-link" href="{% url 'results' item.attempt.id %}" aria-label="View results for {{ item.attempt.exam_set.title }}"><i class="bi bi-chevron-right"></i></a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dashboard-empty">
|
||||
<span><i class="bi bi-clipboard2-check"></i></span>
|
||||
<h3>No completed tests yet</h3>
|
||||
<p>Your scores and test history will appear here after your first completed mock test.</p>
|
||||
<a class="dashboard-primary-btn" href="{% url 'exam_list' %}">Explore available tests</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Profile Settings | Testpoint{% endblock %}
|
||||
{% block meta_description %}Manage your Testpoint profile and study goals.{% endblock %}
|
||||
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/dashboard.css' %}?v=20260727.2">{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
<div class="settings-shell">
|
||||
<header class="settings-heading">
|
||||
<a href="{% url 'student_dashboard' %}"><i class="bi bi-arrow-left"></i> Back to dashboard</a>
|
||||
<span>Account</span>
|
||||
<h1>Profile settings</h1>
|
||||
<p>Keep your personal details and IELTS study goals up to date.</p>
|
||||
</header>
|
||||
|
||||
<div class="settings-layout">
|
||||
<aside class="settings-summary">
|
||||
<span class="settings-avatar settings-avatar--default" role="img" aria-label="Smiling default profile avatar"><i class="bi bi-emoji-smile-fill" aria-hidden="true"></i></span>
|
||||
<h2>{{ request.user.get_full_name|default:request.user.username }}</h2>
|
||||
<p>@{{ request.user.username }}</p>
|
||||
<div><span>Current target</span><strong>Band {{ profile.target_band|floatformat:1 }}</strong></div>
|
||||
<div><span>Daily goal</span><strong>{{ profile.daily_goal }} minutes</strong></div>
|
||||
</aside>
|
||||
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-heading"><h2>Personal information</h2><p>Your username cannot be changed.</p></div>
|
||||
<form method="post" class="settings-form" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}<div class="settings-errors">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<div class="settings-row">
|
||||
<div class="settings-field"><label for="{{ form.first_name.id_for_label }}">First name</label>{{ form.first_name }}{{ form.first_name.errors }}</div>
|
||||
<div class="settings-field"><label for="{{ form.last_name.id_for_label }}">Last name</label>{{ form.last_name }}{{ form.last_name.errors }}</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field"><label for="{{ form.display_name.id_for_label }}">Leaderboard display name</label>{{ form.display_name }}<small>{{ form.display_name.help_text }}</small>{{ form.display_name.errors }}</div>
|
||||
<div class="settings-field"><label for="{{ form.country.id_for_label }}">Country</label>{{ form.country }}<small>Optional. Used for leaderboard country filters.</small>{{ form.country.errors }}</div>
|
||||
</div>
|
||||
<div class="settings-account-row"><div><span>Email address</span><strong>{{ request.user.email }}</strong></div><a href="{% url 'account_email' %}">Manage email</a></div>
|
||||
<div class="settings-account-row"><div><span>Password</span><strong>Protected account</strong></div><a href="{% url 'account_change_password' %}">Change password</a></div>
|
||||
<div class="settings-divider"></div>
|
||||
<div class="settings-card-heading"><h2>Study goals</h2><p>These values appear on your student dashboard.</p></div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field"><label for="{{ form.target_band.id_for_label }}">Target band score</label>{{ form.target_band }}<small>Choose a score from 4.0 to 9.0 in 0.5-band increments.</small>{{ form.target_band.errors }}</div>
|
||||
<div class="settings-field"><label for="{{ form.daily_goal.id_for_label }}">Daily practice (minutes)</label>{{ form.daily_goal }}<small>Between 10 and 300 minutes.</small>{{ form.daily_goal.errors }}</div>
|
||||
</div>
|
||||
<div class="settings-actions"><a href="{% url 'student_dashboard' %}">Cancel</a><button type="submit">Save changes</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,347 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.access import has_lifetime_premium
|
||||
from accounts.models import DailyMissionClaim, PremiumEntitlement, StudentProfile
|
||||
from exams.models import ExamSet, Question, Section, StudentAnswer, StudentAttempt
|
||||
|
||||
|
||||
class StudentDashboardTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="anvar", password="test-password"
|
||||
)
|
||||
|
||||
def test_dashboard_requires_login(self):
|
||||
response = self.client.get(reverse("student_dashboard"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_dashboard_recovers_missing_profile_and_has_working_links(self):
|
||||
self.user.studentprofile.delete()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("student_dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "No completed tests yet")
|
||||
self.assertContains(response, reverse("exam_list"))
|
||||
self.assertNotContains(response, 'href="#"')
|
||||
self.assertTrue(hasattr(self.user, "studentprofile"))
|
||||
|
||||
def test_dashboard_displays_real_attempt_metrics(self):
|
||||
exam = ExamSet.objects.create(title="IELTS Academic Mock 1")
|
||||
section = Section.objects.create(
|
||||
exam_set=exam,
|
||||
order=1,
|
||||
section_type="reading",
|
||||
time_limit_minutes=60,
|
||||
)
|
||||
correct_question = Question.objects.create(
|
||||
section=section,
|
||||
order=1,
|
||||
question_type="mcq",
|
||||
prompt="Correct",
|
||||
correct_answer="A",
|
||||
)
|
||||
wrong_question = Question.objects.create(
|
||||
section=section,
|
||||
order=2,
|
||||
question_type="mcq",
|
||||
prompt="Wrong",
|
||||
correct_answer="B",
|
||||
)
|
||||
attempt = StudentAttempt.objects.create(
|
||||
student=self.user,
|
||||
exam_set=exam,
|
||||
is_complete=True,
|
||||
submitted_at=timezone.now(),
|
||||
)
|
||||
StudentAttempt.objects.filter(pk=attempt.pk).update(
|
||||
started_at=timezone.now() - timedelta(minutes=30)
|
||||
)
|
||||
StudentAnswer.objects.create(
|
||||
attempt=attempt,
|
||||
question=correct_question,
|
||||
answer_text="A",
|
||||
is_correct=True,
|
||||
)
|
||||
StudentAnswer.objects.create(
|
||||
attempt=attempt,
|
||||
question=wrong_question,
|
||||
answer_text="A",
|
||||
is_correct=False,
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("student_dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "IELTS Academic Mock 1")
|
||||
self.assertEqual(response.context["tests_taken"], 1)
|
||||
self.assertEqual(response.context["accuracy"], 50)
|
||||
self.assertEqual(response.context["skills"][0]["value"], 50)
|
||||
self.assertIn(response.context["avg_time"], {"29m", "30m"})
|
||||
self.assertContains(response, reverse("results", args=[attempt.pk]))
|
||||
|
||||
def test_dashboard_uses_manual_band_scores_for_writing_progress(self):
|
||||
exam = ExamSet.objects.create(title="Reviewed Writing")
|
||||
section = Section.objects.create(
|
||||
exam_set=exam,
|
||||
order=1,
|
||||
section_type="writing",
|
||||
time_limit_minutes=40,
|
||||
)
|
||||
question = Question.objects.create(
|
||||
section=section,
|
||||
order=1,
|
||||
question_type="essay",
|
||||
prompt="Write an essay.",
|
||||
)
|
||||
attempt = StudentAttempt.objects.create(
|
||||
student=self.user,
|
||||
exam_set=exam,
|
||||
is_complete=True,
|
||||
submitted_at=timezone.now(),
|
||||
)
|
||||
StudentAnswer.objects.create(
|
||||
attempt=attempt,
|
||||
question=question,
|
||||
answer_text="Response",
|
||||
manual_score=7.2,
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("student_dashboard"))
|
||||
|
||||
writing = next(skill for skill in response.context["skills"] if skill["name"] == "Writing")
|
||||
self.assertEqual(writing["value"], 80)
|
||||
|
||||
def test_completed_daily_mission_can_be_claimed_once_for_xp(self):
|
||||
exam = ExamSet.objects.create(title="Daily mission test")
|
||||
section = Section.objects.create(
|
||||
exam_set=exam, order=1, section_type="reading", time_limit_minutes=20
|
||||
)
|
||||
question = Question.objects.create(
|
||||
section=section,
|
||||
order=1,
|
||||
question_type="gap",
|
||||
prompt="Answer",
|
||||
correct_answer="answer",
|
||||
)
|
||||
attempt = StudentAttempt.objects.create(
|
||||
student=self.user,
|
||||
exam_set=exam,
|
||||
is_complete=True,
|
||||
submitted_at=timezone.now(),
|
||||
)
|
||||
StudentAnswer.objects.create(
|
||||
attempt=attempt, question=question, answer_text="answer", is_correct=True
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(reverse("claim_daily_mission", args=["complete_test"]))
|
||||
self.user.studentprofile.refresh_from_db()
|
||||
|
||||
self.assertRedirects(response, reverse("student_dashboard"))
|
||||
self.assertEqual(self.user.studentprofile.xp, 30)
|
||||
self.assertTrue(
|
||||
DailyMissionClaim.objects.filter(
|
||||
user=self.user, mission_key="complete_test"
|
||||
).exists()
|
||||
)
|
||||
|
||||
self.client.post(reverse("claim_daily_mission", args=["complete_test"]))
|
||||
self.user.studentprofile.refresh_from_db()
|
||||
self.assertEqual(self.user.studentprofile.xp, 30)
|
||||
|
||||
|
||||
class StudentSettingsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="student", email="old@example.com", password="test-password"
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_settings_page_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("student_settings"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_student_can_update_profile_and_goals(self):
|
||||
response = self.client.post(
|
||||
reverse("student_settings"),
|
||||
{
|
||||
"first_name": "Anvar",
|
||||
"last_name": "Student",
|
||||
"target_band": "8.0",
|
||||
"daily_goal": "90",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("student_settings"))
|
||||
self.user.refresh_from_db()
|
||||
self.user.studentprofile.refresh_from_db()
|
||||
self.assertEqual(self.user.first_name, "Anvar")
|
||||
self.assertEqual(self.user.email, "old@example.com")
|
||||
self.assertEqual(self.user.studentprofile.target_band, 8.0)
|
||||
self.assertEqual(self.user.studentprofile.daily_goal, 90)
|
||||
self.assertContains(response, "Your profile settings have been updated.")
|
||||
|
||||
def test_settings_reject_invalid_goals(self):
|
||||
response = self.client.post(
|
||||
reverse("student_settings"),
|
||||
{
|
||||
"target_band": "10.0",
|
||||
"daily_goal": "5",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context["form"], "target_band", "Ensure this value is less than or equal to 9.")
|
||||
self.assertFormError(response.context["form"], "daily_goal", "Ensure this value is greater than or equal to 10.")
|
||||
|
||||
def test_settings_reject_target_bands_outside_half_band_increments(self):
|
||||
response = self.client.post(
|
||||
reverse("student_settings"),
|
||||
{"target_band": "7.3", "daily_goal": "60"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(
|
||||
response.context["form"],
|
||||
"target_band",
|
||||
"Choose an IELTS band score in 0.5 increments.",
|
||||
)
|
||||
|
||||
|
||||
class LifetimePremiumTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="premium-student",
|
||||
email="premium@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
|
||||
def test_premium_page_explains_one_time_access(self):
|
||||
response = self.client.get(reverse("premium"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "One payment.")
|
||||
self.assertContains(response, "No subscription")
|
||||
|
||||
def test_active_and_revoked_entitlements_are_distinguished(self):
|
||||
entitlement = PremiumEntitlement.objects.create(
|
||||
user=self.user,
|
||||
source=PremiumEntitlement.SOURCE_PAYMENT,
|
||||
order_reference="ORDER-1001",
|
||||
)
|
||||
self.assertTrue(has_lifetime_premium(self.user))
|
||||
entitlement.revoked_at = timezone.now()
|
||||
entitlement.save(update_fields=["revoked_at"])
|
||||
self.assertFalse(has_lifetime_premium(self.user))
|
||||
|
||||
def test_staff_has_premium_access_without_entitlement(self):
|
||||
self.user.is_staff = True
|
||||
self.user.save(update_fields=["is_staff"])
|
||||
self.assertTrue(has_lifetime_premium(self.user))
|
||||
|
||||
def test_management_command_grants_and_restores_access(self):
|
||||
call_command(
|
||||
"grant_lifetime_premium",
|
||||
self.user.email,
|
||||
source="payment",
|
||||
order_reference="ORDER-2002",
|
||||
)
|
||||
entitlement = PremiumEntitlement.objects.get(user=self.user)
|
||||
self.assertTrue(entitlement.is_active)
|
||||
self.assertEqual(entitlement.order_reference, "ORDER-2002")
|
||||
|
||||
entitlement.revoked_at = timezone.now()
|
||||
entitlement.save(update_fields=["revoked_at"])
|
||||
call_command("grant_lifetime_premium", self.user.username)
|
||||
entitlement.refresh_from_db()
|
||||
self.assertTrue(entitlement.is_active)
|
||||
|
||||
|
||||
class LeaderboardTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="ranked-student", password="test-password"
|
||||
)
|
||||
self.profile, _ = StudentProfile.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_landing_page_uses_demo_leaderboard_preview(self):
|
||||
response = self.client.get(reverse("home"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Weekly leaderboard")
|
||||
self.assertContains(response, "Alex")
|
||||
self.assertContains(response, "Join the leaderboard")
|
||||
|
||||
def test_leaderboard_requires_login(self):
|
||||
response = self.client.get(reverse("leaderboard"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_completed_scored_attempt_places_student_in_rankings(self):
|
||||
exam = ExamSet.objects.create(title="Ranked Reading")
|
||||
section = Section.objects.create(
|
||||
exam_set=exam,
|
||||
order=1,
|
||||
section_type="reading",
|
||||
time_limit_minutes=60,
|
||||
)
|
||||
question = Question.objects.create(
|
||||
section=section,
|
||||
order=1,
|
||||
question_type="gap",
|
||||
prompt="Answer",
|
||||
correct_answer="valid",
|
||||
)
|
||||
attempt = StudentAttempt.objects.create(
|
||||
student=self.user,
|
||||
exam_set=exam,
|
||||
is_complete=True,
|
||||
submitted_at=timezone.now(),
|
||||
)
|
||||
StudentAnswer.objects.create(
|
||||
attempt=attempt,
|
||||
question=question,
|
||||
answer_text="valid",
|
||||
is_correct=True,
|
||||
)
|
||||
self.profile.display_name = "Akhmed"
|
||||
self.profile.country = "Uzbekistan"
|
||||
self.profile.save(update_fields=["display_name", "country"])
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("leaderboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Akhmed")
|
||||
self.assertContains(response, "Uzbekistan")
|
||||
self.assertContains(response, "(You)")
|
||||
self.assertEqual(response.context["current_row"]["band"], 9.0)
|
||||
|
||||
def test_settings_save_public_leaderboard_identity(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("student_settings"),
|
||||
{
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"display_name": "StudyFox",
|
||||
"country": "Uzbekistan",
|
||||
"target_band": "7.5",
|
||||
"daily_goal": "60",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("student_settings"))
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.display_name, "StudyFox")
|
||||
self.assertEqual(self.profile.country, "Uzbekistan")
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("premium/", views.premium, name="premium"),
|
||||
path("dashboard/", views.student_dashboard, name="student_dashboard"),
|
||||
path("leaderboard/", views.leaderboard, name="leaderboard"),
|
||||
path("settings/", views.student_settings, name="student_settings"),
|
||||
path("missions/<str:mission_key>/claim/", views.claim_daily_mission, name="claim_daily_mission"),
|
||||
path("", include("allauth.urls")),
|
||||
]
|
||||
@@ -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