Initial commit

This commit is contained in:
kddd21a
2026-07-29 10:47:40 +00:00
commit 402c3ec6ed
171 changed files with 10536 additions and 0 deletions
View File
+20
View File
@@ -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)
)
+62
View File
@@ -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",)
+9
View File
@@ -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
+65
View File
@@ -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
+130
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -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}.")
)
+28
View File
@@ -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),
),
]
View File
+73
View File
@@ -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}"
+11
View File
@@ -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 %}
+54
View File
@@ -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>Heres 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>Todays 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 %}
+347
View File
@@ -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")
+11
View File
@@ -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")),
]
+414
View File
@@ -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},
)