Initial commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
*.zip
|
||||||
|
staticfiles
|
||||||
|
media
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000
|
||||||
|
|
||||||
|
# Production examples:
|
||||||
|
# DJANGO_DEBUG=False
|
||||||
|
# DJANGO_ALLOWED_HOSTS=example.com,www.example.com
|
||||||
|
# DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
|
||||||
|
# DATABASE_URL=postgresql://user:password@host:5432/database
|
||||||
|
# cPanel MySQL example: mysql://cpanel_user:password@localhost/cpanel_database
|
||||||
|
# DJANGO_DB_SSL_REQUIRE=False
|
||||||
|
# DJANGO_TRUST_PROXY_HEADERS=True
|
||||||
|
# DJANGO_SECURE_SSL_REDIRECT=True
|
||||||
|
# DJANGO_SECURE_HSTS_SECONDS=3600
|
||||||
|
# DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=False
|
||||||
|
# DJANGO_SECURE_HSTS_PRELOAD=False
|
||||||
|
# DJANGO_ACCOUNT_EMAIL_VERIFICATION=mandatory
|
||||||
|
# DJANGO_EMAIL_HOST=smtp.example.com
|
||||||
|
# DJANGO_EMAIL_PORT=587
|
||||||
|
# DJANGO_EMAIL_HOST_USER=your-smtp-username
|
||||||
|
# DJANGO_EMAIL_HOST_PASSWORD=your-smtp-password
|
||||||
|
# DJANGO_EMAIL_USE_TLS=True
|
||||||
|
# DJANGO_DEFAULT_FROM_EMAIL=IELTS Mock <noreply@example.com>
|
||||||
|
# DJANGO_SUPPORT_EMAIL=support@example.com
|
||||||
|
# SOCIAL_FACEBOOK_URL=https://facebook.com/your-page
|
||||||
|
# SOCIAL_INSTAGRAM_URL=https://instagram.com/your-page
|
||||||
|
# SOCIAL_YOUTUBE_URL=https://youtube.com/@your-channel
|
||||||
|
# SOCIAL_TELEGRAM_URL=https://t.me/your-channel
|
||||||
|
# DJANGO_LOG_LEVEL=INFO
|
||||||
|
# GUNICORN_WORKERS=3
|
||||||
|
# GUNICORN_TIMEOUT=120
|
||||||
|
# POSTGRES_DB=ielts_mock
|
||||||
|
# POSTGRES_USER=ielts_mock
|
||||||
|
# POSTGRES_PASSWORD=replace-with-a-strong-password
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Django
|
||||||
|
media/
|
||||||
|
staticfiles/
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Generated backups
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# JavaScript dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Generated artifacts and local QA output
|
||||||
|
outputs/
|
||||||
|
generated/
|
||||||
|
release/
|
||||||
|
.codex-work/
|
||||||
|
.codex_spreadsheet_work/
|
||||||
|
.xlsx_builder/
|
||||||
|
|
||||||
|
# Local agent/editor metadata
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
.cursor/
|
||||||
|
.impeccable/
|
||||||
|
testpoint.tar
|
||||||
|
testpoint.tar
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# cPanel deployment
|
||||||
|
|
||||||
|
## Hosting requirements
|
||||||
|
|
||||||
|
- cPanel **Setup Python App** (Phusion Passenger)
|
||||||
|
- Python 3.12 or newer
|
||||||
|
- SSH or cPanel Terminal access
|
||||||
|
- PostgreSQL or MariaDB/MySQL; SQLite can be used only for a small single-server launch
|
||||||
|
- A working SMTP mailbox or external SMTP provider
|
||||||
|
|
||||||
|
## 1. Upload the application
|
||||||
|
|
||||||
|
Create a folder such as `ielts_mock` outside `public_html` when the host permits it. Upload and extract the cPanel release package into that folder. Do not upload a development `.env`, `db.sqlite3`, backups, or local media.
|
||||||
|
|
||||||
|
## 2. Create the Python application
|
||||||
|
|
||||||
|
In **Setup Python App**, create an application with:
|
||||||
|
|
||||||
|
- Python version: 3.12 or newer
|
||||||
|
- Application root: `ielts_mock`
|
||||||
|
- Application URL: the intended domain or subdomain
|
||||||
|
- Startup file: `passenger_wsgi.py`
|
||||||
|
- Entry point: `application`
|
||||||
|
|
||||||
|
Save the virtual-environment activation command displayed by cPanel.
|
||||||
|
|
||||||
|
## 3. Install dependencies
|
||||||
|
|
||||||
|
Open cPanel Terminal, activate the application virtual environment, change to the application root, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Configure environment variables
|
||||||
|
|
||||||
|
Use the environment-variable section in **Setup Python App** when available. Otherwise create a private `.env` in the application root with permissions `600`.
|
||||||
|
|
||||||
|
Required production values:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DJANGO_DEBUG=False
|
||||||
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
|
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
|
||||||
|
DJANGO_TRUST_PROXY_HEADERS=True
|
||||||
|
DJANGO_SECURE_SSL_REDIRECT=True
|
||||||
|
DJANGO_SECURE_HSTS_SECONDS=3600
|
||||||
|
DJANGO_ACCOUNT_EMAIL_VERIFICATION=mandatory
|
||||||
|
DATABASE_URL=mysql://CPANEL_USER:PASSWORD@localhost/CPANEL_DATABASE
|
||||||
|
DJANGO_DB_SSL_REQUIRE=False
|
||||||
|
DJANGO_EMAIL_HOST=smtp.example.com
|
||||||
|
DJANGO_EMAIL_PORT=587
|
||||||
|
DJANGO_EMAIL_HOST_USER=mailbox@example.com
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=replace-with-mailbox-password
|
||||||
|
DJANGO_EMAIL_USE_TLS=True
|
||||||
|
DJANGO_DEFAULT_FROM_EMAIL=IELTS Mock <mailbox@example.com>
|
||||||
|
DJANGO_SUPPORT_EMAIL=support@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not enable HSTS subdomains or preload until every required subdomain is permanently HTTPS-only.
|
||||||
|
For a remote PostgreSQL provider, use its PostgreSQL URL and set `DJANGO_DB_SSL_REQUIRE=True`. For cPanel's local MySQL/MariaDB service, SSL is normally disabled because the connection never leaves the server.
|
||||||
|
|
||||||
|
## 5. Initialize Django
|
||||||
|
|
||||||
|
With the virtual environment active:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
python manage.py seed_practice_library
|
||||||
|
python manage.py createsuperuser
|
||||||
|
python manage.py check --deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Configure static and media URLs
|
||||||
|
|
||||||
|
The application serves versioned static files through WhiteNoise. For uploaded listening audio, create a `/media/` mapping from the domain document root to the application's `media` directory. With SSH this is normally a symbolic link similar to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s /home/CPANEL_USER/ielts_mock/media /home/CPANEL_USER/public_html/media
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace both paths with the real application root and the real document root shown in cPanel. If the host blocks symbolic links, configure the mapping through cPanel support or use external object storage.
|
||||||
|
|
||||||
|
## 7. Enable HTTPS and restart
|
||||||
|
|
||||||
|
Enable AutoSSL for the domain in cPanel, confirm HTTPS works, and restart the Python application from **Setup Python App**. Then verify:
|
||||||
|
|
||||||
|
- `/health/` returns HTTP 200
|
||||||
|
- `/`, `/accounts/login/`, `/accounts/signup/`, and `/accounts/dashboard/`
|
||||||
|
- `/admin/`
|
||||||
|
- password-reset delivery
|
||||||
|
- contact-form delivery
|
||||||
|
|
||||||
|
## Updating later
|
||||||
|
|
||||||
|
Back up the database and media, upload the new files, activate the virtual environment, run migrations and static collection, then restart the Python application.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# IELTS Mock deployment runbook
|
||||||
|
|
||||||
|
## Before the first deployment
|
||||||
|
|
||||||
|
1. Create a private `.env` from `.env.example`.
|
||||||
|
2. Generate a long random `DJANGO_SECRET_KEY`.
|
||||||
|
3. Set `DJANGO_DEBUG=False`.
|
||||||
|
4. Set the real domain in `DJANGO_ALLOWED_HOSTS` and HTTPS origins in `DJANGO_CSRF_TRUSTED_ORIGINS`.
|
||||||
|
5. Set a strong PostgreSQL password and production `DATABASE_URL`.
|
||||||
|
6. Configure all `DJANGO_EMAIL_*` values and send a real password-reset test.
|
||||||
|
7. Keep `DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=False` and `DJANGO_SECURE_HSTS_PRELOAD=False` until every required subdomain is confirmed HTTPS-only.
|
||||||
|
8. Create the first administrator with `python manage.py createsuperuser` inside the deployed web container.
|
||||||
|
|
||||||
|
## Release procedure
|
||||||
|
|
||||||
|
1. Run `powershell -ExecutionPolicy Bypass -File scripts/check_deployment.ps1` locally.
|
||||||
|
2. Back up the current production database and media.
|
||||||
|
3. Build and deploy the new image.
|
||||||
|
4. The container entrypoint runs migrations and `collectstatic` before Gunicorn starts.
|
||||||
|
5. Confirm `/health/` returns HTTP 200.
|
||||||
|
6. Check `/`, `/accounts/login/`, `/faq/`, `/contact/`, and `/admin/`.
|
||||||
|
7. Submit a contact message and password-reset request.
|
||||||
|
8. Review application and proxy logs for errors.
|
||||||
|
|
||||||
|
The repository is deployment-ready, but the final upload requires the chosen host, public domain, PostgreSQL credentials, SMTP credentials, and DNS/HTTPS access. Never commit these values to the repository.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
- Windows Docker host: `powershell -ExecutionPolicy Bypass -File scripts/backup_docker.ps1`
|
||||||
|
- Linux/macOS Docker host: `sh scripts/backup_docker.sh`
|
||||||
|
- Local SQLite development copy: `powershell -ExecutionPolicy Bypass -File scripts/backup_local.ps1`
|
||||||
|
- Store copies outside the server and test restoration regularly.
|
||||||
|
- Never consider a backup reliable until a restoration test succeeds.
|
||||||
|
|
||||||
|
## Routine operations
|
||||||
|
|
||||||
|
- Monitor `/health/` and HTTPS certificate expiration.
|
||||||
|
- Review error logs and disk usage.
|
||||||
|
- Apply dependency/security updates through a tested deployment.
|
||||||
|
- Verify automated backups and keep more than one retention point.
|
||||||
|
- Do not edit production data directly unless a backup has been confirmed.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
1. Stop routing new traffic to the failed release.
|
||||||
|
2. Redeploy the previously working image.
|
||||||
|
3. Restore the database only if the migration or release changed data incompatibly.
|
||||||
|
4. Restore media independently if uploaded files were affected.
|
||||||
|
5. Run `/health/` and the release smoke checks before reopening traffic.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system django && adduser --system --ingroup django django
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN chmod +x /app/entrypoint.sh && \
|
||||||
|
mkdir -p /app/staticfiles /app/media && \
|
||||||
|
chown -R django:django /app
|
||||||
|
|
||||||
|
USER django
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Professional Review — Initial Pass
|
||||||
|
|
||||||
|
## Fixed in this version
|
||||||
|
|
||||||
|
1. Removed three conflicting `student_dashboard` view definitions and kept one authenticated, data-driven implementation.
|
||||||
|
2. Prevented users from opening sections that do not belong to their own exam attempt.
|
||||||
|
3. Prevented editing completed attempts and viewing incomplete results.
|
||||||
|
4. Recorded `submitted_at` when the final section is submitted, fixing average-time calculations.
|
||||||
|
5. Preserved the latest attempt per exam instead of accidentally overwriting it with an older one.
|
||||||
|
6. Validated the exam-list status filter.
|
||||||
|
7. Removed duplicate `Section.__str__` code.
|
||||||
|
8. Removed duplicate CSS/CDN imports and references to missing static files.
|
||||||
|
9. Moved secret key, debug mode, and hosts toward environment-based configuration.
|
||||||
|
10. Changed the project timezone to `Asia/Tashkent` and login redirect to the student dashboard.
|
||||||
|
11. Added `requirements.txt`, `.env.example`, and setup documentation.
|
||||||
|
12. Removed accidental Microsoft Word support folders from template directories.
|
||||||
|
|
||||||
|
## Important remaining work
|
||||||
|
|
||||||
|
### Priority 1 — Core exam reliability
|
||||||
|
- Add database constraints to prevent duplicate answers and duplicate active attempts.
|
||||||
|
- Add proper section/question ordering fields and navigation state.
|
||||||
|
- Save answers periodically so refreshes do not erase work.
|
||||||
|
- Store server-side exam deadlines; the current JavaScript-only timer can be bypassed.
|
||||||
|
- Add validation for required questions and malformed JSON options.
|
||||||
|
- Build robust IELTS scoring conversion instead of displaying only raw correct counts.
|
||||||
|
|
||||||
|
### Priority 2 — Exam builder
|
||||||
|
- Create staff-only exam builder pages instead of relying solely on Django admin.
|
||||||
|
- Support bulk question and answer import.
|
||||||
|
- Add passage, audio, question-group, and preview workflows.
|
||||||
|
- Support IELTS question types separately: true/false/not given, headings, sentence completion, maps, multiple selection, etc.
|
||||||
|
|
||||||
|
### Priority 3 — Product UI
|
||||||
|
- Consolidate Bootstrap and Tailwind into one deliberate frontend system.
|
||||||
|
- Replace placeholder dashboard values with real metrics.
|
||||||
|
- Add responsive mobile navigation and accessible form states.
|
||||||
|
- Create consistent empty, loading, success, and error states.
|
||||||
|
|
||||||
|
### Priority 4 — Deployment
|
||||||
|
- Use PostgreSQL in production.
|
||||||
|
- Configure secure cookies, HTTPS redirect, CSRF trusted origins, email delivery, and media storage.
|
||||||
|
- Add automated tests and a deployment pipeline.
|
||||||
|
- Never upload virtual environments, `db.sqlite3`, `.env`, or collected `staticfiles` to Git.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
web: gunicorn core.wsgi:application --bind 0.0.0.0:$PORT --workers 3 --timeout 120
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# IELTS Mock Platform
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
# Windows: venv\Scripts\activate
|
||||||
|
# macOS/Linux: source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py seed_practice_library
|
||||||
|
python manage.py createsuperuser
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` before starting. Development defaults use SQLite and the console email backend.
|
||||||
|
|
||||||
|
## Production foundation
|
||||||
|
|
||||||
|
- Set `DJANGO_DEBUG=False` and provide a long, private `DJANGO_SECRET_KEY`.
|
||||||
|
- Set the public domain in `DJANGO_ALLOWED_HOSTS` and `DJANGO_CSRF_TRUSTED_ORIGINS`.
|
||||||
|
- Set `DATABASE_URL` to the production PostgreSQL connection string.
|
||||||
|
- Configure the `DJANGO_EMAIL_*` variables for password resets and email verification.
|
||||||
|
- Use persistent storage for uploaded media. WhiteNoise serves static assets only.
|
||||||
|
- Enable `DJANGO_TRUST_PROXY_HEADERS=True` only when the hosting proxy reliably sets `X-Forwarded-Proto`.
|
||||||
|
- Start HSTS conservatively and increase it after HTTPS is confirmed across required subdomains.
|
||||||
|
|
||||||
|
## Portable Docker deployment
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and replace every production value, including `POSTGRES_PASSWORD`.
|
||||||
|
2. Run `docker compose up --build -d`.
|
||||||
|
3. The web container applies migrations, collects static assets, and starts Gunicorn.
|
||||||
|
4. Configure the load balancer or uptime monitor to check `/health/`.
|
||||||
|
5. Back up both the `postgres_data` and `media_data` volumes.
|
||||||
|
|
||||||
|
## Automated verification
|
||||||
|
|
||||||
|
- CI performs migration checks, Django's deployment audit, static collection, and the full test suite.
|
||||||
|
- On Windows, run `powershell -ExecutionPolicy Bypass -File scripts/check_deployment.ps1` before a release.
|
||||||
|
- Release, monitoring, backup, and rollback procedures are in `DEPLOYMENT.md`.
|
||||||
|
- Shared-hosting instructions for Phusion Passenger are in `CPANEL_DEPLOYMENT.md`.
|
||||||
|
|
||||||
|
## Main routes
|
||||||
|
|
||||||
|
- `/` - landing page
|
||||||
|
- `/accounts/login/` - login
|
||||||
|
- `/accounts/dashboard/` - student dashboard
|
||||||
|
- `/accounts/settings/` - student profile settings
|
||||||
|
- `/exams/` - mock-test catalogue
|
||||||
|
- `/admin/` - content administration
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const outDir = "C:/Users/Anvar/Desktop/cat/outputs/tuatara_grouped";
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
const wb = Workbook.create();
|
||||||
|
const instructions = wb.worksheets.add("Instructions");
|
||||||
|
const test = wb.worksheets.add("Test");
|
||||||
|
const sections = wb.worksheets.add("Sections");
|
||||||
|
const groups = wb.worksheets.add("Groups");
|
||||||
|
const questions = wb.worksheets.add("Questions");
|
||||||
|
|
||||||
|
const passage = `The tuatara – past and future
|
||||||
|
|
||||||
|
The New Zealand species of lizard, the tuatara, is firmly embedded in the national psyche: an icon for today which dates from the age of dinosaurs; an ancient reptile common before the arrival of humans.
|
||||||
|
|
||||||
|
When European explorers reached New Zealand in 1769 they found two large islands, which together they called the “mainland”, and many tiny offshore islands around the coast. The naturalists who came with the explorers disregarded the tuatara, though it is now several times more numerous than it once was on the mainland. One of the first scientists who realised that aspects of tuatara anatomy were odd – unchanged for tens of thousands of years – was Albert Gunther in 1876. Gunther believed the tuatara was one of the most valuable objects in zoological anatomical collections, and also noted, in passing, that the reptile was likely to become extinct. From the perspective of the tuatara, it is striking how Gunther’s contentions were products of their age, strongly influenced by Charles Darwin’s theory, which had only recently been published. Their views were something like this: “Extinction is a natural process. It is sad that species disappear, but that is part of nature.”
|
||||||
|
|
||||||
|
There is a second important aspect of Gunther’s work. He recorded, correctly, that some of the mammals introduced by Europeans were predators of the tuatara – particularly rats. But what he did not realise was that New Zealand has two species of rat, both introduced, both with an appetite for tuatara: the ship’s rat came with European explorers and settlers; the kiore rat had already been brought by Polynesian explorers from Pacific islands. Gunther failed to recognise the distinction, believing all rats to be a relatively recent introduction.
|
||||||
|
|
||||||
|
Little further research was conducted until Ian Crook of the NZ Wildlife Service published his findings in 1973, which can be summarised as follows. Tuatara thrive on offshore islands with no rats. Tuatara never survived on islands with ship’s rats. On a few islands, however, there was declining evidence that tuatara and kiore could coexist. Rather, Crook proposed, kiore probably only arrived recently on such islands, and thus the small populations represent extinctions in progress.
|
||||||
|
|
||||||
|
Throughout the 1990s, Richard Holdaway and his colleagues at Victoria University in Wellington documented the surprising discovery that kiore probably arrived about 800 years ago. How did this happen? Presumably, Holdaway argued, the kiore were brought by Polynesian explorers who visited the country but did not settle. Thereafter, the rats were agents of ecological warfare, exterminating perhaps 1,000–3,000 species. Thus, tuatara and many other species were already rare or extinct when permanent human inhabitants – the Maori – arrived around 1300. This hypothesis is still being debated, but the evidence continues to accumulate in its favour.
|
||||||
|
|
||||||
|
Conservation practice has changed dramatically since Crook’s findings were published in 1973. Eradication of rats from any given environment was believed to be virtually impossible until about 1980, but since then has become routine. Enormous conservation benefits are accruing as newly rat-free offshore islands are providing sanctuaries for the country’s rarest species. In 1995, for example, Nicola Nelson’s Department of Conservation established 68 tuatara on Titi Island. Four more populations of tuatara have been established elsewhere under similar conditions.
|
||||||
|
|
||||||
|
Today, numbers of tuatara are still a fraction of what they once were, but for the first time in 800 years the decline has been reversed.
|
||||||
|
|
||||||
|
While the recovery of rare species is itself a good thing, the truly significant outcome of this research is that it liberates the imagination. If we can remove predatory introduced mammals from islands, why not from the mainland? Our rivers, for example, are full of surrogate rats, in the form of introduced species of fish called trout. Should we now go further and consider reintroducing native fish to our mainland rivers? Similarly, can bellbirds and tuis replace birds like starlings and mynas?
|
||||||
|
|
||||||
|
The answers to such questions are uncertain, and opposing sides will doubtless be fiercely debated. But the role of scientific knowledge in illuminating the past will be crucial. Perhaps our children will come to believe in the restoration of species, in the same way that our generation refuses to accept the extinction of species. Creating the future we wish for our children and ourselves is not primarily about the past, but about imagining and then using the past. For 80 million years until humans arrived, tuatara occurred throughout New Zealand – might they do so again?`;
|
||||||
|
|
||||||
|
instructions.getRange("A1:B7").values = [
|
||||||
|
["IELTS Mock — Ready-to-import workbook", ""],
|
||||||
|
["Purpose", "Academic Reading Passage 3, Questions 27–40"],
|
||||||
|
["Upload", "Admin → Import Excel tests → choose this file → review → confirm"],
|
||||||
|
["Grouped layout", "Questions 36–40 use summary_36_40 and render as one inline worksheet."],
|
||||||
|
["Ordinary layout", "Questions 27–35 remain standard multiple-choice cards."],
|
||||||
|
["Compatibility", "Do not rename worksheets or change row-1 headers."],
|
||||||
|
["Review", "Verify the cleaned passage and answer key before publishing."],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.getRange("A1:B4").values = [["field","value"],["title","The tuatara – past and future"],["description","Academic Reading Passage 3 with Questions 27–40."],["category","reading"]];
|
||||||
|
sections.getRange("A1:D2").values = [["section_order","section_type","time_limit_minutes","passage_text"],[1,"reading",20,passage]];
|
||||||
|
|
||||||
|
const layout = `<h3>What conclusions can we draw?</h3><p>The most important result of the tuatara research is that it frees our [[36]].</p><p>For example, there are many similarities between rats and [[37]]. Should we now go further and consider reintroducing [[38]] to our mainland rivers?</p><p>Perhaps our children will come to believe in the [[39]] of species, in the same way that our generation refuses to accept [[40]] of species.</p><div><p><strong>A</strong> natural evolution</p><p><strong>B</strong> creative thought</p><p><strong>C</strong> indigenous plants</p><p><strong>D</strong> trout</p><p><strong>E</strong> pollution</p><p><strong>F</strong> restoration</p><p><strong>G</strong> native fish</p><p><strong>H</strong> extinction</p></div>`;
|
||||||
|
groups.getRange("A1:G2").values = [["section_order","group_key","group_order","layout_type","title","instructions","layout_html"],[1,"summary_36_40",36,"notes","What conclusions can we draw?","<p>Questions 36–40</p><p>Complete the summary using the list of words, <strong>A–H</strong>, below.</p><p>Write the correct letter, <strong>A–H</strong>, in boxes 36–40.</p>",layout]];
|
||||||
|
|
||||||
|
const mcq = (order,prompt,options,answer,reference) => [1,order,"mcq",prompt,options.join(" | "),answer,"",reference,"",""];
|
||||||
|
const gap = (order,prompt,answer,reference) => [1,order,"gap",prompt,"",answer,"",reference,"","summary_36_40"];
|
||||||
|
const qrows = [
|
||||||
|
["section_order","question_order","question_type","prompt","options","correct_answer","explanation","passage_reference","notes_for_admin","group_key"],
|
||||||
|
mcq(27,"What are we told about the Europeans who arrived in 1769?",["A They thought there was only one large island.","B They had not come to study natural history.","C They had no interest in the tuatara.","D They sent a tuatara to the British Museum."],"C They had no interest in the tuatara.","The naturalists who came with the explorers disregarded the tuatara."),
|
||||||
|
mcq(28,"What does the text say about Albert Gunther in paragraph 3?",["A He believed the tuatara could fetch a high price.","B He was typical of his generation of scientists.","C He disagreed with Charles Darwin's theory.","D He wanted to stop the tuatara becoming extinct."],"B He was typical of his generation of scientists.","Gunther’s contentions were products of their age, strongly influenced by Charles Darwin’s theory."),
|
||||||
|
mcq(29,"What did Albert Gunther think about the rats in New Zealand?",["A They did not eat the tuatara.","B There was only one species of rat.","C There had always been rats in New Zealand.","D They were killed by Polynesians."],"B There was only one species of rat.","Gunther failed to recognise the distinction, believing all rats to be a relatively recent introduction."),
|
||||||
|
mcq(30,"What did Ian Crook conclude from his research?",["A Tuatara are safe on small islands.","B Kiore rats kill more tuatara than ship’s rats.","C Ship’s rats kill more tuatara than kiore rats.","D Rats and tuatara cannot live together."],"D Rats and tuatara cannot live together.","Crook proposed that the small populations represent extinctions in progress."),
|
||||||
|
mcq(31,"What were the findings of Richard Holdaway's research?",["A Maori settled more recently than previously thought.","B The first Polynesian explorers formed permanent settlements.","C Ship's rats are the oldest rat species in the country.","D Rats caused extinctions before any humans settled."],"D Rats caused extinctions before any humans settled.","Tuatara and many other species were already rare or extinct when permanent human inhabitants arrived around 1300."),
|
||||||
|
mcq(32,"The available research supports Holdaway's theory but it has not been proved.",["YES","NO","NOT GIVEN"],"YES","This hypothesis is still being debated, but the evidence continues to accumulate in its favour."),
|
||||||
|
mcq(33,"Nowadays, it is possible to totally destroy a population of rats on a small island.",["YES","NO","NOT GIVEN"],"YES","Eradication of rats ... has become routine."),
|
||||||
|
mcq(34,"Crook was the first person to recognize the potential of offshore islands as sanctuaries.",["YES","NO","NOT GIVEN"],"NOT GIVEN","The passage does not state that Crook was the first person to recognise this potential."),
|
||||||
|
mcq(35,"Tuatara numbers are continuing to fall.",["YES","NO","NOT GIVEN"],"NO","For the first time in 800 years the decline has been reversed."),
|
||||||
|
gap(36,"The most important result of the tuatara research is that it frees our _____.","B","The truly significant outcome of this research is that it liberates the imagination."),
|
||||||
|
gap(37,"There are many similarities between rats and _____.","D","Our rivers ... are full of surrogate rats, in the form of introduced species of fish called trout."),
|
||||||
|
gap(38,"Consider reintroducing _____ to our mainland rivers.","G","Should we now go further and consider reintroducing native fish to our mainland rivers?"),
|
||||||
|
gap(39,"Our children may come to believe in the _____ of species.","F","Perhaps our children will come to believe in the restoration of species."),
|
||||||
|
gap(40,"Our generation refuses to accept _____ of species.","H","Our generation refuses to accept the extinction of species."),
|
||||||
|
];
|
||||||
|
questions.getRange(`A1:J${qrows.length}`).values = qrows;
|
||||||
|
|
||||||
|
for (const sheet of [instructions,test,sections,groups,questions]) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
sheet.freezePanes.freezeRows(1);
|
||||||
|
const used = sheet.getUsedRange();
|
||||||
|
used.format.wrapText = true;
|
||||||
|
used.format.verticalAlignment = "top";
|
||||||
|
const header = sheet.getRangeByIndexes(0,0,1,used.columnCount);
|
||||||
|
header.format = {fill:"#10213E",font:{bold:true,color:"#FFFFFF"},verticalAlignment:"center",wrapText:true};
|
||||||
|
header.format.rowHeight = 28;
|
||||||
|
}
|
||||||
|
instructions.getRange("A1:B1").format.fill = "#1463E9";
|
||||||
|
instructions.getRange("A1:B1").format.font = {bold:true,color:"#FFFFFF",size:14};
|
||||||
|
instructions.getRange("A1:B7").format.borders = {preset:"insideHorizontal",style:"thin",color:"#DCE5EF"};
|
||||||
|
|
||||||
|
const widths = {
|
||||||
|
Instructions:[18,82], Test:[22,76], Sections:[16,18,22,110], Groups:[16,20,16,17,35,72,115], Questions:[15,15,18,58,70,34,38,72,26,22]
|
||||||
|
};
|
||||||
|
for (const [name,vals] of Object.entries(widths)) vals.forEach((w,i)=>wb.worksheets.getItem(name).getRangeByIndexes(0,i,wb.worksheets.getItem(name).getUsedRange().rowCount,1).format.columnWidth=w);
|
||||||
|
sections.getRange("A2:D2").format.rowHeight = 190;
|
||||||
|
groups.getRange("A2:G2").format.rowHeight = 150;
|
||||||
|
questions.getRange(`A2:J${qrows.length}`).format.rowHeight = 66;
|
||||||
|
test.getRange("B4").dataValidation = {rule:{type:"list",values:["reading","listening","writing","speaking","full"]}};
|
||||||
|
sections.getRange("B2").dataValidation = {rule:{type:"list",values:["reading","listening","writing","speaking"]}};
|
||||||
|
groups.getRange("D2").dataValidation = {rule:{type:"list",values:["notes","table","flow"]}};
|
||||||
|
questions.getRange(`C2:C${qrows.length}`).dataValidation = {rule:{type:"list",values:["mcq","gap","matching","essay","speaking"]}};
|
||||||
|
|
||||||
|
for (const name of ["Instructions","Test","Sections","Groups","Questions"]) {
|
||||||
|
const preview = await wb.render({sheetName:name,autoCrop:"all",scale:1,format:"png"});
|
||||||
|
await fs.writeFile(`${outDir}/${name}.png`,new Uint8Array(await preview.arrayBuffer()));
|
||||||
|
}
|
||||||
|
console.log((await wb.inspect({kind:"table",range:"Questions!A1:J15",include:"values,formulas",tableMaxRows:15,tableMaxCols:10,maxChars:8000})).ndjson);
|
||||||
|
console.log((await wb.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:100},summary:"formula error scan"})).ndjson);
|
||||||
|
const output = await SpreadsheetFile.exportXlsx(wb);
|
||||||
|
await output.save(`${outDir}/Tuatara_Passage_3_Grouped_Import.xlsx`);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-ielts_mock}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-ielts_mock}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ielts_mock} -d ${POSTGRES_DB:-ielts_mock}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-ielts_mock}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-ielts_mock}
|
||||||
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-False}
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?Set DJANGO_SECRET_KEY in .env}
|
||||||
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-http://localhost:8000}
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8000}:${PORT:-8000}"
|
||||||
|
volumes:
|
||||||
|
- media_data:/app/media
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
media_data:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: testpoint-config
|
||||||
|
namespace: testpoint
|
||||||
|
data:
|
||||||
|
DEBUG: "False"
|
||||||
|
ALLOWED_HOSTS: "testpoint.uzbutterfly.com"
|
||||||
|
PORT: "8000"
|
||||||
|
GUNICORN_WORKERS: "3"
|
||||||
|
GUNICORN_TIMEOUT: "120"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
except ImportError:
|
||||||
|
pymysql = None
|
||||||
|
|
||||||
|
if pymysql is not None:
|
||||||
|
pymysql.install_as_MySQLdb()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from exams.models import ExamSet, StudentAttempt
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_points(values, maximum, width=680, height=170, padding=18):
|
||||||
|
"""Return stable SVG points for a seven-day chart, including an empty state."""
|
||||||
|
maximum = max(maximum, 1)
|
||||||
|
usable_width = width - padding * 2
|
||||||
|
usable_height = height - padding * 2
|
||||||
|
points = []
|
||||||
|
for index, value in enumerate(values):
|
||||||
|
x = padding + usable_width * index / max(len(values) - 1, 1)
|
||||||
|
y = padding + usable_height - (value / maximum * usable_height)
|
||||||
|
points.append(f"{x:.1f},{y:.1f}")
|
||||||
|
return " ".join(points)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_callback(request, context):
|
||||||
|
today = timezone.localdate()
|
||||||
|
days = [today - timedelta(days=offset) for offset in range(6, -1, -1)]
|
||||||
|
starts = [timezone.make_aware(datetime.combine(day, time.min)) for day in days]
|
||||||
|
ends = [start + timedelta(days=1) for start in starts]
|
||||||
|
|
||||||
|
attempt_counts = []
|
||||||
|
active_student_counts = []
|
||||||
|
for start, end in zip(starts, ends):
|
||||||
|
attempts = StudentAttempt.objects.filter(started_at__gte=start, started_at__lt=end)
|
||||||
|
attempt_counts.append(attempts.count())
|
||||||
|
active_student_counts.append(attempts.values("student_id").distinct().count())
|
||||||
|
|
||||||
|
total_attempts = StudentAttempt.objects.count()
|
||||||
|
seven_day_attempts = sum(attempt_counts)
|
||||||
|
active_students = StudentAttempt.objects.filter(started_at__gte=starts[0]).values("student_id").distinct().count()
|
||||||
|
recent_tests = (
|
||||||
|
ExamSet.objects.annotate(
|
||||||
|
question_count=Count("sections__questions", distinct=True),
|
||||||
|
attempt_count=Count("studentattempt", distinct=True),
|
||||||
|
)
|
||||||
|
.order_by("-created_at")[:6]
|
||||||
|
)
|
||||||
|
chart_maximum = max([*attempt_counts, *active_student_counts, 1])
|
||||||
|
context.update(
|
||||||
|
{
|
||||||
|
"dashboard_metrics": [
|
||||||
|
{
|
||||||
|
"label": "Published tests",
|
||||||
|
"value": ExamSet.objects.filter(is_published=True).count(),
|
||||||
|
"detail": "Tests available to students",
|
||||||
|
"icon": "description",
|
||||||
|
"link": "/admin/exams/examset/?is_published__exact=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Student attempts",
|
||||||
|
"value": total_attempts,
|
||||||
|
"detail": f"{seven_day_attempts} started in the last 7 days",
|
||||||
|
"icon": "monitoring",
|
||||||
|
"link": "/admin/exams/studentattempt/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Active students",
|
||||||
|
"value": active_students,
|
||||||
|
"detail": "Students active in the last 7 days",
|
||||||
|
"icon": "group",
|
||||||
|
"link": "/admin/auth/user/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"chart_labels": [f"{day.strftime('%b')} {day.day}" for day in days],
|
||||||
|
"attempt_points": _chart_points(attempt_counts, chart_maximum),
|
||||||
|
"active_points": _chart_points(active_student_counts, chart_maximum),
|
||||||
|
"has_chart_activity": any(attempt_counts),
|
||||||
|
"recent_tests": recent_tests,
|
||||||
|
"total_users": get_user_model().objects.count(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return context
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for core project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from accounts.access import has_lifetime_premium
|
||||||
|
|
||||||
|
|
||||||
|
def site_settings(request):
|
||||||
|
return {
|
||||||
|
"site_support_email": settings.SUPPORT_EMAIL,
|
||||||
|
"social_facebook_url": settings.SOCIAL_FACEBOOK_URL,
|
||||||
|
"social_instagram_url": settings.SOCIAL_INSTAGRAM_URL,
|
||||||
|
"social_youtube_url": settings.SOCIAL_YOUTUBE_URL,
|
||||||
|
"social_telegram_url": settings.SOCIAL_TELEGRAM_URL,
|
||||||
|
"has_lifetime_premium": has_lifetime_premium(request.user),
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class ContactForm(forms.Form):
|
||||||
|
name = forms.CharField(max_length=100)
|
||||||
|
email = forms.EmailField()
|
||||||
|
subject = forms.CharField(max_length=160)
|
||||||
|
message = forms.CharField(max_length=4000, widget=forms.Textarea)
|
||||||
|
website = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||||
|
|
||||||
|
def clean_website(self):
|
||||||
|
value = self.cleaned_data.get("website", "")
|
||||||
|
if value:
|
||||||
|
raise forms.ValidationError("Invalid submission.")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Django settings for core project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 6.0.7.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.templatetags.static import static
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name, default=False):
|
||||||
|
return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_list(name, default=""):
|
||||||
|
return [value.strip() for value in os.getenv(name, default).split(",") if value.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_env(path):
|
||||||
|
"""Load a simple KEY=VALUE .env file without overriding server variables."""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
load_local_env(BASE_DIR / ".env")
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
DEVELOPMENT_SECRET_KEY = 'django-insecure-development-only-change-me'
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', DEVELOPMENT_SECRET_KEY)
|
||||||
|
DEBUG = env_bool('DJANGO_DEBUG', True)
|
||||||
|
ALLOWED_HOSTS = env_list('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost')
|
||||||
|
|
||||||
|
if not DEBUG and SECRET_KEY == DEVELOPMENT_SECRET_KEY:
|
||||||
|
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a secure value in production.")
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'unfold',
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'django.contrib.sites',
|
||||||
|
|
||||||
|
'allauth',
|
||||||
|
'allauth.account',
|
||||||
|
'allauth.socialaccount',
|
||||||
|
'allauth.socialaccount.providers.google',
|
||||||
|
|
||||||
|
'exams',
|
||||||
|
'accounts.apps.AccountsConfig',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Keep the back office clean and close to Unfold's maintained defaults.
|
||||||
|
UNFOLD = {
|
||||||
|
"SITE_TITLE": "Testpoint Admin",
|
||||||
|
"SITE_HEADER": "Testpoint",
|
||||||
|
"SITE_ICON": lambda request: static("images/favicon.svg"),
|
||||||
|
"SITE_SYMBOL": "menu_book",
|
||||||
|
"SHOW_HISTORY": True,
|
||||||
|
"SHOW_VIEW_ON_SITE": True,
|
||||||
|
"DASHBOARD_CALLBACK": "core.admin_dashboard.dashboard_callback",
|
||||||
|
"STYLES": [lambda request: "/static/css/unfold-dashboard.css"],
|
||||||
|
"SIDEBAR": {
|
||||||
|
"show_search": True,
|
||||||
|
"show_all_applications": False,
|
||||||
|
"navigation": [
|
||||||
|
{
|
||||||
|
"title": "Navigation",
|
||||||
|
"separator": True,
|
||||||
|
"items": [
|
||||||
|
{"title": "Dashboard", "icon": "dashboard", "link": reverse_lazy("admin:index")},
|
||||||
|
{"title": "Tests", "icon": "description", "link": reverse_lazy("admin:exams_examset_changelist")},
|
||||||
|
{"title": "Questions", "icon": "quiz", "link": reverse_lazy("admin:exams_question_changelist")},
|
||||||
|
{"title": "Results", "icon": "monitoring", "link": reverse_lazy("admin:exams_studentattempt_changelist")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Create a test",
|
||||||
|
"collapsible": True,
|
||||||
|
"items": [
|
||||||
|
{"title": "Reading test", "icon": "menu_book", "link": lambda request: reverse("admin:exams_examset_add") + "?category=reading"},
|
||||||
|
{"title": "Listening test", "icon": "headphones", "link": lambda request: reverse("admin:exams_examset_add") + "?category=listening"},
|
||||||
|
{"title": "Writing test", "icon": "edit_note", "link": lambda request: reverse("admin:exams_examset_add") + "?category=writing"},
|
||||||
|
{"title": "Speaking test", "icon": "mic", "link": lambda request: reverse("admin:exams_examset_add") + "?category=speaking"},
|
||||||
|
{"title": "Full Mock Test", "icon": "assignment", "link": lambda request: reverse("admin:exams_examset_add") + "?category=full"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Import content",
|
||||||
|
"collapsible": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Import Excel workbook",
|
||||||
|
"icon": "upload_file",
|
||||||
|
"link": reverse_lazy("admin:exams_examset_import_excel"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Users & settings",
|
||||||
|
"separator": True,
|
||||||
|
"items": [
|
||||||
|
{"title": "Users", "icon": "group", "link": reverse_lazy("admin:auth_user_changelist")},
|
||||||
|
{"title": "Student profiles", "icon": "school", "link": reverse_lazy("admin:accounts_studentprofile_changelist")},
|
||||||
|
{"title": "Site settings", "icon": "settings", "link": reverse_lazy("admin:sites_site_changelist")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"COLORS": {"primary": {
|
||||||
|
"50": "oklch(97.2% .016 251)", "100": "oklch(93.8% .043 251)",
|
||||||
|
"200": "oklch(88.8% .086 251)", "300": "oklch(80.7% .14 251)",
|
||||||
|
"400": "oklch(70.4% .177 251)", "500": "oklch(60.3% .195 251)",
|
||||||
|
"600": "oklch(52.7% .19 251)", "700": "oklch(45.2% .16 251)",
|
||||||
|
"800": "oklch(38.2% .127 251)", "900": "oklch(32.5% .091 251)",
|
||||||
|
"950": "oklch(23.8% .057 251)"
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
|
'allauth.account.auth_backends.AuthenticationBackend',
|
||||||
|
]
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'allauth.account.middleware.AccountMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
if not DEBUG:
|
||||||
|
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"core.context_processors.site_settings",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL = os.getenv('DATABASE_URL')
|
||||||
|
if DATABASE_URL:
|
||||||
|
import dj_database_url
|
||||||
|
|
||||||
|
database_ssl_default = not DEBUG and DATABASE_URL.startswith(
|
||||||
|
("postgres://", "postgresql://")
|
||||||
|
)
|
||||||
|
DATABASES = {
|
||||||
|
'default': dj_database_url.parse(
|
||||||
|
DATABASE_URL,
|
||||||
|
conn_max_age=600,
|
||||||
|
conn_health_checks=True,
|
||||||
|
ssl_require=env_bool('DJANGO_DB_SSL_REQUIRE', database_ssl_default),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if DATABASES['default']['ENGINE'] == 'django.db.backends.mysql':
|
||||||
|
mysql_options = DATABASES['default'].setdefault('OPTIONS', {})
|
||||||
|
mysql_options.setdefault('charset', 'utf8mb4')
|
||||||
|
mysql_options.setdefault('init_command', "SET sql_mode='STRICT_TRANS_TABLES'")
|
||||||
|
else:
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'Asia/Tashkent'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
|
# Where collectstatic will put files
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
# Extra places Django will look for static files (your app-level static folders)
|
||||||
|
STATICFILES_DIRS = [
|
||||||
|
BASE_DIR / "static",
|
||||||
|
]
|
||||||
|
|
||||||
|
STORAGES = {
|
||||||
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": (
|
||||||
|
"django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
if DEBUG
|
||||||
|
else "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
LOGIN_REDIRECT_URL = '/accounts/dashboard/'
|
||||||
|
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
|
||||||
|
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION = os.getenv(
|
||||||
|
'DJANGO_ACCOUNT_EMAIL_VERIFICATION',
|
||||||
|
'mandatory' if not DEBUG else 'none',
|
||||||
|
)
|
||||||
|
ACCOUNT_UNIQUE_EMAIL = True
|
||||||
|
ACCOUNT_LOGIN_METHODS = {'email'}
|
||||||
|
ACCOUNT_SIGNUP_FIELDS = ['email*', 'username*', 'password1*', 'password2*']
|
||||||
|
|
||||||
|
EMAIL_BACKEND = os.getenv(
|
||||||
|
'DJANGO_EMAIL_BACKEND',
|
||||||
|
(
|
||||||
|
'django.core.mail.backends.console.EmailBackend'
|
||||||
|
if DEBUG
|
||||||
|
else 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
EMAIL_HOST = os.getenv('DJANGO_EMAIL_HOST', 'localhost')
|
||||||
|
EMAIL_PORT = int(os.getenv('DJANGO_EMAIL_PORT', '587'))
|
||||||
|
EMAIL_HOST_USER = os.getenv('DJANGO_EMAIL_HOST_USER', '')
|
||||||
|
EMAIL_HOST_PASSWORD = os.getenv('DJANGO_EMAIL_HOST_PASSWORD', '')
|
||||||
|
EMAIL_USE_TLS = env_bool('DJANGO_EMAIL_USE_TLS', True)
|
||||||
|
DEFAULT_FROM_EMAIL = os.getenv('DJANGO_DEFAULT_FROM_EMAIL', 'Testpoint <noreply@localhost>')
|
||||||
|
SERVER_EMAIL = os.getenv('DJANGO_SERVER_EMAIL', DEFAULT_FROM_EMAIL)
|
||||||
|
SUPPORT_EMAIL = os.getenv('DJANGO_SUPPORT_EMAIL', 'support@ieltsmock.com')
|
||||||
|
SOCIAL_FACEBOOK_URL = os.getenv('SOCIAL_FACEBOOK_URL', '')
|
||||||
|
SOCIAL_INSTAGRAM_URL = os.getenv('SOCIAL_INSTAGRAM_URL', '')
|
||||||
|
SOCIAL_YOUTUBE_URL = os.getenv('SOCIAL_YOUTUBE_URL', '')
|
||||||
|
SOCIAL_TELEGRAM_URL = os.getenv('SOCIAL_TELEGRAM_URL', '')
|
||||||
|
|
||||||
|
SOCIALACCOUNT_PROVIDERS = {
|
||||||
|
'google': {
|
||||||
|
'SCOPE': ['profile', 'email'],
|
||||||
|
'AUTH_PARAMS': {'access_type': 'online'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Production security. These remain development-friendly while DEBUG=True.
|
||||||
|
CSRF_TRUSTED_ORIGINS = env_list('DJANGO_CSRF_TRUSTED_ORIGINS')
|
||||||
|
SECURE_SSL_REDIRECT = env_bool('DJANGO_SECURE_SSL_REDIRECT', not DEBUG)
|
||||||
|
SESSION_COOKIE_SECURE = not DEBUG
|
||||||
|
CSRF_COOKIE_SECURE = not DEBUG
|
||||||
|
SECURE_HSTS_SECONDS = int(os.getenv('DJANGO_SECURE_HSTS_SECONDS', '3600' if not DEBUG else '0'))
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool('DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
|
||||||
|
SECURE_HSTS_PRELOAD = env_bool('DJANGO_SECURE_HSTS_PRELOAD', False)
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
X_FRAME_OPTIONS = 'DENY'
|
||||||
|
|
||||||
|
if env_bool('DJANGO_TRUST_PROXY_HEADERS', False):
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
|
||||||
|
LOG_LEVEL = os.getenv('DJANGO_LOG_LEVEL', 'INFO')
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'standard': {
|
||||||
|
'format': '{asctime} {levelname} {name}: {message}',
|
||||||
|
'style': '{',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'standard',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'root': {'handlers': ['console'], 'level': LOG_LEVEL},
|
||||||
|
'loggers': {
|
||||||
|
'django.request': {
|
||||||
|
'handlers': ['console'],
|
||||||
|
'level': 'WARNING',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
|
||||||
|
class PublicNavigationTests(TestCase):
|
||||||
|
def test_public_navigation_has_working_destinations_and_mobile_bundle(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertContains(response, 'href="/#about"')
|
||||||
|
self.assertContains(response, 'href="/exams/"')
|
||||||
|
self.assertContains(response, 'href="/accounts/login/"')
|
||||||
|
self.assertContains(response, 'href="/accounts/signup/"')
|
||||||
|
self.assertContains(response, "bootstrap.bundle.min.js")
|
||||||
|
self.assertContains(response, 'id="mainNavigation"')
|
||||||
|
self.assertContains(response, "footer.css?v=20260727.2")
|
||||||
|
self.assertNotContains(response, 'href="#"')
|
||||||
|
|
||||||
|
def test_authenticated_navigation_shows_dashboard_and_logout(self):
|
||||||
|
user = User.objects.create_user(username="nav-user", password="test-pass-123")
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertContains(response, 'href="/accounts/dashboard/"')
|
||||||
|
self.assertContains(response, 'href="/accounts/logout/"')
|
||||||
|
self.assertNotContains(response, 'href="/accounts/login/"')
|
||||||
|
|
||||||
|
def test_admin_dashboard_uses_real_unfold_dashboard_shell(self):
|
||||||
|
admin_user = User.objects.create_superuser(
|
||||||
|
username="dashboard-admin",
|
||||||
|
email="dashboard@example.com",
|
||||||
|
password="test-pass-123",
|
||||||
|
)
|
||||||
|
self.client.force_login(admin_user)
|
||||||
|
|
||||||
|
response = self.client.get("/admin/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Activity overview")
|
||||||
|
self.assertContains(response, "Published tests")
|
||||||
|
self.assertContains(response, "No activity yet")
|
||||||
|
self.assertContains(response, "/static/unfold/css/styles.css")
|
||||||
|
|
||||||
|
def test_navigation_marks_only_the_current_main_page_active(self):
|
||||||
|
home_response = self.client.get("/")
|
||||||
|
self.assertContains(home_response, 'class="nav-link active" href="/"')
|
||||||
|
|
||||||
|
user = User.objects.create_user(username="exam-nav", password="test-pass-123")
|
||||||
|
self.client.force_login(user)
|
||||||
|
exams_response = self.client.get("/exams/")
|
||||||
|
self.assertContains(exams_response, 'class="nav-link active" href="/exams/"')
|
||||||
|
self.assertNotContains(exams_response, 'class="nav-link active" href="/"')
|
||||||
|
|
||||||
|
def test_public_information_pages_resolve(self):
|
||||||
|
faq_response = self.client.get("/faq/")
|
||||||
|
privacy_response = self.client.get("/privacy/")
|
||||||
|
terms_response = self.client.get("/terms/")
|
||||||
|
|
||||||
|
self.assertEqual(faq_response.status_code, 200)
|
||||||
|
self.assertContains(faq_response, "Frequently Asked Questions")
|
||||||
|
self.assertEqual(privacy_response.status_code, 200)
|
||||||
|
self.assertContains(privacy_response, "Privacy Policy")
|
||||||
|
self.assertEqual(terms_response.status_code, 200)
|
||||||
|
self.assertContains(terms_response, "Terms of Use")
|
||||||
|
self.assertContains(terms_response, "not an official IELTS examination service")
|
||||||
|
|
||||||
|
def test_footer_uses_configured_links_without_fake_social_destinations(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertContains(response, "©", html=False)
|
||||||
|
self.assertContains(response, "support@ieltsmock.com")
|
||||||
|
self.assertNotContains(response, "https://www.facebook.com/")
|
||||||
|
self.assertNotContains(response, "https://www.instagram.com/")
|
||||||
|
|
||||||
|
def test_account_pages_use_ielts_mock_branding(self):
|
||||||
|
login_response = self.client.get("/accounts/login/")
|
||||||
|
signup_response = self.client.get("/accounts/signup/")
|
||||||
|
reset_response = self.client.get("/accounts/password/reset/")
|
||||||
|
|
||||||
|
self.assertContains(login_response, "Welcome back")
|
||||||
|
self.assertNotContains(login_response, "SpcHub")
|
||||||
|
self.assertContains(signup_response, "Create your account")
|
||||||
|
self.assertContains(reset_response, "Reset your password")
|
||||||
|
|
||||||
|
def test_account_recovery_confirmation_screen_is_branded(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/accounts/password/reset/",
|
||||||
|
{"email": "missing@example.com"},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Check your email")
|
||||||
|
self.assertContains(response, "Testpoint")
|
||||||
|
|
||||||
|
def test_health_endpoint_reports_database_readiness(self):
|
||||||
|
response = self.client.get("/health/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"status": "ok", "database": "ok"})
|
||||||
|
|
||||||
|
def test_missing_page_uses_branded_error_template(self):
|
||||||
|
response = self.client.get("/this-page-does-not-exist/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertContains(response, "Page not found", status_code=404)
|
||||||
|
|
||||||
|
def test_home_has_search_and_accessibility_metadata(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertContains(response, 'rel="canonical"')
|
||||||
|
self.assertContains(response, 'property="og:title"')
|
||||||
|
self.assertContains(response, 'href="/static/images/favicon.svg"')
|
||||||
|
self.assertContains(response, "Skip to main content")
|
||||||
|
self.assertContains(response, 'id="main-content"')
|
||||||
|
|
||||||
|
def test_robots_and_sitemap_resolve(self):
|
||||||
|
robots_response = self.client.get("/robots.txt")
|
||||||
|
sitemap_response = self.client.get("/sitemap.xml")
|
||||||
|
|
||||||
|
self.assertEqual(robots_response.status_code, 200)
|
||||||
|
self.assertContains(robots_response, "Disallow: /admin/")
|
||||||
|
self.assertContains(robots_response, "/sitemap.xml")
|
||||||
|
self.assertEqual(sitemap_response.status_code, 200)
|
||||||
|
self.assertEqual(sitemap_response["Content-Type"], "application/xml")
|
||||||
|
self.assertContains(sitemap_response, "/faq/")
|
||||||
|
self.assertContains(sitemap_response, "/terms/")
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||||
|
SUPPORT_EMAIL="support@example.com",
|
||||||
|
)
|
||||||
|
def test_contact_form_sends_support_email(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/contact/",
|
||||||
|
{
|
||||||
|
"name": "Test Student",
|
||||||
|
"email": "student@example.com",
|
||||||
|
"subject": "Account help",
|
||||||
|
"message": "Please help with my account.",
|
||||||
|
"website": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, "/contact/")
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].to, ["support@example.com"])
|
||||||
|
self.assertEqual(mail.outbox[0].reply_to, ["student@example.com"])
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.urls import path, include
|
||||||
|
from .views import contact, faq, health, home, privacy, robots, sitemap, terms
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
|
path('', home, name='home'),
|
||||||
|
path('faq/', faq, name='faq'),
|
||||||
|
path('privacy/', privacy, name='privacy'),
|
||||||
|
path('terms/', terms, name='terms'),
|
||||||
|
path('contact/', contact, name='contact'),
|
||||||
|
path('health/', health, name='health'),
|
||||||
|
path('robots.txt', robots, name='robots'),
|
||||||
|
path('sitemap.xml', sitemap, name='sitemap'),
|
||||||
|
|
||||||
|
path('accounts/', include('accounts.urls')),
|
||||||
|
path('exams/', include('exams.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
from django.db import connection
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from .forms import ContactForm
|
||||||
|
from accounts.leaderboard import build_leaderboard
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def home(request):
|
||||||
|
rows, current_row, _ = build_leaderboard(
|
||||||
|
period="week",
|
||||||
|
current_user=request.user if request.user.is_authenticated else None,
|
||||||
|
)
|
||||||
|
preview_user = current_row or {
|
||||||
|
"rank": 24,
|
||||||
|
"name": "Akhmed",
|
||||||
|
"band": 7.5,
|
||||||
|
"trend": "up",
|
||||||
|
"initial": "A",
|
||||||
|
"is_current": True,
|
||||||
|
"demo": True,
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"home.html",
|
||||||
|
{"leaderboard_preview": rows[:5], "leaderboard_preview_user": preview_user},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def faq(request):
|
||||||
|
return render(request, "faq.html")
|
||||||
|
|
||||||
|
|
||||||
|
def privacy(request):
|
||||||
|
return render(request, "privacy.html")
|
||||||
|
|
||||||
|
|
||||||
|
def terms(request):
|
||||||
|
return render(request, "terms.html")
|
||||||
|
|
||||||
|
|
||||||
|
def contact(request):
|
||||||
|
form = ContactForm(request.POST or None)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
body = (
|
||||||
|
f"Name: {form.cleaned_data['name']}\n"
|
||||||
|
f"Email: {form.cleaned_data['email']}\n\n"
|
||||||
|
f"{form.cleaned_data['message']}"
|
||||||
|
)
|
||||||
|
email = EmailMessage(
|
||||||
|
subject=f"Testpoint support: {form.cleaned_data['subject']}",
|
||||||
|
body=body,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[settings.SUPPORT_EMAIL],
|
||||||
|
reply_to=[form.cleaned_data["email"]],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Support contact email could not be sent")
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"We could not send your message. Please try again shortly.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
"Your message has been sent. We'll reply as soon as possible.",
|
||||||
|
)
|
||||||
|
return redirect("contact")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"contact.html",
|
||||||
|
{"form": form, "support_email": settings.SUPPORT_EMAIL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def health(request):
|
||||||
|
"""Lightweight process and database readiness check for the host."""
|
||||||
|
try:
|
||||||
|
connection.ensure_connection()
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse(
|
||||||
|
{"status": "unhealthy", "database": "unavailable"}, status=503
|
||||||
|
)
|
||||||
|
return JsonResponse({"status": "ok", "database": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
def robots(request):
|
||||||
|
sitemap_url = request.build_absolute_uri(reverse("sitemap"))
|
||||||
|
body = f"User-agent: *\nAllow: /\nDisallow: /admin/\nSitemap: {sitemap_url}\n"
|
||||||
|
return HttpResponse(body, content_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
def sitemap(request):
|
||||||
|
urls = [
|
||||||
|
request.build_absolute_uri(reverse("home")),
|
||||||
|
request.build_absolute_uri(reverse("faq")),
|
||||||
|
request.build_absolute_uri(reverse("privacy")),
|
||||||
|
request.build_absolute_uri(reverse("terms")),
|
||||||
|
request.build_absolute_uri(reverse("contact")),
|
||||||
|
]
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"sitemap.xml",
|
||||||
|
{"urls": urls},
|
||||||
|
content_type="application/xml",
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for core project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# deployment.yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: testpoint
|
||||||
|
namespace: testpoint
|
||||||
|
labels:
|
||||||
|
app: testpoint
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: testpoint
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: testpoint
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: testpoint
|
||||||
|
image: localhost:5000/testpoint:1.0.0
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: testpoint-config
|
||||||
|
- secretRef:
|
||||||
|
name: testpoint-secrets
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "250m"
|
||||||
|
memory: "256Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 20
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
exec gunicorn core.wsgi:application \
|
||||||
|
--bind "0.0.0.0:${PORT:-8000}" \
|
||||||
|
--workers "${GUNICORN_WORKERS:-3}" \
|
||||||
|
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||||
|
--access-logfile - \
|
||||||
|
--error-logfile -
|
||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core import signing
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.http import FileResponse, HttpResponseRedirect
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.urls import path, reverse
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from unfold.admin import ModelAdmin, StackedInline, TabularInline
|
||||||
|
|
||||||
|
from .forms import ExcelExamImportForm, HtmlExamImportForm, ListeningTestSetupForm, QuestionAdminForm, QuestionGroupAdminForm, SectionAdminForm
|
||||||
|
from .excel_importer import ExcelImportError, build_excel_template, parse_excel_test
|
||||||
|
from .html_importer import create_exam_from_payload
|
||||||
|
from .models import ExamSet, Question, QuestionGroup, Section, StudentAnswer, StudentAttempt
|
||||||
|
|
||||||
|
|
||||||
|
HTML_IMPORT_SALT = "exams.html-import.v1"
|
||||||
|
EXCEL_IMPORT_SALT = "exams.excel-import.v1"
|
||||||
|
|
||||||
|
# The custom IELTS Blue sidebar lives in admin/base_site.html. Django otherwise
|
||||||
|
# skips the navigation block entirely when its built-in sidebar flag is disabled.
|
||||||
|
|
||||||
|
|
||||||
|
_original_admin_index = admin.site.index
|
||||||
|
|
||||||
|
|
||||||
|
def ielts_admin_index(request, extra_context=None):
|
||||||
|
context = {
|
||||||
|
"dashboard_metrics": [
|
||||||
|
{"label": "Total tests", "value": ExamSet.objects.count(), "icon": "▤"},
|
||||||
|
{
|
||||||
|
"label": "Published tests",
|
||||||
|
"value": ExamSet.objects.filter(is_published=True).count(),
|
||||||
|
"icon": "✓",
|
||||||
|
},
|
||||||
|
{"label": "Test attempts", "value": StudentAttempt.objects.count(), "icon": "↗"},
|
||||||
|
{"label": "Students", "value": get_user_model().objects.count(), "icon": "◉"},
|
||||||
|
],
|
||||||
|
"recent_tests": ExamSet.objects.annotate(section_count=Count("sections"))
|
||||||
|
.order_by("-created_at")[:6],
|
||||||
|
}
|
||||||
|
if extra_context:
|
||||||
|
context.update(extra_context)
|
||||||
|
return _original_admin_index(request, extra_context=context)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SectionInline(TabularInline):
|
||||||
|
model = Section
|
||||||
|
extra = 0
|
||||||
|
fields = ("order", "section_type", "time_limit_minutes")
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionInline(StackedInline):
|
||||||
|
model = Question
|
||||||
|
form = QuestionAdminForm
|
||||||
|
extra = 0
|
||||||
|
fields = (
|
||||||
|
"order",
|
||||||
|
"group",
|
||||||
|
"question_type",
|
||||||
|
"prompt",
|
||||||
|
"options",
|
||||||
|
"correct_answer",
|
||||||
|
"explanation",
|
||||||
|
"passage_reference",
|
||||||
|
)
|
||||||
|
|
||||||
|
class QuestionGroupInline(StackedInline):
|
||||||
|
model = QuestionGroup
|
||||||
|
form = QuestionGroupAdminForm
|
||||||
|
extra = 0
|
||||||
|
fields = ("key", "order", "layout_type", "title", "instructions", "layout_html")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExamSet)
|
||||||
|
class ExamSetAdmin(ModelAdmin):
|
||||||
|
list_display = ("title", "category", "access_level", "delivery_mode", "is_published", "section_total", "created_at")
|
||||||
|
list_filter = ("category", "access_level", "is_published", "created_at")
|
||||||
|
search_fields = ("title", "description")
|
||||||
|
inlines = (SectionInline,)
|
||||||
|
actions = ("publish_ready_tests", "unpublish_tests")
|
||||||
|
|
||||||
|
def get_changeform_initial_data(self, request):
|
||||||
|
initial = super().get_changeform_initial_data(request)
|
||||||
|
category = request.GET.get("category")
|
||||||
|
valid_categories = {value for value, _label in ExamSet.CATEGORY_CHOICES}
|
||||||
|
if category in valid_categories:
|
||||||
|
initial["category"] = category
|
||||||
|
return initial
|
||||||
|
|
||||||
|
def get_fields(self, request, obj=None):
|
||||||
|
# Raw exact-mode documents are deliberately kept out of the ordinary
|
||||||
|
# edit form. They are replaced through the importer, not a huge textarea.
|
||||||
|
return ("title", "description", "category", "access_level", "delivery_mode", "is_published")
|
||||||
|
|
||||||
|
def get_inlines(self, request, obj):
|
||||||
|
if obj and obj.delivery_mode == "exact_html":
|
||||||
|
return ()
|
||||||
|
return super().get_inlines(request, obj)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
"""Expose the preserved workbook importer as an optional admin tool.
|
||||||
|
|
||||||
|
Normal test creation stays in the standard Unfold forms; this route is
|
||||||
|
deliberately separate so importing a prepared workbook never replaces
|
||||||
|
the ordinary editor.
|
||||||
|
"""
|
||||||
|
custom_urls = [
|
||||||
|
path(
|
||||||
|
"import-excel/",
|
||||||
|
self.admin_site.admin_view(self.import_excel_view),
|
||||||
|
name="exams_examset_import_excel",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"excel-template/",
|
||||||
|
self.admin_site.admin_view(self.excel_template_view),
|
||||||
|
name="exams_examset_excel_template",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + super().get_urls()
|
||||||
|
|
||||||
|
def create_listening_view(self, request):
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"title": "Create Listening test",
|
||||||
|
"form": ListeningTestSetupForm(),
|
||||||
|
}
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ListeningTestSetupForm(request.POST, request.FILES)
|
||||||
|
context["form"] = form
|
||||||
|
if form.is_valid():
|
||||||
|
exam = ExamSet.objects.create(
|
||||||
|
title=form.cleaned_data["title"],
|
||||||
|
description=form.cleaned_data["description"],
|
||||||
|
category="listening",
|
||||||
|
is_published=False,
|
||||||
|
)
|
||||||
|
section = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=1,
|
||||||
|
section_type="listening",
|
||||||
|
time_limit_minutes=form.cleaned_data["time_limit_minutes"],
|
||||||
|
audio_file=form.cleaned_data["audio_file"],
|
||||||
|
)
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"Listening test created as a draft. Now add its questions.",
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("admin:exams_section_change", args=(section.pk,))
|
||||||
|
)
|
||||||
|
return TemplateResponse(request, "admin/exams/examset/create_listening.html", context)
|
||||||
|
|
||||||
|
def excel_template_view(self, request):
|
||||||
|
return FileResponse(
|
||||||
|
build_excel_template(),
|
||||||
|
as_attachment=True,
|
||||||
|
filename="IELTS_Test_Import_Template.xlsx",
|
||||||
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
)
|
||||||
|
|
||||||
|
def import_excel_view(self, request):
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"title": "Import Excel tests",
|
||||||
|
"form": ExcelExamImportForm(),
|
||||||
|
"preview": None,
|
||||||
|
}
|
||||||
|
if request.method == "POST" and request.POST.get("confirm_import"):
|
||||||
|
try:
|
||||||
|
payload = signing.loads(
|
||||||
|
request.POST.get("import_payload", ""),
|
||||||
|
salt=EXCEL_IMPORT_SALT,
|
||||||
|
max_age=60 * 60,
|
||||||
|
)
|
||||||
|
exam = create_exam_from_payload(payload)
|
||||||
|
except signing.BadSignature:
|
||||||
|
self.message_user(request, "The preview expired. Upload the workbook again.", level=messages.ERROR)
|
||||||
|
return HttpResponseRedirect(reverse("admin:exams_examset_import_excel"))
|
||||||
|
except (KeyError, ValidationError, ValueError) as error:
|
||||||
|
self.message_user(request, f"Import failed: {error}", level=messages.ERROR)
|
||||||
|
return HttpResponseRedirect(reverse("admin:exams_examset_import_excel"))
|
||||||
|
state = "published" if exam.is_published else "saved as a draft"
|
||||||
|
self.message_user(request, f"{exam.title} imported successfully and {state}.", level=messages.SUCCESS)
|
||||||
|
return HttpResponseRedirect(reverse("admin:exams_examset_change", args=(exam.pk,)))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ExcelExamImportForm(request.POST, request.FILES)
|
||||||
|
context["form"] = form
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
payload = parse_excel_test(form.cleaned_data["excel_file"], publish=form.cleaned_data["publish"])
|
||||||
|
except ExcelImportError as error:
|
||||||
|
form.add_error("excel_file", error.messages[0])
|
||||||
|
else:
|
||||||
|
context["preview"] = payload
|
||||||
|
context["preview_question_count"] = sum(len(section["questions"]) for section in payload["sections"])
|
||||||
|
context["preview_group_count"] = sum(len(section.get("groups", [])) for section in payload["sections"])
|
||||||
|
context["import_payload"] = signing.dumps(payload, salt=EXCEL_IMPORT_SALT, compress=True)
|
||||||
|
return TemplateResponse(request, "admin/exams/examset/import_excel.html", context)
|
||||||
|
|
||||||
|
def import_html_view(self, request):
|
||||||
|
requested_type = request.GET.get("type", "reading")
|
||||||
|
valid_section_types = {value for value, _label in Section.SECTION_TYPES}
|
||||||
|
if requested_type not in valid_section_types:
|
||||||
|
requested_type = "reading"
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"title": "Import HTML tests",
|
||||||
|
"form": HtmlExamImportForm(
|
||||||
|
initial={
|
||||||
|
"section_type": requested_type,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"preview_exact": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST" and request.POST.get("confirm_import"):
|
||||||
|
try:
|
||||||
|
payload = signing.loads(
|
||||||
|
request.POST.get("import_payload", ""),
|
||||||
|
salt=HTML_IMPORT_SALT,
|
||||||
|
max_age=60 * 60,
|
||||||
|
)
|
||||||
|
exam = create_exam_from_payload(payload)
|
||||||
|
except signing.BadSignature:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"The import preview expired or was changed. Upload the HTML again.",
|
||||||
|
level=messages.ERROR,
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse("admin:exams_examset_import_html"))
|
||||||
|
except (KeyError, ValidationError, ValueError) as error:
|
||||||
|
self.message_user(request, f"Import failed: {error}", level=messages.ERROR)
|
||||||
|
return HttpResponseRedirect(reverse("admin:exams_examset_import_html"))
|
||||||
|
|
||||||
|
state = "published" if exam.is_published else "saved as a draft"
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{exam.title} imported successfully and {state}.",
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("admin:exams_examset_change", args=(exam.pk,))
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = HtmlExamImportForm(request.POST, request.FILES)
|
||||||
|
context["form"] = form
|
||||||
|
if form.is_valid():
|
||||||
|
upload = form.cleaned_data["html_files"][0]
|
||||||
|
raw = upload.read()
|
||||||
|
try:
|
||||||
|
source_html = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
source_html = raw.decode("cp1252")
|
||||||
|
if "<html" not in source_html.lower() and "<body" not in source_html.lower():
|
||||||
|
form.add_error("html_files", "The uploaded file must be a complete HTML document containing an HTML or BODY element.")
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"title": form.cleaned_data["title"],
|
||||||
|
"description": form.cleaned_data["description"],
|
||||||
|
"category": form.cleaned_data["section_type"],
|
||||||
|
"delivery_mode": "exact_html",
|
||||||
|
"source_html": source_html,
|
||||||
|
"publish": form.cleaned_data["publish"],
|
||||||
|
"sections": [],
|
||||||
|
}
|
||||||
|
context.update({
|
||||||
|
"preview_exact": True,
|
||||||
|
"preview_source_name": upload.name,
|
||||||
|
"preview_file_size": len(raw),
|
||||||
|
"import_payload": signing.dumps(payload, salt=HTML_IMPORT_SALT, compress=True),
|
||||||
|
"preview_title": payload["title"],
|
||||||
|
"preview_publish": payload["publish"],
|
||||||
|
"total_questions": "Original HTML",
|
||||||
|
})
|
||||||
|
|
||||||
|
return TemplateResponse(
|
||||||
|
request,
|
||||||
|
"admin/exams/examset/import_html.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Sections")
|
||||||
|
def section_total(self, obj):
|
||||||
|
return obj.sections.count()
|
||||||
|
|
||||||
|
@admin.action(description="Publish selected ready tests")
|
||||||
|
def publish_ready_tests(self, request, queryset):
|
||||||
|
published = 0
|
||||||
|
skipped = []
|
||||||
|
for exam_set in queryset.prefetch_related("sections__questions"):
|
||||||
|
if exam_set.is_ready:
|
||||||
|
exam_set.is_published = True
|
||||||
|
exam_set.save(update_fields=["is_published"])
|
||||||
|
published += 1
|
||||||
|
else:
|
||||||
|
skipped.append(exam_set.title)
|
||||||
|
if published:
|
||||||
|
self.message_user(request, f"Published {published} ready test(s).")
|
||||||
|
if skipped:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"Not published because sections or questions are missing: "
|
||||||
|
+ ", ".join(skipped),
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Unpublish selected tests")
|
||||||
|
def unpublish_tests(self, request, queryset):
|
||||||
|
updated = queryset.update(is_published=False)
|
||||||
|
self.message_user(request, f"Unpublished {updated} test(s).")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Section)
|
||||||
|
class SectionAdmin(ModelAdmin):
|
||||||
|
form = SectionAdminForm
|
||||||
|
list_display = (
|
||||||
|
"exam_set",
|
||||||
|
"order",
|
||||||
|
"section_type",
|
||||||
|
"time_limit_minutes",
|
||||||
|
"question_total",
|
||||||
|
)
|
||||||
|
list_filter = ("section_type", "exam_set")
|
||||||
|
search_fields = ("exam_set__title", "passage_text")
|
||||||
|
inlines = (QuestionGroupInline, QuestionInline)
|
||||||
|
@admin.display(description="Questions")
|
||||||
|
def question_total(self, obj):
|
||||||
|
return obj.questions.count()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Question)
|
||||||
|
class QuestionAdmin(ModelAdmin):
|
||||||
|
form = QuestionAdminForm
|
||||||
|
list_display = ("short_prompt", "section", "order", "question_type")
|
||||||
|
list_filter = ("question_type", "section__section_type", "section__exam_set")
|
||||||
|
search_fields = (
|
||||||
|
"prompt",
|
||||||
|
"correct_answer",
|
||||||
|
"explanation",
|
||||||
|
"passage_reference",
|
||||||
|
"section__exam_set__title",
|
||||||
|
)
|
||||||
|
fields = (
|
||||||
|
"section",
|
||||||
|
"group",
|
||||||
|
"order",
|
||||||
|
"question_type",
|
||||||
|
"prompt",
|
||||||
|
"options",
|
||||||
|
"correct_answer",
|
||||||
|
"explanation",
|
||||||
|
"passage_reference",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Question")
|
||||||
|
def short_prompt(self, obj):
|
||||||
|
return strip_tags(obj.prompt)[:70]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(QuestionGroup)
|
||||||
|
class QuestionGroupAdmin(ModelAdmin):
|
||||||
|
form = QuestionGroupAdminForm
|
||||||
|
list_display = ("title", "section", "key", "layout_type", "order")
|
||||||
|
list_filter = ("layout_type", "section__section_type", "section__exam_set")
|
||||||
|
search_fields = ("title", "key", "instructions", "section__exam_set__title")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StudentAttempt)
|
||||||
|
class StudentAttemptAdmin(ModelAdmin):
|
||||||
|
list_display = ("student", "exam_set", "is_complete", "started_at", "submitted_at")
|
||||||
|
list_filter = ("is_complete", "exam_set", "started_at")
|
||||||
|
search_fields = ("student__username", "student__email", "exam_set__title")
|
||||||
|
readonly_fields = ("started_at", "submitted_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StudentAnswer)
|
||||||
|
class StudentAnswerAdmin(ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"student_name",
|
||||||
|
"exam_name",
|
||||||
|
"question_kind",
|
||||||
|
"is_correct",
|
||||||
|
"manual_score",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"question__question_type",
|
||||||
|
"question__section__section_type",
|
||||||
|
"attempt__exam_set",
|
||||||
|
"is_correct",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"attempt__student__username",
|
||||||
|
"attempt__student__email",
|
||||||
|
"answer_text",
|
||||||
|
"question__prompt",
|
||||||
|
)
|
||||||
|
readonly_fields = ("attempt", "question", "answer_text", "audio_response", "is_correct")
|
||||||
|
fields = (
|
||||||
|
"attempt",
|
||||||
|
"question",
|
||||||
|
"answer_text",
|
||||||
|
"audio_response",
|
||||||
|
"is_correct",
|
||||||
|
"manual_score",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Student")
|
||||||
|
def student_name(self, obj):
|
||||||
|
return obj.attempt.student.username
|
||||||
|
|
||||||
|
@admin.display(description="Test")
|
||||||
|
def exam_name(self, obj):
|
||||||
|
return obj.attempt.exam_set.title
|
||||||
|
|
||||||
|
@admin.display(description="Type")
|
||||||
|
def question_kind(self, obj):
|
||||||
|
return obj.question.get_question_type_display()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ExamsConfig(AppConfig):
|
||||||
|
name = 'exams'
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
from openpyxl.worksheet.datavalidation import DataValidation
|
||||||
|
|
||||||
|
|
||||||
|
TEST_FIELDS = {"title", "description", "category"}
|
||||||
|
SECTION_HEADERS = ["section_order", "section_type", "time_limit_minutes", "passage_text"]
|
||||||
|
QUESTION_HEADERS = [
|
||||||
|
"section_order", "question_order", "question_type", "prompt", "options",
|
||||||
|
"correct_answer", "explanation", "passage_reference", "notes_for_admin",
|
||||||
|
]
|
||||||
|
QUESTION_HEADERS_GROUPED = QUESTION_HEADERS + ["group_key"]
|
||||||
|
GROUP_HEADERS = ["section_order", "group_key", "group_order", "layout_type", "title", "instructions", "layout_html"]
|
||||||
|
SKILLS = {"reading", "listening", "writing", "speaking", "full"}
|
||||||
|
SECTION_TYPES = {"reading", "listening", "writing", "speaking"}
|
||||||
|
QUESTION_TYPES = {"mcq", "gap", "matching", "essay", "speaking"}
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelImportError(ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value):
|
||||||
|
return "" if value is None else str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value, label, row, minimum=1, maximum=240):
|
||||||
|
try:
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
number = int(value)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
match = re.fullmatch(r"\s*(\d+)(?:\.0+)?(?:\s*(?:min|mins|minute|minutes))?\s*", value, re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
raise ValueError
|
||||||
|
number = int(match.group(1))
|
||||||
|
else:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ExcelImportError(f"{label}, row {row}: enter a whole number.")
|
||||||
|
if not minimum <= number <= maximum:
|
||||||
|
raise ExcelImportError(f"{label}, row {row}: use a number from {minimum} to {maximum}.")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(sheet, expected):
|
||||||
|
actual = [_text(cell.value).lower() for cell in sheet[1]][:len(expected)]
|
||||||
|
if actual != expected:
|
||||||
|
raise ExcelImportError(
|
||||||
|
f"Sheet '{sheet.title}' has changed column headers. Download a fresh template and keep its first row unchanged."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_excel_test(file_object, publish=False):
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(file_object, read_only=True, data_only=True)
|
||||||
|
except Exception as error:
|
||||||
|
raise ExcelImportError(f"The workbook could not be opened: {error}")
|
||||||
|
required = {"Test", "Sections", "Questions"}
|
||||||
|
missing = required.difference(workbook.sheetnames)
|
||||||
|
if missing:
|
||||||
|
raise ExcelImportError("Missing worksheet(s): " + ", ".join(sorted(missing)))
|
||||||
|
|
||||||
|
test_sheet = workbook["Test"]
|
||||||
|
metadata = {}
|
||||||
|
for row in test_sheet.iter_rows(min_row=2, max_col=2, values_only=True):
|
||||||
|
key = _text(row[0]).lower()
|
||||||
|
if key:
|
||||||
|
metadata[key] = _text(row[1])
|
||||||
|
if not metadata.get("title"):
|
||||||
|
raise ExcelImportError("Test sheet: title is required.")
|
||||||
|
category = metadata.get("category", "full").lower()
|
||||||
|
if category not in SKILLS:
|
||||||
|
raise ExcelImportError("Test sheet: category must be reading, listening, writing, speaking, or full.")
|
||||||
|
|
||||||
|
section_sheet = workbook["Sections"]
|
||||||
|
if section_sheet.max_row is not None and section_sheet.max_row > 51:
|
||||||
|
raise ExcelImportError("Sections sheet: maximum 50 section rows are allowed.")
|
||||||
|
_headers(section_sheet, SECTION_HEADERS)
|
||||||
|
sections = {}
|
||||||
|
for row_number, row in enumerate(section_sheet.iter_rows(min_row=2, max_col=4, values_only=True), start=2):
|
||||||
|
if not any(value not in (None, "") for value in row):
|
||||||
|
continue
|
||||||
|
order = _integer(row[0], "Sections sheet section_order", row_number, maximum=50)
|
||||||
|
section_type = _text(row[1]).lower()
|
||||||
|
if section_type not in SECTION_TYPES:
|
||||||
|
raise ExcelImportError(f"Sections sheet, row {row_number}: invalid section_type '{section_type}'.")
|
||||||
|
if order in sections:
|
||||||
|
raise ExcelImportError(f"Sections sheet: section_order {order} appears more than once.")
|
||||||
|
sections[order] = {
|
||||||
|
"order": order,
|
||||||
|
"section_type": section_type,
|
||||||
|
"time_limit_minutes": (
|
||||||
|
_integer(row[2], "Sections sheet time_limit_minutes", row_number)
|
||||||
|
if _text(row[2])
|
||||||
|
else {"reading": 60, "listening": 30, "writing": 60, "speaking": 15}[section_type]
|
||||||
|
),
|
||||||
|
"passage_text": _text(row[3]),
|
||||||
|
"questions": [],
|
||||||
|
}
|
||||||
|
if not sections:
|
||||||
|
raise ExcelImportError("Sections sheet: add at least one section.")
|
||||||
|
|
||||||
|
if "Groups" in workbook.sheetnames:
|
||||||
|
group_sheet = workbook["Groups"]
|
||||||
|
_headers(group_sheet, GROUP_HEADERS)
|
||||||
|
for row_number, row in enumerate(group_sheet.iter_rows(min_row=2, max_col=7, values_only=True), start=2):
|
||||||
|
if not any(value not in (None, "") for value in row):
|
||||||
|
continue
|
||||||
|
section_order = _integer(row[0], "Groups sheet section_order", row_number, maximum=50)
|
||||||
|
if section_order not in sections:
|
||||||
|
raise ExcelImportError(f"Groups sheet, row {row_number}: section_order {section_order} is not on the Sections sheet.")
|
||||||
|
key = _text(row[1]).lower()
|
||||||
|
if not re.fullmatch(r"[a-z0-9_-]+", key):
|
||||||
|
raise ExcelImportError(f"Groups sheet, row {row_number}: group_key must use letters, numbers, hyphens, or underscores.")
|
||||||
|
layout_type = _text(row[3]).lower() or "notes"
|
||||||
|
if layout_type not in {"notes", "table", "flow"}:
|
||||||
|
raise ExcelImportError(f"Groups sheet, row {row_number}: layout_type must be notes, table, or flow.")
|
||||||
|
if any(group["key"] == key for group in sections[section_order].setdefault("groups", [])):
|
||||||
|
raise ExcelImportError(f"Groups sheet, row {row_number}: group_key '{key}' is duplicated in section {section_order}.")
|
||||||
|
layout_html = _text(row[6])
|
||||||
|
if not layout_html:
|
||||||
|
raise ExcelImportError(f"Groups sheet, row {row_number}: layout_html is required.")
|
||||||
|
sections[section_order].setdefault("groups", []).append({
|
||||||
|
"key": key, "order": _integer(row[2], "Groups sheet group_order", row_number, maximum=200),
|
||||||
|
"layout_type": layout_type, "title": _text(row[4]), "instructions": _text(row[5]), "layout_html": layout_html,
|
||||||
|
})
|
||||||
|
|
||||||
|
question_sheet = workbook["Questions"]
|
||||||
|
if question_sheet.max_row is not None and question_sheet.max_row > 251:
|
||||||
|
raise ExcelImportError("Questions sheet: maximum 250 question rows are allowed.")
|
||||||
|
actual_headers = [_text(cell.value).lower() for cell in question_sheet[1]]
|
||||||
|
if actual_headers[:len(QUESTION_HEADERS_GROUPED)] == QUESTION_HEADERS_GROUPED:
|
||||||
|
question_columns = 10
|
||||||
|
elif actual_headers[:len(QUESTION_HEADERS)] == QUESTION_HEADERS:
|
||||||
|
question_columns = 9
|
||||||
|
else:
|
||||||
|
raise ExcelImportError("Sheet 'Questions' has changed column headers. Download a fresh template and keep its first row unchanged.")
|
||||||
|
seen = set()
|
||||||
|
for row_number, row in enumerate(question_sheet.iter_rows(min_row=2, max_col=question_columns, values_only=True), start=2):
|
||||||
|
if not any(value not in (None, "") for value in row):
|
||||||
|
continue
|
||||||
|
section_order = _integer(row[0], "Questions sheet section_order", row_number, maximum=50)
|
||||||
|
if section_order not in sections:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: section_order {section_order} is not on the Sections sheet.")
|
||||||
|
question_order = _integer(row[1], "Questions sheet question_order", row_number, maximum=200)
|
||||||
|
key = (section_order, question_order)
|
||||||
|
if key in seen:
|
||||||
|
raise ExcelImportError(f"Questions sheet: question_order {question_order} is duplicated in section {section_order}.")
|
||||||
|
seen.add(key)
|
||||||
|
question_type = _text(row[2]).lower()
|
||||||
|
if question_type not in QUESTION_TYPES:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: invalid question_type '{question_type}'.")
|
||||||
|
prompt = _text(row[3])
|
||||||
|
if not prompt:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: prompt is required.")
|
||||||
|
options = [item.strip() for item in _text(row[4]).split("|") if item.strip()]
|
||||||
|
answer = _text(row[5])
|
||||||
|
if question_type in {"mcq", "matching"}:
|
||||||
|
if len(options) < 2:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: {question_type} requires at least two | separated options.")
|
||||||
|
if answer not in options:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: correct_answer must exactly match one option.")
|
||||||
|
if question_type == "gap" and not answer:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: gap questions require a correct_answer.")
|
||||||
|
group_key = _text(row[9]).lower() if question_columns == 10 else ""
|
||||||
|
if group_key:
|
||||||
|
groups = sections[section_order].get("groups", [])
|
||||||
|
group = next((item for item in groups if item["key"] == group_key), None)
|
||||||
|
if group is None:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: group_key '{group_key}' is not defined on the Groups sheet.")
|
||||||
|
if question_type not in {"gap", "matching"}:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: grouped questions must use gap or matching.")
|
||||||
|
if f"[[{question_order}]]" not in group["layout_html"]:
|
||||||
|
raise ExcelImportError(f"Questions sheet, row {row_number}: group '{group_key}' layout_html does not contain [[{question_order}]].")
|
||||||
|
sections[section_order]["questions"].append({
|
||||||
|
"order": question_order,
|
||||||
|
"question_type": question_type,
|
||||||
|
"prompt": prompt,
|
||||||
|
"options": options or None,
|
||||||
|
"correct_answer": answer or None,
|
||||||
|
"explanation": _text(row[6]),
|
||||||
|
"passage_reference": _text(row[7]),
|
||||||
|
"group_key": group_key,
|
||||||
|
})
|
||||||
|
empty_sections = [str(order) for order, section in sections.items() if not section["questions"]]
|
||||||
|
if empty_sections:
|
||||||
|
raise ExcelImportError("Every section needs at least one question. Empty section_order: " + ", ".join(empty_sections))
|
||||||
|
for section in sections.values():
|
||||||
|
for group in section.get("groups", []):
|
||||||
|
grouped_orders = {question["order"] for question in section["questions"] if question.get("group_key") == group["key"]}
|
||||||
|
placeholder_orders = [int(value) for value in re.findall(r"\[\[(\d+)\]\]", group["layout_html"])]
|
||||||
|
if len(placeholder_orders) != len(set(placeholder_orders)):
|
||||||
|
raise ExcelImportError(f"Groups sheet: group '{group['key']}' contains a duplicate question placeholder.")
|
||||||
|
if set(placeholder_orders) != grouped_orders:
|
||||||
|
raise ExcelImportError(f"Groups sheet: group '{group['key']}' placeholders must exactly match its Questions rows.")
|
||||||
|
section["questions"].sort(key=lambda item: item["order"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": metadata["title"],
|
||||||
|
"description": metadata.get("description", ""),
|
||||||
|
"category": category,
|
||||||
|
"delivery_mode": "native",
|
||||||
|
"publish": bool(publish),
|
||||||
|
"sections": [sections[key] for key in sorted(sections)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_excel_template():
|
||||||
|
workbook = Workbook()
|
||||||
|
instructions = workbook.active
|
||||||
|
instructions.title = "Instructions"
|
||||||
|
test = workbook.create_sheet("Test")
|
||||||
|
sections = workbook.create_sheet("Sections")
|
||||||
|
questions = workbook.create_sheet("Questions")
|
||||||
|
groups = workbook.create_sheet("Groups")
|
||||||
|
navy, blue, pale = "10213E", "1463E9", "EAF2FF"
|
||||||
|
|
||||||
|
instructions.append(["Testpoint — Excel Import Template"])
|
||||||
|
instructions.append(["Keep sheet names and column headers unchanged."])
|
||||||
|
instructions.append(["1", "Complete Test, Sections, and Questions. Use Groups only for inline notes, tables, forms, or flow charts."])
|
||||||
|
instructions.append(["2", "Use | between MCQ or matching options."])
|
||||||
|
instructions.append(["3", "Upload in Admin → Import Excel tests, review, then save."])
|
||||||
|
instructions.column_dimensions["A"].width = 16
|
||||||
|
instructions.column_dimensions["B"].width = 78
|
||||||
|
|
||||||
|
test.append(["field", "value"])
|
||||||
|
test.append(["title", "Academic Reading Practice 1"])
|
||||||
|
test.append(["description", "A complete IELTS practice test imported from Excel."])
|
||||||
|
test.append(["category", "reading"])
|
||||||
|
test.column_dimensions["A"].width = 22
|
||||||
|
test.column_dimensions["B"].width = 68
|
||||||
|
|
||||||
|
sections.append(SECTION_HEADERS)
|
||||||
|
sections.append([1, "reading", 20, "Paste the complete reading passage here."])
|
||||||
|
sections.column_dimensions["A"].width = 16
|
||||||
|
sections.column_dimensions["B"].width = 20
|
||||||
|
sections.column_dimensions["C"].width = 22
|
||||||
|
sections.column_dimensions["D"].width = 75
|
||||||
|
|
||||||
|
groups.append(GROUP_HEADERS)
|
||||||
|
groups.append([1, "notes_1", 1, "notes", "Urban farming in Paris", "Complete the notes below. Choose NO MORE THAN TWO WORDS AND/OR A NUMBER.", "<h3>Urban farming in Paris</h3><h4>Farm layout and production</h4><ul><li>Vertical tubes grow strawberries, [[1]] and herbs.</li><li>The daily harvest may reach [[2]] in weight.</li></ul>"])
|
||||||
|
for index, width in enumerate([15, 18, 15, 18, 32, 65, 95], start=1):
|
||||||
|
groups.column_dimensions[chr(64 + index)].width = width
|
||||||
|
questions.append(QUESTION_HEADERS_GROUPED)
|
||||||
|
questions.append([1, 1, "gap", "Vertical tubes grow strawberries, _____ and herbs.", "", "lettuces", "Explain why Option B is correct.", "From identical vertical tubes nearby burst row upon row of lettuces.", "", "notes_1"])
|
||||||
|
questions.append([1, 2, "gap", "The daily harvest may reach _____ in weight.", "", "1,000 kg", "", "Staff will harvest up to 1,000 kg every day.", "", "notes_1"])
|
||||||
|
widths = [15, 15, 18, 48, 44, 28, 42, 46, 28, 20]
|
||||||
|
for index, width in enumerate(widths, start=1):
|
||||||
|
questions.column_dimensions[chr(64 + index)].width = width
|
||||||
|
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
sheet.freeze_panes = "A2"
|
||||||
|
sheet.sheet_view.showGridLines = False
|
||||||
|
for cell in sheet[1]:
|
||||||
|
cell.fill = PatternFill("solid", fgColor=navy)
|
||||||
|
cell.font = Font(color="FFFFFF", bold=True)
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||||
|
for row in sheet.iter_rows():
|
||||||
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
||||||
|
category_validation = DataValidation(type="list", formula1='"reading,listening,writing,speaking,full"')
|
||||||
|
test.add_data_validation(category_validation)
|
||||||
|
category_validation.add(test["B4"])
|
||||||
|
section_validation = DataValidation(type="list", formula1='"reading,listening,writing,speaking"')
|
||||||
|
sections.add_data_validation(section_validation)
|
||||||
|
section_validation.add("B2:B50")
|
||||||
|
question_validation = DataValidation(type="list", formula1='"mcq,gap,matching,essay,speaking"')
|
||||||
|
questions.add_data_validation(question_validation)
|
||||||
|
question_validation.add("C2:C250")
|
||||||
|
output = BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
return output
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
import bleach
|
||||||
|
|
||||||
|
from .models import ExamSet, Question, QuestionGroup, Section
|
||||||
|
|
||||||
|
|
||||||
|
RICH_TEXT_TAGS = ["p", "br", "strong", "em", "u", "h2", "h3", "ul", "ol", "li", "blockquote", "a"]
|
||||||
|
RICH_TEXT_ATTRIBUTES = {"a": ["href", "title", "target", "rel"]}
|
||||||
|
|
||||||
|
|
||||||
|
class SectionAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Section
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"passage_text": forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"data-rich-text-editor": "passage",
|
||||||
|
"rows": 18,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_passage_text(self):
|
||||||
|
value = self.cleaned_data.get("passage_text") or ""
|
||||||
|
return bleach.clean(
|
||||||
|
value,
|
||||||
|
tags=RICH_TEXT_TAGS,
|
||||||
|
attributes=RICH_TEXT_ATTRIBUTES,
|
||||||
|
protocols=["http", "https", "mailto"],
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Question
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"prompt": forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"data-rich-text-editor": "prompt",
|
||||||
|
"rows": 7,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_prompt(self):
|
||||||
|
value = self.cleaned_data.get("prompt") or ""
|
||||||
|
return bleach.clean(
|
||||||
|
value,
|
||||||
|
tags=RICH_TEXT_TAGS,
|
||||||
|
attributes=RICH_TEXT_ATTRIBUTES,
|
||||||
|
protocols=["http", "https", "mailto"],
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionGroupAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = QuestionGroup
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {"instructions": forms.Textarea(attrs={"rows": 4}), "layout_html": forms.Textarea(attrs={"rows": 14})}
|
||||||
|
|
||||||
|
def clean_layout_html(self):
|
||||||
|
value = self.cleaned_data.get("layout_html") or ""
|
||||||
|
allowed = RICH_TEXT_TAGS + ["div", "section", "table", "thead", "tbody", "tr", "th", "td", "caption", "span"]
|
||||||
|
return bleach.clean(value, tags=allowed, attributes={}, strip=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleFileInput(forms.ClearableFileInput):
|
||||||
|
allow_multiple_selected = True
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleFileField(forms.FileField):
|
||||||
|
def clean(self, data, initial=None):
|
||||||
|
single_file_clean = super().clean
|
||||||
|
if isinstance(data, (list, tuple)):
|
||||||
|
return [single_file_clean(item, initial) for item in data]
|
||||||
|
return [single_file_clean(data, initial)]
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlExamImportForm(forms.Form):
|
||||||
|
title = forms.CharField(
|
||||||
|
label="Test title",
|
||||||
|
max_length=200,
|
||||||
|
help_text="Use a clear name such as Academic Reading Mock Test 2.",
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 3}),
|
||||||
|
)
|
||||||
|
section_type = forms.ChoiceField(
|
||||||
|
label="Skill",
|
||||||
|
choices=Section.SECTION_TYPES,
|
||||||
|
initial="reading",
|
||||||
|
help_text="The test category and every uploaded section will use this skill.",
|
||||||
|
)
|
||||||
|
html_files = MultipleFileField(
|
||||||
|
widget=MultipleFileInput(attrs={"accept": ".html,.htm,text/html"}),
|
||||||
|
help_text="Choose one complete HTML or HTM test file.",
|
||||||
|
)
|
||||||
|
publish = forms.BooleanField(
|
||||||
|
label="Publish immediately",
|
||||||
|
required=False,
|
||||||
|
help_text="Draft is recommended until questions and answers have been reviewed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_html_files(self):
|
||||||
|
files = self.cleaned_data["html_files"]
|
||||||
|
if len(files) != 1:
|
||||||
|
raise ValidationError("Upload exactly one complete HTML file.")
|
||||||
|
for uploaded in files:
|
||||||
|
if not uploaded.name.lower().endswith((".html", ".htm")):
|
||||||
|
raise ValidationError(f"{uploaded.name} is not an HTML file.")
|
||||||
|
if uploaded.size > 2 * 1024 * 1024:
|
||||||
|
raise ValidationError(f"{uploaded.name} is larger than 2 MB.")
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelExamImportForm(forms.Form):
|
||||||
|
excel_file = forms.FileField(
|
||||||
|
label="Excel workbook",
|
||||||
|
widget=forms.ClearableFileInput(attrs={"accept": ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}),
|
||||||
|
help_text="Upload a completed IELTS_Test_Import_Template.xlsx file (maximum 5 MB).",
|
||||||
|
)
|
||||||
|
publish = forms.BooleanField(
|
||||||
|
label="Publish immediately",
|
||||||
|
required=False,
|
||||||
|
help_text="Draft is recommended until you open and review the imported test.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_excel_file(self):
|
||||||
|
uploaded = self.cleaned_data["excel_file"]
|
||||||
|
if not uploaded.name.lower().endswith(".xlsx"):
|
||||||
|
raise ValidationError("Upload an .xlsx workbook.")
|
||||||
|
if uploaded.size > 5 * 1024 * 1024:
|
||||||
|
raise ValidationError("The workbook is larger than 5 MB.")
|
||||||
|
return uploaded
|
||||||
|
|
||||||
|
|
||||||
|
class ListeningTestSetupForm(forms.Form):
|
||||||
|
title = forms.CharField(
|
||||||
|
label="Listening test title",
|
||||||
|
max_length=200,
|
||||||
|
help_text="For example: Listening Practice Test 1.",
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 3}),
|
||||||
|
help_text="A short note students will see before starting the test.",
|
||||||
|
)
|
||||||
|
audio_file = forms.FileField(
|
||||||
|
label="Listening audio",
|
||||||
|
widget=forms.ClearableFileInput(attrs={"accept": "audio/mpeg,audio/mp4,audio/wav,audio/ogg,.mp3,.m4a,.wav,.ogg"}),
|
||||||
|
help_text="MP3 is recommended. Maximum file size: 30 MB.",
|
||||||
|
)
|
||||||
|
time_limit_minutes = forms.IntegerField(
|
||||||
|
label="Time limit (minutes)",
|
||||||
|
min_value=1,
|
||||||
|
initial=30,
|
||||||
|
help_text="Use 30 minutes for a standard IELTS Listening practice test.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_audio_file(self):
|
||||||
|
uploaded = self.cleaned_data["audio_file"]
|
||||||
|
if not uploaded.name.lower().endswith((".mp3", ".m4a", ".wav", ".ogg")):
|
||||||
|
raise ValidationError("Upload an MP3, M4A, WAV, or OGG audio file.")
|
||||||
|
if uploaded.size > 30 * 1024 * 1024:
|
||||||
|
raise ValidationError("The audio file must be 30 MB or smaller.")
|
||||||
|
return uploaded
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
# IELTS Academic Reading conversion commonly used for 40-question papers.
|
||||||
|
# Scores between published boundaries are represented in 0.5-band steps.
|
||||||
|
ACADEMIC_READING_BANDS = (
|
||||||
|
(39, 9.0), (37, 8.5), (35, 8.0), (33, 7.5), (30, 7.0),
|
||||||
|
(27, 6.5), (23, 6.0), (19, 5.5), (15, 5.0), (13, 4.5),
|
||||||
|
(10, 4.0), (8, 3.5), (6, 3.0), (4, 2.5), (3, 2.0),
|
||||||
|
(2, 1.5), (1, 1.0), (0, 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def academic_reading_band(correct, total=40):
|
||||||
|
"""Return (band, equivalent_40_score, is_estimate) for an objective Reading score."""
|
||||||
|
if total <= 0:
|
||||||
|
return None, None, False
|
||||||
|
correct = max(0, min(int(correct), int(total)))
|
||||||
|
if total == 40:
|
||||||
|
equivalent = correct
|
||||||
|
estimated = False
|
||||||
|
else:
|
||||||
|
# Round half upward so Python's banker rounding does not disadvantage
|
||||||
|
# passage-sized practice scores at exact .5 boundaries.
|
||||||
|
equivalent = min(40, math.floor((correct * 40 / total) + 0.5))
|
||||||
|
estimated = True
|
||||||
|
band = next(band for minimum, band in ACADEMIC_READING_BANDS if equivalent >= minimum)
|
||||||
|
return band, equivalent, estimated
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from html import unescape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import ExamSet, Question, QuestionGroup, Section
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlImportError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _classes(attrs):
|
||||||
|
return set(dict(attrs).get("class", "").split())
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value):
|
||||||
|
value = unescape(value).replace("\xa0", " ")
|
||||||
|
value = re.sub(r"[ \t\r\f\v]+", " ", value)
|
||||||
|
value = re.sub(r" *\n *", "\n", value)
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_inline_text(value):
|
||||||
|
return re.sub(r"\s+", " ", unescape(value).replace("\xa0", " ")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class IeltsHtmlParser(HTMLParser):
|
||||||
|
"""Extracts structured content without executing or retaining uploaded markup."""
|
||||||
|
|
||||||
|
block_tags = {"p", "div", "h1", "h2", "h3", "h4", "li", "br"}
|
||||||
|
void_tags = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.passage_depth = 0
|
||||||
|
self.passage_parts = []
|
||||||
|
self.current_question = None
|
||||||
|
self.question_depth = 0
|
||||||
|
self.question_text_depth = 0
|
||||||
|
self.current_label = None
|
||||||
|
self.label_depth = 0
|
||||||
|
self.current_option = None
|
||||||
|
self.option_depth = 0
|
||||||
|
self.group_depth = 0
|
||||||
|
self.group_parts = []
|
||||||
|
self.group_context_frozen = False
|
||||||
|
self.questions = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs_dict = dict(attrs)
|
||||||
|
classes = _classes(attrs)
|
||||||
|
|
||||||
|
if "question-group" in classes and not self.group_depth:
|
||||||
|
self.group_depth = 1
|
||||||
|
self.group_parts = []
|
||||||
|
self.group_context_frozen = False
|
||||||
|
elif self.group_depth:
|
||||||
|
if not self.group_context_frozen and tag in self.block_tags:
|
||||||
|
self.group_parts.append("\n")
|
||||||
|
if tag not in self.void_tags:
|
||||||
|
self.group_depth += 1
|
||||||
|
|
||||||
|
if "passage-content" in classes and not self.passage_depth:
|
||||||
|
self.passage_depth = 1
|
||||||
|
elif self.passage_depth:
|
||||||
|
if tag in self.block_tags:
|
||||||
|
self.passage_parts.append("\n")
|
||||||
|
if tag not in self.void_tags:
|
||||||
|
self.passage_depth += 1
|
||||||
|
|
||||||
|
if self.current_question is None and "question" in classes and attrs_dict.get("data-question"):
|
||||||
|
try:
|
||||||
|
order = int(attrs_dict["data-question"])
|
||||||
|
except ValueError:
|
||||||
|
order = len(self.questions) + 1
|
||||||
|
self.current_question = {
|
||||||
|
"order": order,
|
||||||
|
"prompt_parts": [],
|
||||||
|
"input_types": set(),
|
||||||
|
"select_options": [],
|
||||||
|
"radio_options": [],
|
||||||
|
"group_context": _clean_text("".join(self.group_parts)),
|
||||||
|
}
|
||||||
|
self.group_context_frozen = True
|
||||||
|
self.question_depth = 1
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.current_question is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag not in self.void_tags:
|
||||||
|
self.question_depth += 1
|
||||||
|
if "question-text" in classes:
|
||||||
|
self.question_text_depth = 1
|
||||||
|
elif self.question_text_depth and tag not in self.void_tags:
|
||||||
|
self.question_text_depth += 1
|
||||||
|
|
||||||
|
if tag == "input":
|
||||||
|
input_type = attrs_dict.get("type", "text").lower()
|
||||||
|
self.current_question["input_types"].add(input_type)
|
||||||
|
if input_type == "text" and self.question_text_depth:
|
||||||
|
self.current_question["prompt_parts"].append(" ____ ")
|
||||||
|
if input_type == "radio" and self.current_label is not None:
|
||||||
|
self.current_label["value"] = attrs_dict.get("value", "").strip()
|
||||||
|
elif tag == "label":
|
||||||
|
self.current_label = {"value": "", "parts": []}
|
||||||
|
self.label_depth = 1
|
||||||
|
elif self.current_label is not None and tag not in self.void_tags:
|
||||||
|
self.label_depth += 1
|
||||||
|
|
||||||
|
if tag == "option":
|
||||||
|
self.current_option = {
|
||||||
|
"value": attrs_dict.get("value", "").strip(),
|
||||||
|
"parts": [],
|
||||||
|
"disabled": "disabled" in attrs_dict,
|
||||||
|
}
|
||||||
|
self.option_depth = 1
|
||||||
|
elif self.current_option is not None and tag not in self.void_tags:
|
||||||
|
self.option_depth += 1
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag, attrs):
|
||||||
|
self.handle_starttag(tag, attrs)
|
||||||
|
if tag not in self.void_tags:
|
||||||
|
self.handle_endtag(tag)
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.passage_depth:
|
||||||
|
self.passage_parts.append(data)
|
||||||
|
if self.group_depth and not self.group_context_frozen:
|
||||||
|
self.group_parts.append(data)
|
||||||
|
if self.current_question is None:
|
||||||
|
return
|
||||||
|
if self.question_text_depth:
|
||||||
|
self.current_question["prompt_parts"].append(data)
|
||||||
|
if self.current_label is not None:
|
||||||
|
self.current_label["parts"].append(data)
|
||||||
|
if self.current_option is not None:
|
||||||
|
self.current_option["parts"].append(data)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self.passage_depth:
|
||||||
|
if tag in self.block_tags:
|
||||||
|
self.passage_parts.append("\n")
|
||||||
|
self.passage_depth -= 1
|
||||||
|
|
||||||
|
if self.group_depth:
|
||||||
|
if not self.group_context_frozen and tag in self.block_tags:
|
||||||
|
self.group_parts.append("\n")
|
||||||
|
self.group_depth -= 1
|
||||||
|
if self.group_depth == 0:
|
||||||
|
self.group_parts = []
|
||||||
|
self.group_context_frozen = False
|
||||||
|
|
||||||
|
if self.current_question is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.current_option is not None:
|
||||||
|
self.option_depth -= 1
|
||||||
|
if self.option_depth == 0:
|
||||||
|
text = _clean_text("".join(self.current_option["parts"]))
|
||||||
|
value = self.current_option["value"] or text
|
||||||
|
if value and not self.current_option["disabled"]:
|
||||||
|
self.current_question["select_options"].append(value)
|
||||||
|
self.current_option = None
|
||||||
|
|
||||||
|
if self.current_label is not None:
|
||||||
|
self.label_depth -= 1
|
||||||
|
if self.label_depth == 0:
|
||||||
|
value = self.current_label["value"]
|
||||||
|
text = _clean_inline_text("".join(self.current_label["parts"]))
|
||||||
|
if value:
|
||||||
|
self.current_question["radio_options"].append((value, text or value))
|
||||||
|
self.current_label = None
|
||||||
|
|
||||||
|
if self.question_text_depth:
|
||||||
|
self.question_text_depth -= 1
|
||||||
|
|
||||||
|
self.question_depth -= 1
|
||||||
|
if self.question_depth == 0:
|
||||||
|
self.questions.append(self.current_question)
|
||||||
|
self.current_question = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportedQuestion:
|
||||||
|
order: int
|
||||||
|
question_type: str
|
||||||
|
prompt: str
|
||||||
|
options: list | None
|
||||||
|
correct_answer: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportedSection:
|
||||||
|
source_name: str
|
||||||
|
passage_text: str
|
||||||
|
questions: list
|
||||||
|
warnings: list
|
||||||
|
|
||||||
|
def as_payload(self):
|
||||||
|
return {
|
||||||
|
"source_name": self.source_name,
|
||||||
|
"passage_text": self.passage_text,
|
||||||
|
"questions": [asdict(question) for question in self.questions],
|
||||||
|
"warnings": self.warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_correct_answers(html):
|
||||||
|
match = re.search(
|
||||||
|
r"(?:const|let|var)\s+correctAnswers\s*=\s*\{(?P<body>.*?)\}\s*;?",
|
||||||
|
html,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return {}
|
||||||
|
pairs = re.findall(
|
||||||
|
r"[\"']?q?(\d+)[\"']?\s*:\s*[\"']([^\"']*)[\"']",
|
||||||
|
match.group("body"),
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
return {int(number): unescape(answer).strip() for number, answer in pairs}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ielts_html(content, source_name="uploaded.html", section_type="reading"):
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
try:
|
||||||
|
html = content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
html = content.decode("cp1252")
|
||||||
|
else:
|
||||||
|
html = content
|
||||||
|
|
||||||
|
parser = IeltsHtmlParser()
|
||||||
|
parser.feed(html)
|
||||||
|
answers = _extract_correct_answers(html)
|
||||||
|
passage = _clean_text("".join(parser.passage_parts))
|
||||||
|
if section_type == "reading" and not passage:
|
||||||
|
raise HtmlImportError(f"{source_name}: no element with class 'passage-content' was found.")
|
||||||
|
if not parser.questions:
|
||||||
|
raise HtmlImportError(f"{source_name}: no numbered question blocks were found.")
|
||||||
|
|
||||||
|
imported = []
|
||||||
|
warnings = []
|
||||||
|
seen_orders = set()
|
||||||
|
last_matching_context = None
|
||||||
|
for raw in parser.questions:
|
||||||
|
order = raw["order"]
|
||||||
|
if order in seen_orders:
|
||||||
|
raise HtmlImportError(f"{source_name}: question number {order} appears more than once.")
|
||||||
|
seen_orders.add(order)
|
||||||
|
prompt = _clean_text("".join(raw["prompt_parts"]))
|
||||||
|
prompt = re.sub(rf"^\s*{order}\s*[.)]?\s*", "", prompt).strip()
|
||||||
|
if not prompt:
|
||||||
|
raise HtmlImportError(f"{source_name}: question {order} has no readable prompt.")
|
||||||
|
|
||||||
|
answer = answers.get(order, "")
|
||||||
|
input_types = raw["input_types"]
|
||||||
|
if section_type == "writing":
|
||||||
|
question_type = "essay"
|
||||||
|
options = None
|
||||||
|
answer = ""
|
||||||
|
elif section_type == "speaking":
|
||||||
|
question_type = "speaking"
|
||||||
|
options = None
|
||||||
|
answer = ""
|
||||||
|
elif raw["select_options"]:
|
||||||
|
question_type = "matching"
|
||||||
|
options = raw["select_options"]
|
||||||
|
context = raw.get("group_context", "")
|
||||||
|
if context and context != last_matching_context:
|
||||||
|
prompt = f"{context}\n\n{prompt}"
|
||||||
|
last_matching_context = context
|
||||||
|
elif "radio" in input_types:
|
||||||
|
question_type = "mcq"
|
||||||
|
options = [text for _value, text in raw["radio_options"]]
|
||||||
|
answer_map = {value.casefold(): text for value, text in raw["radio_options"]}
|
||||||
|
answer = answer_map.get(answer.casefold(), answer)
|
||||||
|
elif "text" in input_types:
|
||||||
|
question_type = "gap"
|
||||||
|
options = None
|
||||||
|
else:
|
||||||
|
warnings.append(f"Question {order} had no recognized input and was treated as a gap fill.")
|
||||||
|
question_type = "gap"
|
||||||
|
options = None
|
||||||
|
|
||||||
|
if question_type in {"mcq", "gap", "matching"} and not answer:
|
||||||
|
raise HtmlImportError(
|
||||||
|
f"{source_name}: correct answer for question {order} was not found in correctAnswers."
|
||||||
|
)
|
||||||
|
if question_type == "mcq" and answer not in options:
|
||||||
|
raise HtmlImportError(
|
||||||
|
f"{source_name}: answer for question {order} does not match an available option."
|
||||||
|
)
|
||||||
|
imported.append(ImportedQuestion(order, question_type, prompt, options, answer))
|
||||||
|
|
||||||
|
return ImportedSection(source_name, passage, imported, warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_exam_from_payload(payload):
|
||||||
|
exam = ExamSet(
|
||||||
|
title=payload["title"],
|
||||||
|
description=payload.get("description", ""),
|
||||||
|
category=payload["category"],
|
||||||
|
is_published=False,
|
||||||
|
delivery_mode=payload.get("delivery_mode", "native"),
|
||||||
|
source_html=payload.get("source_html", ""),
|
||||||
|
)
|
||||||
|
exam.full_clean()
|
||||||
|
exam.save()
|
||||||
|
for section_order, section_data in enumerate(payload.get("sections", []), start=1):
|
||||||
|
section = Section(
|
||||||
|
exam_set=exam,
|
||||||
|
order=section_data.get("order", section_order),
|
||||||
|
section_type=section_data.get("section_type", payload.get("section_type", "reading")),
|
||||||
|
time_limit_minutes=section_data.get("time_limit_minutes", payload.get("time_limit_minutes", 60)),
|
||||||
|
passage_text=section_data["passage_text"],
|
||||||
|
)
|
||||||
|
section.full_clean()
|
||||||
|
section.save()
|
||||||
|
groups_by_key = {}
|
||||||
|
for group_data in section_data.get("groups", []):
|
||||||
|
group = QuestionGroup(section=section, **group_data)
|
||||||
|
group.full_clean()
|
||||||
|
group.save()
|
||||||
|
groups_by_key[group.key] = group
|
||||||
|
for question_data in section_data["questions"]:
|
||||||
|
question_data = question_data.copy()
|
||||||
|
group_key = question_data.pop("group_key", "")
|
||||||
|
if group_key:
|
||||||
|
question_data["group"] = groups_by_key[group_key]
|
||||||
|
question = Question(section=section, **question_data)
|
||||||
|
question.full_clean()
|
||||||
|
question.save()
|
||||||
|
if payload.get("publish") and exam.is_ready:
|
||||||
|
exam.is_published = True
|
||||||
|
exam.save(update_fields=["is_published"])
|
||||||
|
return exam
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from exams.models import ExamSet, Question, Section
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create an original, reusable IELTS skills diagnostic test."
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
exam, created = ExamSet.objects.get_or_create(
|
||||||
|
title="IELTS Skills Diagnostic",
|
||||||
|
defaults={
|
||||||
|
"description": (
|
||||||
|
"An original three-skill diagnostic covering reading, writing, and "
|
||||||
|
"speaking. This is practice material, not an official IELTS test."
|
||||||
|
),
|
||||||
|
"is_published": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created and exam.sections.exists():
|
||||||
|
self.stdout.write(self.style.WARNING("Diagnostic test already exists; no changes made."))
|
||||||
|
return
|
||||||
|
|
||||||
|
exam.description = (
|
||||||
|
"An original three-skill diagnostic covering reading, writing, and speaking. "
|
||||||
|
"This is practice material, not an official IELTS test."
|
||||||
|
)
|
||||||
|
exam.is_published = True
|
||||||
|
exam.save(update_fields=["description", "is_published"])
|
||||||
|
|
||||||
|
reading = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=20,
|
||||||
|
passage_text=(
|
||||||
|
"Pocket Parks in Growing Cities\n\n"
|
||||||
|
"As cities become more densely populated, planners are looking for ways to "
|
||||||
|
"create useful green spaces on small pieces of unused land. These compact areas, "
|
||||||
|
"often called pocket parks, may occupy a single vacant lot or a widened section of "
|
||||||
|
"pavement. Although they are much smaller than traditional public parks, they can "
|
||||||
|
"provide seating, shade, and a quiet place away from traffic.\n\n"
|
||||||
|
"The first widely recognised pocket park opened in New York City in 1967. Its "
|
||||||
|
"design used trees, a waterfall, and movable chairs to make a narrow site feel calm "
|
||||||
|
"and welcoming. Similar projects have since appeared in cities around the world. "
|
||||||
|
"Researchers have reported that even brief contact with greenery can reduce stress, "
|
||||||
|
"while local businesses may benefit from increased pedestrian activity.\n\n"
|
||||||
|
"Pocket parks are not a complete solution to the need for urban open space. They "
|
||||||
|
"cannot provide large playing fields, long walking routes, or major wildlife habitats. "
|
||||||
|
"They also require regular maintenance and careful lighting. However, when they are "
|
||||||
|
"planned with local residents, they can turn neglected land into a shared community asset."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Question.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Question(
|
||||||
|
section=reading,
|
||||||
|
order=1,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="What is a pocket park usually created on?",
|
||||||
|
options=[
|
||||||
|
"A small unused urban site",
|
||||||
|
"A large wildlife reserve",
|
||||||
|
"A private sports field",
|
||||||
|
],
|
||||||
|
correct_answer="A small unused urban site",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=reading,
|
||||||
|
order=2,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="In which year did the first widely recognised pocket park open?",
|
||||||
|
correct_answer="1967",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=reading,
|
||||||
|
order=3,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="Which limitation of pocket parks is mentioned in the passage?",
|
||||||
|
options=[
|
||||||
|
"They cannot provide large playing fields",
|
||||||
|
"They always reduce pedestrian activity",
|
||||||
|
"They cannot contain trees or seating",
|
||||||
|
],
|
||||||
|
correct_answer="They cannot provide large playing fields",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
writing = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=2,
|
||||||
|
section_type="writing",
|
||||||
|
time_limit_minutes=30,
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=writing,
|
||||||
|
order=1,
|
||||||
|
question_type="essay",
|
||||||
|
prompt=(
|
||||||
|
"Some people believe every neighbourhood should have a public green space. "
|
||||||
|
"To what extent do you agree or disagree? Give reasons and relevant examples."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
speaking = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=3,
|
||||||
|
section_type="speaking",
|
||||||
|
time_limit_minutes=10,
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=speaking,
|
||||||
|
order=1,
|
||||||
|
question_type="speaking",
|
||||||
|
prompt=(
|
||||||
|
"Describe a public place in your town or city that you enjoy visiting. "
|
||||||
|
"Explain where it is, what people do there, and why it is important to you. "
|
||||||
|
"Record your response and upload the audio, or type notes as a fallback."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Created and published IELTS Skills Diagnostic."))
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.files import File
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from exams.models import ExamSet, Question, Section
|
||||||
|
|
||||||
|
|
||||||
|
PASSAGE = (
|
||||||
|
"Pocket Parks in Growing Cities\n\n"
|
||||||
|
"As cities become more densely populated, planners are looking for ways to create useful "
|
||||||
|
"green spaces on small pieces of unused land. These compact areas, often called pocket "
|
||||||
|
"parks, may occupy a single vacant lot or a widened section of pavement. Although they are "
|
||||||
|
"much smaller than traditional public parks, they can provide seating, shade, and a quiet "
|
||||||
|
"place away from traffic.\n\n"
|
||||||
|
"The first widely recognised pocket park opened in New York City in 1967. Its design used "
|
||||||
|
"trees, a waterfall, and movable chairs to make a narrow site feel calm and welcoming. "
|
||||||
|
"Similar projects have since appeared in cities around the world. Researchers report that "
|
||||||
|
"even brief contact with greenery can reduce stress, while local businesses may benefit "
|
||||||
|
"from increased pedestrian activity.\n\n"
|
||||||
|
"Pocket parks are not a complete solution to the need for urban open space. They cannot "
|
||||||
|
"provide large playing fields, long walking routes, or major wildlife habitats. However, "
|
||||||
|
"when planned with local residents, they can turn neglected land into a shared asset."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create separate Reading, Listening, Writing, Speaking, and Full Mock tests."
|
||||||
|
|
||||||
|
def add_reading(self, exam, order=1):
|
||||||
|
section = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=order,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=20,
|
||||||
|
passage_text=PASSAGE,
|
||||||
|
)
|
||||||
|
Question.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=1,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="What is a pocket park usually created on?",
|
||||||
|
options=["A small unused urban site", "A wildlife reserve", "A sports field"],
|
||||||
|
correct_answer="A small unused urban site",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=2,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="In which year did the first widely recognised pocket park open?",
|
||||||
|
correct_answer="1967",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=3,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="Which limitation is mentioned in the passage?",
|
||||||
|
options=[
|
||||||
|
"They cannot provide large playing fields",
|
||||||
|
"They always reduce foot traffic",
|
||||||
|
"They cannot contain trees",
|
||||||
|
],
|
||||||
|
correct_answer="They cannot provide large playing fields",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_listening(self, exam, order=1):
|
||||||
|
section = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=order,
|
||||||
|
section_type="listening",
|
||||||
|
time_limit_minutes=10,
|
||||||
|
)
|
||||||
|
audio_path = Path(__file__).resolve().parents[2] / "seed_assets" / "community_library.wav"
|
||||||
|
with audio_path.open("rb") as audio:
|
||||||
|
section.audio_file.save("community_library.wav", File(audio), save=True)
|
||||||
|
Question.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=1,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="What time will the library close on weekdays?",
|
||||||
|
options=["4:00 pm", "7:00 pm", "9:00 pm"],
|
||||||
|
correct_answer="7:00 pm",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=2,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="On which floor is the new study room?",
|
||||||
|
correct_answer="second",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=3,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="What may students take into the study room?",
|
||||||
|
options=["Hot food", "Covered drinks", "Uncovered drinks"],
|
||||||
|
correct_answer="Covered drinks",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_writing(self, exam, order=1):
|
||||||
|
section = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=order,
|
||||||
|
section_type="writing",
|
||||||
|
time_limit_minutes=40,
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=section,
|
||||||
|
order=1,
|
||||||
|
question_type="essay",
|
||||||
|
prompt=(
|
||||||
|
"Some people believe every neighbourhood should have a public green space. "
|
||||||
|
"To what extent do you agree or disagree? Give reasons and relevant examples."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_speaking(self, exam, order=1):
|
||||||
|
section = Section.objects.create(
|
||||||
|
exam_set=exam,
|
||||||
|
order=order,
|
||||||
|
section_type="speaking",
|
||||||
|
time_limit_minutes=15,
|
||||||
|
)
|
||||||
|
Question.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=1,
|
||||||
|
question_type="speaking",
|
||||||
|
prompt="Describe a public place in your town or city that you enjoy visiting.",
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
section=section,
|
||||||
|
order=2,
|
||||||
|
question_type="speaking",
|
||||||
|
prompt="Why are shared public spaces important for a community?",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_exam(self, title, category, description, section_builders):
|
||||||
|
exam, created = ExamSet.objects.update_or_create(
|
||||||
|
title=title,
|
||||||
|
defaults={
|
||||||
|
"category": category,
|
||||||
|
"description": description,
|
||||||
|
"is_published": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created and exam.sections.exists():
|
||||||
|
return exam, False
|
||||||
|
for order, builder in enumerate(section_builders, start=1):
|
||||||
|
builder(exam, order)
|
||||||
|
return exam, True
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
definitions = [
|
||||||
|
(
|
||||||
|
"Reading Practice 1",
|
||||||
|
"reading",
|
||||||
|
"Focused reading practice with an original passage and objective questions.",
|
||||||
|
[self.add_reading],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Listening Practice 1",
|
||||||
|
"listening",
|
||||||
|
"Focused listening practice with original audio and objective questions.",
|
||||||
|
[self.add_listening],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Writing Practice 1",
|
||||||
|
"writing",
|
||||||
|
"Timed essay practice ready for instructor review and band feedback.",
|
||||||
|
[self.add_writing],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Speaking Practice 1",
|
||||||
|
"speaking",
|
||||||
|
"Speaking prompts with audio-response upload and instructor review.",
|
||||||
|
[self.add_speaking],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"IELTS Full Mock Test 1",
|
||||||
|
"full",
|
||||||
|
"A compact full mock covering Listening, Reading, Writing, and Speaking.",
|
||||||
|
[self.add_listening, self.add_reading, self.add_writing, self.add_speaking],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
created_count = 0
|
||||||
|
for definition in definitions:
|
||||||
|
_, created = self.create_exam(*definition)
|
||||||
|
created_count += int(created)
|
||||||
|
|
||||||
|
ExamSet.objects.filter(title="IELTS Skills Diagnostic").update(is_published=False)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Practice library ready: {len(definitions)} published categories "
|
||||||
|
f"({created_count} newly populated)."
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-12 07:31
|
||||||
|
|
||||||
|
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='ExamSet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Section',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('section_type', models.CharField(choices=[('listening', 'Listening'), ('reading', 'Reading'), ('writing', 'Writing'), ('speaking', 'Speaking')], max_length=20)),
|
||||||
|
('time_limit_minutes', models.PositiveIntegerField()),
|
||||||
|
('audio_file', models.FileField(blank=True, null=True, upload_to='audio/')),
|
||||||
|
('exam_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='exams.examset')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Question',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('order', models.PositiveIntegerField()),
|
||||||
|
('question_type', models.CharField(choices=[('mcq', 'Multiple Choice'), ('gap', 'Gap Fill'), ('matching', 'Matching'), ('essay', 'Essay'), ('speaking', 'Speaking Prompt')], max_length=20)),
|
||||||
|
('prompt', models.TextField()),
|
||||||
|
('options', models.JSONField(blank=True, null=True)),
|
||||||
|
('correct_answer', models.TextField(blank=True, null=True)),
|
||||||
|
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='exams.section')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StudentAttempt',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('submitted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_complete', models.BooleanField(default=False)),
|
||||||
|
('exam_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exams.examset')),
|
||||||
|
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StudentAnswer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('answer_text', models.TextField(blank=True)),
|
||||||
|
('is_correct', models.BooleanField(null=True)),
|
||||||
|
('manual_score', models.FloatField(blank=True, null=True)),
|
||||||
|
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exams.question')),
|
||||||
|
('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='exams.studentattempt')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-13 06:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='section',
|
||||||
|
name='passage_text',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-17 11:24
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0002_section_passage_text'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='studentanswer',
|
||||||
|
constraint=models.UniqueConstraint(fields=('attempt', 'question'), name='unique_answer_per_attempt_question'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='studentattempt',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('is_complete', False)), fields=('student', 'exam_set'), name='unique_active_attempt_per_exam'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-17 11:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0003_studentanswer_unique_answer_per_attempt_question_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='question',
|
||||||
|
options={'ordering': ['order', 'id']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='section',
|
||||||
|
options={'ordering': ['order', 'id']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='section',
|
||||||
|
name='order',
|
||||||
|
field=models.PositiveIntegerField(default=1),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='question',
|
||||||
|
constraint=models.UniqueConstraint(fields=('section', 'order'), name='unique_question_order_per_section'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='section',
|
||||||
|
constraint=models.UniqueConstraint(fields=('exam_set', 'order'), name='unique_section_order_per_exam'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-17 11:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0004_alter_question_options_alter_section_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='studentattempt',
|
||||||
|
name='current_section',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='exams.section'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='studentattempt',
|
||||||
|
name='section_deadline',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='studentattempt',
|
||||||
|
name='section_started_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-17 12:46
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0005_studentattempt_current_section_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='examset',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='examset',
|
||||||
|
name='is_published',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='studentanswer',
|
||||||
|
name='manual_score',
|
||||||
|
field=models.FloatField(blank=True, help_text='Manual band score from 0.0 to 9.0 for writing or speaking responses.', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("exams", "0008_examset_category")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="question",
|
||||||
|
name="explanation",
|
||||||
|
field=models.TextField(blank=True, help_text="Shown to students in review mode after they submit the test."),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="question",
|
||||||
|
name="passage_reference",
|
||||||
|
field=models.TextField(blank=True, help_text="Optional exact sentence or short excerpt from the reading passage that supports this answer."),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-17 13:00
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0006_examset_description_examset_is_published_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='studentanswer',
|
||||||
|
name='audio_response',
|
||||||
|
field=models.FileField(blank=True, help_text='Optional speaking response. Maximum size: 20 MB.', null=True, upload_to='speaking_responses/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(['mp3', 'm4a', 'wav', 'webm', 'ogg'])]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-17 13:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0007_studentanswer_audio_response'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='examset',
|
||||||
|
name='category',
|
||||||
|
field=models.CharField(choices=[('reading', 'Reading'), ('listening', 'Listening'), ('writing', 'Writing'), ('speaking', 'Speaking'), ('full', 'Full Mock Test')], default='full', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("exams", "0007_question_explanation_question_passage_reference")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="examset",
|
||||||
|
name="delivery_mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("native", "Adaptive platform test"), ("exact_html", "Exact uploaded HTML")],
|
||||||
|
default="native",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="examset",
|
||||||
|
name="source_html",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-07-19 16:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('exams', '0009_examset_exact_html'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='QuestionGroup',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('key', models.SlugField(help_text='Short identifier used by Excel, for example notes_1.', max_length=60)),
|
||||||
|
('order', models.PositiveIntegerField(default=1)),
|
||||||
|
('layout_type', models.CharField(choices=[('notes', 'Notes / summary'), ('table', 'Table / form'), ('flow', 'Flow chart')], default='notes', max_length=20)),
|
||||||
|
('title', models.CharField(blank=True, max_length=200)),
|
||||||
|
('instructions', models.TextField(blank=True)),
|
||||||
|
('layout_html', models.TextField(help_text='Formatted worksheet HTML. Insert blanks with [[question number]], for example [[1]].')),
|
||||||
|
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='question_groups', to='exams.section')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['order', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='group',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questions', to='exams.questiongroup'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='questiongroup',
|
||||||
|
constraint=models.UniqueConstraint(fields=('section', 'key'), name='unique_question_group_key_per_section'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("exams", "0010_questiongroup_question_group_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="examset",
|
||||||
|
name="access_level",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("free", "Free"),
|
||||||
|
("premium", "Lifetime Premium"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="free",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator
|
||||||
|
|
||||||
|
class ExamSet(models.Model):
|
||||||
|
ACCESS_FREE = "free"
|
||||||
|
ACCESS_PREMIUM = "premium"
|
||||||
|
ACCESS_LEVELS = [
|
||||||
|
(ACCESS_FREE, "Free"),
|
||||||
|
(ACCESS_PREMIUM, "Lifetime Premium"),
|
||||||
|
]
|
||||||
|
DELIVERY_MODES = [("native", "Adaptive platform test"), ("exact_html", "Exact uploaded HTML")]
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
("reading", "Reading"),
|
||||||
|
("listening", "Listening"),
|
||||||
|
("writing", "Writing"),
|
||||||
|
("speaking", "Speaking"),
|
||||||
|
("full", "Full Mock Test"),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default="full")
|
||||||
|
access_level = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ACCESS_LEVELS,
|
||||||
|
default=ACCESS_FREE,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
is_published = models.BooleanField(default=False)
|
||||||
|
delivery_mode = models.CharField(max_length=20, choices=DELIVERY_MODES, default="native")
|
||||||
|
source_html = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_ready(self):
|
||||||
|
if self.delivery_mode == "exact_html":
|
||||||
|
return bool((self.source_html or "").strip())
|
||||||
|
sections = list(self.sections.all())
|
||||||
|
return bool(sections) and all(section.questions.exists() for section in sections)
|
||||||
|
|
||||||
|
class Section(models.Model):
|
||||||
|
SECTION_TYPES = [('listening','Listening'), ('reading','Reading'),
|
||||||
|
('writing','Writing'), ('speaking','Speaking')]
|
||||||
|
exam_set = models.ForeignKey(ExamSet, on_delete=models.CASCADE, related_name='sections')
|
||||||
|
order = models.PositiveIntegerField(default=1)
|
||||||
|
section_type = models.CharField(max_length=20, choices=SECTION_TYPES)
|
||||||
|
time_limit_minutes = models.PositiveIntegerField()
|
||||||
|
audio_file = models.FileField(upload_to='audio/', blank=True, null=True)
|
||||||
|
passage_text = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.exam_set.title} - {self.section_type}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "id"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["exam_set", "order"],
|
||||||
|
name="unique_section_order_per_exam",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class QuestionGroup(models.Model):
|
||||||
|
LAYOUT_TYPES = [("notes", "Notes / summary"), ("table", "Table / form"), ("flow", "Flow chart")]
|
||||||
|
section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="question_groups")
|
||||||
|
key = models.SlugField(max_length=60, help_text="Short identifier used by Excel, for example notes_1.")
|
||||||
|
order = models.PositiveIntegerField(default=1)
|
||||||
|
layout_type = models.CharField(max_length=20, choices=LAYOUT_TYPES, default="notes")
|
||||||
|
title = models.CharField(max_length=200, blank=True)
|
||||||
|
instructions = models.TextField(blank=True)
|
||||||
|
layout_html = models.TextField(help_text="Formatted worksheet HTML. Insert blanks with [[question number]], for example [[1]].")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title or f"{self.section} — {self.key}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "id"]
|
||||||
|
constraints = [models.UniqueConstraint(fields=["section", "key"], name="unique_question_group_key_per_section")]
|
||||||
|
|
||||||
|
class Question(models.Model):
|
||||||
|
QUESTION_TYPES = [('mcq','Multiple Choice'), ('gap','Gap Fill'),
|
||||||
|
('matching','Matching'), ('essay','Essay'), ('speaking','Speaking Prompt')]
|
||||||
|
section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name='questions')
|
||||||
|
group = models.ForeignKey(QuestionGroup, on_delete=models.SET_NULL, related_name="questions", blank=True, null=True)
|
||||||
|
order = models.PositiveIntegerField()
|
||||||
|
question_type = models.CharField(max_length=20, choices=QUESTION_TYPES)
|
||||||
|
prompt = models.TextField()
|
||||||
|
options = models.JSONField(blank=True, null=True)
|
||||||
|
correct_answer = models.TextField(blank=True, null=True)
|
||||||
|
explanation = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Shown to students in review mode after they submit the test.",
|
||||||
|
)
|
||||||
|
passage_reference = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional exact sentence or short excerpt from the reading passage that supports this answer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Q{self.order}: {self.prompt[:40]}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
errors = {}
|
||||||
|
if self.group_id and self.section_id and self.group.section_id != self.section_id:
|
||||||
|
errors["group"] = "The question group must belong to the same section."
|
||||||
|
if self.question_type in {"mcq", "gap", "matching"} and not (
|
||||||
|
self.correct_answer or ""
|
||||||
|
).strip():
|
||||||
|
errors["correct_answer"] = "Objective questions require a correct answer."
|
||||||
|
if self.question_type == "mcq":
|
||||||
|
if not isinstance(self.options, list) or len(self.options) < 2:
|
||||||
|
errors["options"] = "Multiple-choice questions require at least two options."
|
||||||
|
elif self.correct_answer and self.correct_answer not in self.options:
|
||||||
|
errors["correct_answer"] = "The correct answer must match one of the options."
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "id"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["section", "order"],
|
||||||
|
name="unique_question_order_per_section",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class StudentAttempt(models.Model):
|
||||||
|
student = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
exam_set = models.ForeignKey(ExamSet, on_delete=models.CASCADE)
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
is_complete = models.BooleanField(default=False)
|
||||||
|
current_section = models.ForeignKey(
|
||||||
|
Section,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
section_started_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
section_deadline = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["student", "exam_set"],
|
||||||
|
condition=Q(is_complete=False),
|
||||||
|
name="unique_active_attempt_per_exam",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class StudentAnswer(models.Model):
|
||||||
|
attempt = models.ForeignKey(StudentAttempt, on_delete=models.CASCADE, related_name='answers')
|
||||||
|
question = models.ForeignKey(Question, on_delete=models.CASCADE)
|
||||||
|
answer_text = models.TextField(blank=True)
|
||||||
|
audio_response = models.FileField(
|
||||||
|
upload_to="speaking_responses/%Y/%m/",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[FileExtensionValidator(["mp3", "m4a", "wav", "webm", "ogg"])],
|
||||||
|
help_text="Optional speaking response. Maximum size: 20 MB.",
|
||||||
|
)
|
||||||
|
is_correct = models.BooleanField(null=True)
|
||||||
|
manual_score = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
validators=[MinValueValidator(0), MaxValueValidator(9)],
|
||||||
|
help_text="Manual band score from 0.0 to 9.0 for writing or speaking responses.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.manual_score is not None and self.question.question_type not in {
|
||||||
|
"essay",
|
||||||
|
"speaking",
|
||||||
|
}:
|
||||||
|
raise ValidationError(
|
||||||
|
{"manual_score": "Manual scores are only valid for writing or speaking answers."}
|
||||||
|
)
|
||||||
|
if self.audio_response and self.audio_response.size > 20 * 1024 * 1024:
|
||||||
|
raise ValidationError({"audio_response": "Audio responses must be 20 MB or smaller."})
|
||||||
|
if self.audio_response and self.question.question_type != "speaking":
|
||||||
|
raise ValidationError(
|
||||||
|
{"audio_response": "Audio responses are only valid for speaking questions."}
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["attempt", "question"],
|
||||||
|
name="unique_answer_per_attempt_question",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
<html xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||||
|
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||||
|
xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"
|
||||||
|
xmlns="http://www.w3.org/TR/REC-html40">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta http-equiv=Content-Type content="text/html; charset=windows-1251">
|
||||||
|
<meta name=ProgId content=Word.Document>
|
||||||
|
<meta name=Generator content="Microsoft Word 15">
|
||||||
|
<meta name=Originator content="Microsoft Word 15">
|
||||||
|
<link rel=File-List href="student_dashboard.files/filelist.xml">
|
||||||
|
<!--[if gte mso 9]><xml>
|
||||||
|
<o:DocumentProperties>
|
||||||
|
<o:Author>sgd</o:Author>
|
||||||
|
<o:LastAuthor>sgd</o:LastAuthor>
|
||||||
|
<o:Revision>2</o:Revision>
|
||||||
|
<o:TotalTime>1</o:TotalTime>
|
||||||
|
<o:Created>2026-07-12T08:17:00Z</o:Created>
|
||||||
|
<o:LastSaved>2026-07-12T08:17:00Z</o:LastSaved>
|
||||||
|
<o:Pages>1</o:Pages>
|
||||||
|
<o:Version>16.00</o:Version>
|
||||||
|
</o:DocumentProperties>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml><![endif]-->
|
||||||
|
<link rel=themeData href="student_dashboard.files/themedata.thmx">
|
||||||
|
<link rel=colorSchemeMapping
|
||||||
|
href="student_dashboard.files/colorschememapping.xml">
|
||||||
|
<!--[if gte mso 9]><xml>
|
||||||
|
<w:WordDocument>
|
||||||
|
<w:TrackMoves>false</w:TrackMoves>
|
||||||
|
<w:TrackFormatting/>
|
||||||
|
<w:PunctuationKerning/>
|
||||||
|
<w:ValidateAgainstSchemas/>
|
||||||
|
<w:SaveIfXMLInvalid>false</w:SaveIfXMLInvalid>
|
||||||
|
<w:IgnoreMixedContent>false</w:IgnoreMixedContent>
|
||||||
|
<w:AlwaysShowPlaceholderText>false</w:AlwaysShowPlaceholderText>
|
||||||
|
<w:DoNotPromoteQF/>
|
||||||
|
<w:LidThemeOther>RU</w:LidThemeOther>
|
||||||
|
<w:LidThemeAsian>X-NONE</w:LidThemeAsian>
|
||||||
|
<w:LidThemeComplexScript>X-NONE</w:LidThemeComplexScript>
|
||||||
|
<w:Compatibility>
|
||||||
|
<w:BreakWrappedTables/>
|
||||||
|
<w:SnapToGridInCell/>
|
||||||
|
<w:WrapTextWithPunct/>
|
||||||
|
<w:UseAsianBreakRules/>
|
||||||
|
<w:DontGrowAutofit/>
|
||||||
|
<w:SplitPgBreakAndParaMark/>
|
||||||
|
<w:EnableOpenTypeKerning/>
|
||||||
|
<w:DontFlipMirrorIndents/>
|
||||||
|
<w:OverrideTableStyleHps/>
|
||||||
|
</w:Compatibility>
|
||||||
|
<m:mathPr>
|
||||||
|
<m:mathFont m:val="Cambria Math"/>
|
||||||
|
<m:brkBin m:val="before"/>
|
||||||
|
<m:brkBinSub m:val="--"/>
|
||||||
|
<m:smallFrac m:val="off"/>
|
||||||
|
<m:dispDef/>
|
||||||
|
<m:lMargin m:val="0"/>
|
||||||
|
<m:rMargin m:val="0"/>
|
||||||
|
<m:defJc m:val="centerGroup"/>
|
||||||
|
<m:wrapIndent m:val="1440"/>
|
||||||
|
<m:intLim m:val="subSup"/>
|
||||||
|
<m:naryLim m:val="undOvr"/>
|
||||||
|
</m:mathPr></w:WordDocument>
|
||||||
|
</xml><![endif]--><!--[if gte mso 9]><xml>
|
||||||
|
<w:LatentStyles DefLockedState="false" DefUnhideWhenUsed="false"
|
||||||
|
DefSemiHidden="false" DefQFormat="false" DefPriority="99"
|
||||||
|
LatentStyleCount="376">
|
||||||
|
<w:LsdException Locked="false" Priority="0" QFormat="true" Name="Normal"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 7"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 8"/>
|
||||||
|
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="heading 9"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 6"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 7"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 8"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index 9"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 7"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 8"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="toc 9"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Normal Indent"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="footnote text"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="annotation text"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="header"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="footer"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="index heading"/>
|
||||||
|
<w:LsdException Locked="false" Priority="35" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="caption"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="table of figures"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="envelope address"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="envelope return"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="footnote reference"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="annotation reference"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="line number"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="page number"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="endnote reference"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="endnote text"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="table of authorities"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="macro"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="toa heading"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Bullet"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Number"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Bullet 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Bullet 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Bullet 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Bullet 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Number 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Number 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Number 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Number 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="10" QFormat="true" Name="Title"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Closing"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Signature"/>
|
||||||
|
<w:LsdException Locked="false" Priority="1" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="Default Paragraph Font"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text Indent"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Continue"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Continue 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Continue 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Continue 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="List Continue 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Message Header"/>
|
||||||
|
<w:LsdException Locked="false" Priority="11" QFormat="true" Name="Subtitle"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Salutation"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Date"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text First Indent"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text First Indent 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Note Heading"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text Indent 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Body Text Indent 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Block Text"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Hyperlink"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="FollowedHyperlink"/>
|
||||||
|
<w:LsdException Locked="false" Priority="22" QFormat="true" Name="Strong"/>
|
||||||
|
<w:LsdException Locked="false" Priority="20" QFormat="true" Name="Emphasis"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Document Map"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Plain Text"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="E-mail Signature"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Top of Form"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Bottom of Form"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Normal (Web)"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Acronym"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Address"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Cite"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Code"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Definition"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Keyboard"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Preformatted"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Sample"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Typewriter"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="HTML Variable"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Normal Table"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="annotation subject"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="No List"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Outline List 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Outline List 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Outline List 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Simple 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Simple 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Simple 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Classic 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Classic 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Classic 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Classic 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Colorful 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Colorful 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Colorful 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Columns 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Columns 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Columns 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Columns 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Columns 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 6"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 7"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Grid 8"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 4"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 5"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 6"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 7"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table List 8"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table 3D effects 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table 3D effects 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table 3D effects 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Contemporary"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Elegant"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Professional"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Subtle 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Subtle 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Web 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Web 2"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Web 3"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Balloon Text"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" Name="Table Grid"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Table Theme"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" Name="Placeholder Text"/>
|
||||||
|
<w:LsdException Locked="false" Priority="1" QFormat="true" Name="No Spacing"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" Name="Revision"/>
|
||||||
|
<w:LsdException Locked="false" Priority="34" QFormat="true"
|
||||||
|
Name="List Paragraph"/>
|
||||||
|
<w:LsdException Locked="false" Priority="29" QFormat="true" Name="Quote"/>
|
||||||
|
<w:LsdException Locked="false" Priority="30" QFormat="true"
|
||||||
|
Name="Intense Quote"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="19" QFormat="true"
|
||||||
|
Name="Subtle Emphasis"/>
|
||||||
|
<w:LsdException Locked="false" Priority="21" QFormat="true"
|
||||||
|
Name="Intense Emphasis"/>
|
||||||
|
<w:LsdException Locked="false" Priority="31" QFormat="true"
|
||||||
|
Name="Subtle Reference"/>
|
||||||
|
<w:LsdException Locked="false" Priority="32" QFormat="true"
|
||||||
|
Name="Intense Reference"/>
|
||||||
|
<w:LsdException Locked="false" Priority="33" QFormat="true" Name="Book Title"/>
|
||||||
|
<w:LsdException Locked="false" Priority="37" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" Name="Bibliography"/>
|
||||||
|
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
|
||||||
|
UnhideWhenUsed="true" QFormat="true" Name="TOC Heading"/>
|
||||||
|
<w:LsdException Locked="false" Priority="41" Name="Plain Table 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="42" Name="Plain Table 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="43" Name="Plain Table 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="44" Name="Plain Table 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="45" Name="Plain Table 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="40" Name="Grid Table Light"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46" Name="Grid Table 1 Light"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51" Name="Grid Table 6 Colorful"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52" Name="Grid Table 7 Colorful"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="Grid Table 1 Light Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="Grid Table 6 Colorful Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="Grid Table 7 Colorful Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="Grid Table 1 Light Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="Grid Table 6 Colorful Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="Grid Table 7 Colorful Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="Grid Table 1 Light Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="Grid Table 6 Colorful Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="Grid Table 7 Colorful Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="Grid Table 1 Light Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="Grid Table 6 Colorful Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="Grid Table 7 Colorful Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="Grid Table 1 Light Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="Grid Table 6 Colorful Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="Grid Table 7 Colorful Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="Grid Table 1 Light Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="Grid Table 6 Colorful Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="Grid Table 7 Colorful Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46" Name="List Table 1 Light"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51" Name="List Table 6 Colorful"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52" Name="List Table 7 Colorful"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="List Table 1 Light Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="List Table 6 Colorful Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="List Table 7 Colorful Accent 1"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="List Table 1 Light Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="List Table 6 Colorful Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="List Table 7 Colorful Accent 2"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="List Table 1 Light Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="List Table 6 Colorful Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="List Table 7 Colorful Accent 3"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="List Table 1 Light Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="List Table 6 Colorful Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="List Table 7 Colorful Accent 4"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="List Table 1 Light Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="List Table 6 Colorful Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="List Table 7 Colorful Accent 5"/>
|
||||||
|
<w:LsdException Locked="false" Priority="46"
|
||||||
|
Name="List Table 1 Light Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="51"
|
||||||
|
Name="List Table 6 Colorful Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" Priority="52"
|
||||||
|
Name="List Table 7 Colorful Accent 6"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Mention"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Smart Hyperlink"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Hashtag"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Unresolved Mention"/>
|
||||||
|
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
|
||||||
|
Name="Smart Link"/>
|
||||||
|
</w:LatentStyles>
|
||||||
|
</xml><![endif]-->
|
||||||
|
<style>
|
||||||
|
<!--
|
||||||
|
/* Font Definitions */
|
||||||
|
@font-face
|
||||||
|
{font-family:"Cambria Math";
|
||||||
|
panose-1:2 4 5 3 5 4 6 3 2 4;
|
||||||
|
mso-font-charset:0;
|
||||||
|
mso-generic-font-family:roman;
|
||||||
|
mso-font-pitch:variable;
|
||||||
|
mso-font-signature:3 0 0 0 1 0;}
|
||||||
|
@font-face
|
||||||
|
{font-family:Calibri;
|
||||||
|
panose-1:2 15 5 2 2 2 4 3 2 4;
|
||||||
|
mso-font-charset:204;
|
||||||
|
mso-generic-font-family:swiss;
|
||||||
|
mso-font-pitch:variable;
|
||||||
|
mso-font-signature:-469750017 -1073732485 9 0 511 0;}
|
||||||
|
/* Style Definitions */
|
||||||
|
p.MsoNormal, li.MsoNormal, div.MsoNormal
|
||||||
|
{mso-style-unhide:no;
|
||||||
|
mso-style-qformat:yes;
|
||||||
|
mso-style-parent:"";
|
||||||
|
margin-top:0cm;
|
||||||
|
margin-right:0cm;
|
||||||
|
margin-bottom:8.0pt;
|
||||||
|
margin-left:0cm;
|
||||||
|
line-height:107%;
|
||||||
|
mso-pagination:widow-orphan;
|
||||||
|
font-size:11.0pt;
|
||||||
|
font-family:"Calibri",sans-serif;
|
||||||
|
mso-ascii-font-family:Calibri;
|
||||||
|
mso-ascii-theme-font:minor-latin;
|
||||||
|
mso-fareast-font-family:Calibri;
|
||||||
|
mso-fareast-theme-font:minor-latin;
|
||||||
|
mso-hansi-font-family:Calibri;
|
||||||
|
mso-hansi-theme-font:minor-latin;
|
||||||
|
mso-bidi-font-family:"Times New Roman";
|
||||||
|
mso-bidi-theme-font:minor-bidi;
|
||||||
|
mso-fareast-language:EN-US;}
|
||||||
|
.MsoChpDefault
|
||||||
|
{mso-style-type:export-only;
|
||||||
|
mso-default-props:yes;
|
||||||
|
font-family:"Calibri",sans-serif;
|
||||||
|
mso-ascii-font-family:Calibri;
|
||||||
|
mso-ascii-theme-font:minor-latin;
|
||||||
|
mso-fareast-font-family:Calibri;
|
||||||
|
mso-fareast-theme-font:minor-latin;
|
||||||
|
mso-hansi-font-family:Calibri;
|
||||||
|
mso-hansi-theme-font:minor-latin;
|
||||||
|
mso-bidi-font-family:"Times New Roman";
|
||||||
|
mso-bidi-theme-font:minor-bidi;
|
||||||
|
mso-fareast-language:EN-US;}
|
||||||
|
.MsoPapDefault
|
||||||
|
{mso-style-type:export-only;
|
||||||
|
margin-bottom:8.0pt;
|
||||||
|
line-height:107%;}
|
||||||
|
@page WordSection1
|
||||||
|
{size:595.3pt 841.9pt;
|
||||||
|
margin:2.0cm 42.5pt 2.0cm 3.0cm;
|
||||||
|
mso-header-margin:35.4pt;
|
||||||
|
mso-footer-margin:35.4pt;
|
||||||
|
mso-paper-source:0;}
|
||||||
|
div.WordSection1
|
||||||
|
{page:WordSection1;}
|
||||||
|
-->
|
||||||
|
</style>
|
||||||
|
<!--[if gte mso 10]>
|
||||||
|
<style>
|
||||||
|
/* Style Definitions */
|
||||||
|
table.MsoNormalTable
|
||||||
|
{mso-style-name:"Îáû÷íàÿ òàáëèöà";
|
||||||
|
mso-tstyle-rowband-size:0;
|
||||||
|
mso-tstyle-colband-size:0;
|
||||||
|
mso-style-noshow:yes;
|
||||||
|
mso-style-priority:99;
|
||||||
|
mso-style-parent:"";
|
||||||
|
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
|
||||||
|
mso-para-margin-top:0cm;
|
||||||
|
mso-para-margin-right:0cm;
|
||||||
|
mso-para-margin-bottom:8.0pt;
|
||||||
|
mso-para-margin-left:0cm;
|
||||||
|
line-height:107%;
|
||||||
|
mso-pagination:widow-orphan;
|
||||||
|
font-size:11.0pt;
|
||||||
|
font-family:"Calibri",sans-serif;
|
||||||
|
mso-ascii-font-family:Calibri;
|
||||||
|
mso-ascii-theme-font:minor-latin;
|
||||||
|
mso-hansi-font-family:Calibri;
|
||||||
|
mso-hansi-theme-font:minor-latin;
|
||||||
|
mso-bidi-font-family:"Times New Roman";
|
||||||
|
mso-bidi-theme-font:minor-bidi;
|
||||||
|
mso-fareast-language:EN-US;}
|
||||||
|
</style>
|
||||||
|
<![endif]--><!--[if gte mso 9]><xml>
|
||||||
|
<o:shapedefaults v:ext="edit" spidmax="1026"/>
|
||||||
|
</xml><![endif]--><!--[if gte mso 9]><xml>
|
||||||
|
<o:shapelayout v:ext="edit">
|
||||||
|
<o:idmap v:ext="edit" data="1"/>
|
||||||
|
</o:shapelayout></xml><![endif]-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body lang=RU style='tab-interval:35.4pt;word-wrap:break-word'>
|
||||||
|
|
||||||
|
<div class=WordSection1>
|
||||||
|
|
||||||
|
<p class=MsoNormal><o:p> </o:p></p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ exam_set.title }} | Testpoint</title><link rel="stylesheet" href="{% static 'css/exact-exam.css' %}?v=20260719.1"></head>
|
||||||
|
<body class="exact-exam-page"><header class="exact-exam-bar"><div><a href="{% url 'exam_list' %}" aria-label="Leave test">←</a><span><small>Exact HTML test</small><strong>{{ exam_set.title }}</strong></span></div><div><button id="exact-fullscreen" type="button">Fullscreen</button><form method="post" action="{% url 'complete_exact_exam' attempt.id %}" onsubmit="return confirm('Mark this test as complete?');">{% csrf_token %}<button type="submit" class="exact-finish">Finish test</button></form></div></header><main class="exact-exam-frame-wrap"><iframe title="{{ exam_set.title }}" src="{% url 'exact_exam_content' attempt.id %}" sandbox="allow-scripts allow-forms allow-modals allow-downloads"></iframe></main><script>document.getElementById('exact-fullscreen').addEventListener('click',function(){if(!document.fullscreenElement){document.documentElement.requestFullscreen().catch(function(){});this.textContent='Exit fullscreen';}else{document.exitFullscreen();this.textContent='Fullscreen';}});</script></body></html>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}{{ exam_set.title }} | Testpoint{% endblock %}
|
||||||
|
{% block meta_description %}Review the sections, timing, and instructions for {{ exam_set.title }}.{% endblock %}
|
||||||
|
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-catalogue.css' %}?v=20260727.1">{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="exam-intro-page">
|
||||||
|
<div class="exam-intro-shell">
|
||||||
|
<a class="exam-back" href="{% url 'exam_list' %}"><i class="bi bi-arrow-left"></i> Back to tests</a>
|
||||||
|
<div class="exam-intro-grid">
|
||||||
|
<main class="exam-intro-card">
|
||||||
|
<span class="exam-intro-label">{% if exam_set.access_level == 'premium' %}<i class="bi bi-stars"></i> Lifetime Premium{% else %}IELTS practice test{% endif %}</span>
|
||||||
|
<h1>{{ exam_set.title }}</h1>
|
||||||
|
<p>{{ exam_set.description|default:"A timed IELTS practice test designed to help you understand your current performance." }}</p>
|
||||||
|
{% if exam_set.delivery_mode == 'exact_html' %}<p><strong>Original HTML test:</strong> this opens with the uploaded design, layout, questions, and controls intact.</p>{% endif %}
|
||||||
|
<div class="exam-intro-stats">
|
||||||
|
{% if exam_set.delivery_mode == 'exact_html' %}
|
||||||
|
<div><i class="bi bi-filetype-html"></i><span><strong>Exact</strong> HTML</span></div>
|
||||||
|
<div><i class="bi bi-aspect-ratio"></i><span><strong>Original</strong> layout</span></div>
|
||||||
|
<div><i class="bi bi-arrows-fullscreen"></i><span><strong>Full</strong> screen</span></div>
|
||||||
|
{% else %}
|
||||||
|
<div><i class="bi bi-layers"></i><span><strong>{{ sections|length }}</strong> sections</span></div>
|
||||||
|
<div><i class="bi bi-question-circle"></i><span><strong>{{ question_count }}</strong> questions</span></div>
|
||||||
|
<div><i class="bi bi-stopwatch"></i><span><strong>{{ total_minutes }}</strong> minutes</span></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if exam_set.delivery_mode != 'exact_html' %}<h2>Test sections</h2>
|
||||||
|
<div class="exam-section-list">
|
||||||
|
{% for section in sections %}
|
||||||
|
<article><span>{{ forloop.counter }}</span><div><strong>{{ section.get_section_type_display }}</strong><small>{{ section.questions.count }} question{{ section.questions.count|pluralize }} · {{ section.time_limit_minutes }} minutes</small></div></article>
|
||||||
|
{% empty %}<p>No sections have been published for this test.</p>{% endfor %}
|
||||||
|
</div>{% endif %}
|
||||||
|
</main>
|
||||||
|
<aside class="exam-rules-card">
|
||||||
|
<span class="exam-rules-icon"><i class="bi bi-info-circle"></i></span>
|
||||||
|
<h2>Before you begin</h2>
|
||||||
|
<ul>
|
||||||
|
<li>The timer continues if you refresh or close the page.</li>
|
||||||
|
<li>Submit each section before its time expires.</li>
|
||||||
|
<li>Objective answers are graded immediately.</li>
|
||||||
|
<li>Writing and speaking responses may require instructor review.</li>
|
||||||
|
</ul>
|
||||||
|
{% if not can_access_exam %}
|
||||||
|
<a class="exam-premium-action" href="{% url 'premium' %}"><i class="bi bi-lock-fill"></i> Unlock with Lifetime Premium</a>
|
||||||
|
<p class="exam-premium-note">One payment unlocks this test and every future Premium test.</p>
|
||||||
|
{% elif is_ready %}
|
||||||
|
<form method="post" action="{% url 'start_exam' exam_set.id %}">{% csrf_token %}<button type="submit">{% if active_attempt %}Continue test{% else %}Begin test{% endif %} <i class="bi bi-arrow-right"></i></button></form>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" disabled>Test unavailable</button>
|
||||||
|
<p class="exam-unavailable">This test still needs content from an administrator.</p>
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}IELTS Practice Tests | Testpoint{% endblock %}
|
||||||
|
{% block meta_description %}Browse available IELTS mock tests and continue your practice progress.{% endblock %}
|
||||||
|
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-catalogue.css' %}?v=20260727.1"><link rel="stylesheet" href="{% static 'css/catalogue-density.css' %}?v=20260718.2"><link rel="stylesheet" href="{% static 'css/catalogue-compact.css' %}?v=20260727.1"><link rel="stylesheet" href="{% static 'css/catalogue-card-type.css' %}?v=20260719.1">{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="catalogue-hero">
|
||||||
|
<div class="catalogue-hero__content">
|
||||||
|
<span>Practice centre</span>
|
||||||
|
<h1>IELTS Practice Tests</h1>
|
||||||
|
<p>Choose a test, practise under realistic time limits, and review your results.</p>
|
||||||
|
<div class="catalogue-summary" aria-label="Test catalogue summary">
|
||||||
|
<div><strong>{{ total }}</strong><span>Available test{{ total|pluralize }}</span></div>
|
||||||
|
<div><strong>{{ counts.in_progress }}</strong><span>In progress</span></div>
|
||||||
|
<div><strong>{{ counts.completed }}</strong><span>Completed</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="catalogue-page">
|
||||||
|
<div class="catalogue-shell">
|
||||||
|
<nav class="catalogue-filters" aria-label="Filter mock tests">
|
||||||
|
<div class="catalogue-filter-group">
|
||||||
|
<span>Test type</span>
|
||||||
|
<div>
|
||||||
|
<a href="?skill=all&status={{ status_filter }}" {% if skill_filter == 'all' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-grid"></i>All tests <b>{{ all_total }}</b></a>
|
||||||
|
<a href="?skill=reading&status={{ status_filter }}" {% if skill_filter == 'reading' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-book"></i>Reading <b>{{ skill_counts.reading }}</b></a>
|
||||||
|
<a href="?skill=listening&status={{ status_filter }}" {% if skill_filter == 'listening' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-headphones"></i>Listening <b>{{ skill_counts.listening }}</b></a>
|
||||||
|
<a href="?skill=writing&status={{ status_filter }}" {% if skill_filter == 'writing' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-pencil"></i>Writing <b>{{ skill_counts.writing }}</b></a>
|
||||||
|
<a href="?skill=speaking&status={{ status_filter }}" {% if skill_filter == 'speaking' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-mic"></i>Speaking <b>{{ skill_counts.speaking }}</b></a>
|
||||||
|
<a href="?skill=full&status={{ status_filter }}" {% if skill_filter == 'full' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-layers"></i>Full Mock Test <b>{{ skill_counts.full }}</b></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="catalogue-filter-group">
|
||||||
|
<span>Progress</span>
|
||||||
|
<div>
|
||||||
|
<a href="?skill={{ skill_filter }}&status=all" {% if status_filter == 'all' %}class="is-active" aria-current="page"{% endif %}>All <b>{{ total }}</b></a>
|
||||||
|
<a href="?skill={{ skill_filter }}&status=not_started" {% if status_filter == 'not_started' %}class="is-active" aria-current="page"{% endif %}>Not started <b>{{ counts.not_started }}</b></a>
|
||||||
|
<a href="?skill={{ skill_filter }}&status=in_progress" {% if status_filter == 'in_progress' %}class="is-active" aria-current="page"{% endif %}>In progress <b>{{ counts.in_progress }}</b></a>
|
||||||
|
<a href="?skill={{ skill_filter }}&status=completed" {% if status_filter == 'completed' %}class="is-active" aria-current="page"{% endif %}>Completed <b>{{ counts.completed }}</b></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="catalogue-main">
|
||||||
|
<div class="catalogue-heading">
|
||||||
|
<div><span>Test library</span><h2>{% if skill_filter == 'reading' %}Reading tests{% elif skill_filter == 'listening' %}Listening tests{% elif skill_filter == 'writing' %}Writing tests{% elif skill_filter == 'speaking' %}Speaking tests{% elif skill_filter == 'full' %}Full mock tests{% elif status_filter == 'not_started' %}Tests not started{% elif status_filter == 'in_progress' %}Tests in progress{% elif status_filter == 'completed' %}Completed tests{% else %}All mock tests{% endif %}</h2></div>
|
||||||
|
<a href="{% url 'student_dashboard' %}"><i class="bi bi-grid"></i> Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if exam_data %}
|
||||||
|
<section class="catalogue-search-panel" aria-label="Search and filter tests">
|
||||||
|
<label class="catalogue-search-box" for="catalogue-search"><i class="bi bi-search"></i><input id="catalogue-search" type="search" placeholder="Search tests by title..." autocomplete="off"></label>
|
||||||
|
<div class="catalogue-part-filters" role="group" aria-label="Filter by passage part"><span>Part:</span><button class="is-active" type="button" data-part="all" aria-pressed="true">All</button><button type="button" data-part="p1" aria-pressed="false">P1</button><button type="button" data-part="p2" aria-pressed="false">P2</button><button type="button" data-part="p3" aria-pressed="false">P3</button></div>
|
||||||
|
</section>
|
||||||
|
<div class="catalogue-grid catalogue-grid--compact" id="catalogue-test-grid">
|
||||||
|
{% for item in exam_data %}
|
||||||
|
<article class="catalogue-compact-card" data-title="{{ item.exam_set.title|lower }}" data-part="{{ item.part }}">
|
||||||
|
{% if item.requires_premium %}<span class="catalogue-premium-mark"><i class="bi bi-stars"></i> Premium</span>{% endif %}
|
||||||
|
<span class="visually-hidden"><strong>{{ item.section_count }}</strong> section{{ item.section_count|pluralize }}; <strong>{{ item.question_count }}</strong> question{{ item.question_count|pluralize }}; <strong>{{ item.total_minutes }}</strong> min{% if item.status == 'in_progress' %}; Continue test{% endif %}</span>
|
||||||
|
<span class="catalogue-compact-icon"><i class="bi {% if item.exam_set.category == 'reading' %}bi-book{% elif item.exam_set.category == 'listening' %}bi-headphones{% elif item.exam_set.category == 'writing' %}bi-pencil{% elif item.exam_set.category == 'speaking' %}bi-mic{% else %}bi-layers{% endif %}"></i></span>
|
||||||
|
<div class="catalogue-compact-body"><h3>{{ item.exam_set.title }}</h3><div class="catalogue-compact-tags"><span>{{ item.exam_set.get_category_display }}</span>{% if item.part != 'all' %}<b>{{ item.part|upper }}</b>{% endif %}{% if item.status == 'completed' %}<em class="is-complete">Completed{% if item.score %} · {{ item.score }}{% endif %}</em>{% elif item.status == 'in_progress' %}<em class="is-progress">In progress</em>{% endif %}</div><p>{% if item.is_exact %}<i class="bi bi-filetype-html"></i> Exact HTML{% else %}<i class="bi bi-stopwatch"></i> {{ item.total_minutes }} min <span>·</span> {{ item.question_count }} question{{ item.question_count|pluralize }}{% endif %}</p></div>
|
||||||
|
{% if item.status == 'completed' %}<a class="catalogue-compact-action" href="{% url 'results' item.attempt.id %}" aria-label="View results for {{ item.exam_set.title }}"><i class="bi bi-arrow-right"></i></a>{% elif item.is_locked %}<a class="catalogue-compact-action is-locked" href="{% url 'premium' %}" aria-label="Unlock {{ item.exam_set.title }} with Lifetime Premium"><i class="bi bi-lock-fill"></i></a>{% else %}<a class="catalogue-compact-action" href="{% url 'exam_detail' item.exam_set.id %}" aria-label="{% if item.status == 'in_progress' %}Continue test{% else %}View test{% endif %}: {{ item.exam_set.title }}"><i class="bi bi-arrow-right"></i></a>{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="catalogue-search-empty" id="catalogue-search-empty" hidden><i class="bi bi-search"></i><strong>No matching tests</strong><span>Try another title or passage part.</span></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="catalogue-empty">
|
||||||
|
<span><i class="bi bi-search"></i></span>
|
||||||
|
<h3>No tests in this category</h3>
|
||||||
|
<p>{% if total %}Try another filter to see the rest of your mock tests.{% else %}Mock tests will appear here after they are published.{% endif %}</p>
|
||||||
|
{% if total %}<a href="?status=all">Show all tests</a>{% else %}<a href="{% url 'student_dashboard' %}">Return to dashboard</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_js %}<script src="{% static 'js/catalogue-search.js' %}?v=20260719.1"></script>{% endblock %}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static exam_content %}
|
||||||
|
{% block title %}Results | {{ attempt.exam_set.title }}{% endblock %}
|
||||||
|
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-session.css' %}?v=20260719.3"><link rel="stylesheet" href="{% static 'css/review-mode.css' %}?v=20260719.2">{% if use_split_reading_review %}<link rel="stylesheet" href="{% static 'css/reading-review.css' %}?v=20260719.7">{% endif %}<link rel="stylesheet" href="{% static 'css/exam-content-typography.css' %}?v=20260719.10"><link rel="stylesheet" href="{% static 'css/assessment-workspace.css' %}?v=20260727.2">{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% if use_split_reading_review %}
|
||||||
|
<section class="reader-review-page assessment-page assessment-page--review" id="reader-review">
|
||||||
|
<header class="reader-review-head assessment-bar">
|
||||||
|
<div class="reader-review-title assessment-bar__title"><a class="assessment-bar__back" href="{% url 'student_dashboard' %}" aria-label="Back to dashboard"><i class="bi bi-arrow-left"></i></a><span class="reader-head-icon assessment-bar__icon"><i class="bi bi-book"></i></span><div><small>Reading · Review</small><h1>{{ attempt.exam_set.title }}</h1></div></div>
|
||||||
|
<article class="assessment-metric"><span class="reader-head-icon"><i class="bi bi-bullseye"></i></span><div><small>Raw score</small><strong>{{ correct_count }} / {{ total_gradable }}</strong></div></article>
|
||||||
|
<article class="assessment-metric"><span class="reader-head-icon reader-head-icon--green"><i class="bi bi-award"></i></span><div><small>{% if reading_band_estimated %}Estimated band{% else %}Reading band{% endif %}</small><strong>{{ reading_band|floatformat:1 }} / 9</strong>{% if reading_band_estimated %}<em>Equivalent to {{ reading_equivalent }} / 40</em>{% endif %}</div></article>
|
||||||
|
<form class="assessment-bar__action" method="post" action="{% url 'start_exam' attempt.exam_set.id %}">{% csrf_token %}<button type="submit"><i class="bi bi-arrow-repeat"></i> Retake test</button></form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="reader-review-workspace">
|
||||||
|
<section class="reader-passage-pane" aria-label="Reading passage">
|
||||||
|
<div class="reader-pane-heading reader-pane-heading--title"><h2>{{ attempt.exam_set.title }}</h2></div>
|
||||||
|
{% for section in reading_passages %}<article class="reader-passage" data-passage-section="{{ section.id }}"{% if not forloop.first %} hidden{% endif %}><div class="reader-passage-content rich-passage-content">{{ section.passage_text|render_rich_text }}</div></article>{% endfor %}
|
||||||
|
</section>
|
||||||
|
<section class="reader-question-pane" aria-label="Answer review">
|
||||||
|
{% for answer in reading_review_answers %}
|
||||||
|
<article class="reader-question-review{% if forloop.first %} is-active{% endif %}" data-review-index="{{ forloop.counter0 }}" data-section-id="{{ answer.question.section_id }}" data-reference="{{ answer.question.passage_reference }}" data-state="{% if answer.is_correct is True %}correct{% elif answer.is_correct is False %}incorrect{% else %}pending{% endif %}">
|
||||||
|
<div class="reader-review-card">
|
||||||
|
<p class="reader-question-count">Question {{ forloop.counter }} of {{ reading_review_answers|length }}</p>
|
||||||
|
<div class="reader-question-prompt rich-question-prompt">{{ answer.question.prompt|render_rich_text }}</div>
|
||||||
|
<div class="reader-answer-content">
|
||||||
|
<div class="reader-answer-grid"><div><small>Your answer</small><strong class="{% if answer.is_correct is True %}is-correct{% elif answer.is_correct is False %}is-wrong{% endif %}">{{ answer.answer_text|default:"No answer submitted" }}</strong></div><div><small>Correct answer</small><strong class="is-correct">{{ answer.question.correct_answer|default:"Instructor reviewed" }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<section class="reader-evidence-content">{% if answer.question.passage_reference %}<blockquote>{{ answer.question.passage_reference }}</blockquote><button class="reader-show-evidence" type="button"><i class="bi bi-box-arrow-up-right"></i> Show in passage</button>{% else %}<p>No exact passage sentence has been attached to this question.</p>{% endif %}</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="reader-review-footer reader-review-footer--no-legend">
|
||||||
|
<nav class="reader-question-nav" aria-label="Review questions">{% for answer in reading_review_answers %}<button type="button" data-review-target="{{ forloop.counter0 }}" class="{% if answer.is_correct is True %}is-correct{% elif answer.is_correct is False %}is-incorrect{% else %}is-pending{% endif %}{% if forloop.first %} is-current{% endif %}" aria-label="Review question {{ forloop.counter }}">{{ forloop.counter }}</button>{% endfor %}</nav>
|
||||||
|
<div class="reader-review-pager"><button id="reader-previous" type="button"><i class="bi bi-arrow-left"></i> Previous</button><button id="reader-next" type="button">Next <i class="bi bi-arrow-right"></i></button></div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
<script src="{% static 'js/reading-review.js' %}?v=20260727.1"></script>
|
||||||
|
{% else %}
|
||||||
|
<section class="results-page assessment-page assessment-page--review">
|
||||||
|
<header class="assessment-bar assessment-bar--results">
|
||||||
|
<div class="assessment-bar__title"><a class="assessment-bar__back" href="{% url 'student_dashboard' %}" aria-label="Back to dashboard"><i class="bi bi-arrow-left"></i></a><span class="assessment-bar__icon"><i class="bi bi-clipboard-check"></i></span><div><span>Completed test · Review</span><h1>{{ attempt.exam_set.title }}</h1><p>Review your responses and grading status.</p></div></div>
|
||||||
|
<div class="assessment-bar__summary"><span>{{ answers|length }} responses</span>{% if pending_manual %}<span>{{ pending_manual }} awaiting review</span>{% endif %}</div>
|
||||||
|
<form class="assessment-bar__action" method="post" action="{% url 'start_exam' attempt.exam_set.id %}">{% csrf_token %}<button type="submit"><i class="bi bi-arrow-repeat"></i> Retake test</button></form>
|
||||||
|
</header>
|
||||||
|
<div class="results-shell">
|
||||||
|
<header class="results-header"><span>Performance summary</span><h2>Your test results</h2><p>Objective answers are scored immediately. Instructor-reviewed responses update after grading.</p></header>
|
||||||
|
<div class="results-summary">
|
||||||
|
<article><span class="result-summary-icon result-summary-icon--blue"><i class="bi bi-bullseye"></i></span><div><span>Objective score</span><strong>{% if total_gradable %}{{ correct_count }} / {{ total_gradable }}{% else %}Not applicable{% endif %}</strong></div></article>
|
||||||
|
<article><span class="result-summary-icon result-summary-icon--green"><i class="bi bi-award"></i></span><div><span>Manual band average</span><strong>{% if average_manual_score is not None %}{{ average_manual_score|floatformat:1 }} / 9{% else %}Awaiting review{% endif %}</strong></div></article>
|
||||||
|
<article><span class="result-summary-icon result-summary-icon--orange"><i class="bi bi-hourglass-split"></i></span><div><span>Awaiting review</span><strong>{{ pending_manual }}</strong></div></article>
|
||||||
|
</div>
|
||||||
|
<section class="answer-review"><div class="answer-review-heading"><span>Answer review</span><h2>Your responses</h2></div>
|
||||||
|
{% for answer in answers %}<article class="answer-card"><div class="answer-number">{{ answer.question.order }}</div><div class="answer-body"><div class="rich-question-prompt review-question-prompt">{{ answer.question.prompt|render_rich_text }}</div><div class="answer-response"><span>Your answer</span><p>{{ answer.answer_text|default:"No typed answer submitted"|linebreaksbr }}</p>{% if answer.audio_response %}<audio controls preload="metadata" src="{{ answer.audio_response.url }}">Your browser does not support audio playback.</audio>{% endif %}</div>{% if answer.is_correct is not None %}{% if answer.is_correct %}<span class="answer-status answer-status--correct"><i class="bi bi-check-circle"></i> Correct</span>{% else %}<span class="answer-status answer-status--wrong"><i class="bi bi-x-circle"></i> Incorrect</span>{% endif %}{% elif answer.manual_score is not None %}<span class="answer-status answer-status--graded"><i class="bi bi-award"></i> Band {{ answer.manual_score|floatformat:1 }}</span>{% else %}<span class="answer-status answer-status--pending"><i class="bi bi-hourglass-split"></i> Pending instructor review</span>{% endif %}</div></article>{% empty %}<div class="results-empty">No answers were submitted for this test.</div>{% endfor %}
|
||||||
|
</section>
|
||||||
|
<div class="results-actions"><a href="{% url 'exam_list' %}">Browse more tests</a><a class="results-primary" href="{% url 'student_dashboard' %}">View dashboard</a></div>
|
||||||
|
</div></section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static exam_content %}
|
||||||
|
{% block title %}{{ section.get_section_type_display }} | {{ section.exam_set.title }}{% endblock %}
|
||||||
|
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-session.css' %}?v=20260717.4"><link rel="stylesheet" href="{% static 'css/exam-retake.css' %}?v=20260717.1"><link rel="stylesheet" href="{% static 'css/speaking-recorder.css' %}?v=20260718.1"><link rel="stylesheet" href="{% static 'css/exam-layout-polish.css' %}?v=20260719.5"><link rel="stylesheet" href="{% static 'css/grouped-questions.css' %}?v=20260719.1"><link rel="stylesheet" href="{% static 'css/listening-worksheet.css' %}?v=20260726.2"><link rel="stylesheet" href="{% static 'css/selection-annotations.css' %}?v=20260719.2"><link rel="stylesheet" href="{% static 'css/exam-content-typography.css' %}?v=20260719.7"><link rel="stylesheet" href="{% static 'css/ielts-question-paper.css' %}?v=20260726.1"><link rel="stylesheet" href="{% static 'css/listening-real-exam.css' %}?v=20260726.1"><link rel="stylesheet" href="{% static 'css/assessment-workspace.css' %}?v=20260727.2">{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section id="exam-session" class="exam-session assessment-page assessment-page--test{% if section.section_type == 'reading' and section.passage_text %} exam-session--reading{% endif %}{% if section.section_type == 'listening' %} exam-session--listening{% endif %}">
|
||||||
|
<header class="exam-session-header assessment-bar">
|
||||||
|
<div class="exam-session-title assessment-bar__title">
|
||||||
|
<a class="assessment-bar__back" href="{% url 'exam_list' %}" aria-label="Leave test"><i class="bi bi-arrow-left"></i></a>
|
||||||
|
<span class="assessment-bar__icon"><i class="bi {% if section.section_type == 'reading' %}bi-book{% elif section.section_type == 'listening' %}bi-headphones{% elif section.section_type == 'writing' %}bi-pencil{% else %}bi-mic{% endif %}"></i></span>
|
||||||
|
<div><span>Section {{ section_number }} of {{ section_total }} · Live test</span><h1>{{ section.get_section_type_display }}</h1><p>{{ section.exam_set.title }}</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="exam-session-controls">
|
||||||
|
<button id="fullscreen-toggle" class="exam-fullscreen-button" type="button" aria-label="Enter fullscreen"><i class="bi bi-arrows-fullscreen"></i><span>Fullscreen</span></button>
|
||||||
|
<div class="exam-timer-wrap"><span>Time remaining</span><strong id="timer" data-seconds="{{ remaining_seconds }}" role="timer" aria-live="off">--:--</strong></div>
|
||||||
|
<form method="post" action="{% url 'retake_exam' attempt.id %}" onsubmit="return confirm('Start this test again? Your unfinished answers in this attempt will be removed.');">{% csrf_token %}<button class="exam-retake-button" type="submit"><i class="bi bi-arrow-repeat"></i><span>Retake</span></button></form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="exam-form" method="post" enctype="multipart/form-data" class="exam-session-form" autocomplete="off">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if section.section_type == 'listening' %}<section class="listening-part-banner" aria-label="Listening section instructions"><strong>Part {{ section.order }}</strong><span>Listen to the audio and answer questions {{ first_question_order }}{% if first_question_order != last_question_order %}–{{ last_question_order }}{% endif %}.</span></section>{% endif %}
|
||||||
|
{% if section.section_type == 'reading' or section.section_type == 'listening' %}
|
||||||
|
<div id="passage-selection-toolbar" class="passage-selection-toolbar" role="toolbar" aria-label="Selected text tools" hidden>
|
||||||
|
<button id="passage-highlight" type="button" title="Highlight selected text"><i class="bi bi-highlighter"></i><span>Highlight</span></button>
|
||||||
|
<button id="passage-add-note" type="button" title="Add a note to selected text"><i class="bi bi-sticky"></i><span>Note</span></button>
|
||||||
|
<button id="passage-clear" type="button" title="Clear all highlights and notes"><i class="bi bi-eraser"></i><span>Clear</span></button>
|
||||||
|
</div>
|
||||||
|
<div id="passage-note-editor" class="passage-note-editor" hidden>
|
||||||
|
<strong>Add note</strong><p id="passage-note-quote"></p><label class="visually-hidden" for="passage-note-text">Your note</label><textarea id="passage-note-text" rows="3" maxlength="500" placeholder="Write a short note about this text..."></textarea><div><button id="passage-note-cancel" type="button">Cancel</button><button id="passage-note-save" type="button">Save note</button></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="exam-workspace{% if section.section_type == 'reading' and section.passage_text %} exam-workspace--split{% else %} exam-workspace--wide{% endif %}{% if section.section_type == 'listening' %} exam-workspace--listening{% endif %}" id="exam-workspace">
|
||||||
|
{% if section.section_type == 'reading' and section.passage_text %}
|
||||||
|
<aside class="exam-passage" aria-label="Reading passage">
|
||||||
|
<div class="exam-pane-heading exam-pane-heading--title"><span>{{ section.exam_set.title }}</span></div>
|
||||||
|
<div class="exam-passage-text rich-passage-content">{{ section.passage_text|render_rich_text }}</div>
|
||||||
|
</aside>
|
||||||
|
<div id="exam-resizer" class="exam-resizer" role="separator" aria-label="Resize passage and questions" aria-orientation="vertical" tabindex="0"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="exam-questions" id="question-panel">
|
||||||
|
{% if section.audio_file %}<div class="exam-audio"><div><i class="bi bi-headphones"></i><span><strong>Listening audio</strong><small>Use headphones in a quiet place.</small></span></div><audio controls preload="metadata" src="{{ section.audio_file.url }}">Your browser does not support audio playback.</audio></div>{% endif %}
|
||||||
|
{% for block in question_blocks %}
|
||||||
|
{% if block.kind == 'group' %}
|
||||||
|
{{ block.group|render_question_group }}
|
||||||
|
{% else %}
|
||||||
|
{% with question=block.question %}
|
||||||
|
<fieldset class="exam-question-card" id="question-{{ question.id }}" data-question-id="{{ question.id }}" data-question-order="{{ question.order }}" tabindex="-1">
|
||||||
|
<legend><span>{{ question.order }}</span><span class="rich-question-prompt">{{ question.prompt|render_rich_text }}</span></legend>
|
||||||
|
{% if question.question_type == 'mcq' %}
|
||||||
|
<div class="exam-options">{% for option in question.options %}<label><input type="radio" name="q{{ question.id }}" value="{{ option }}"><span>{{ option }}</span></label>{% endfor %}</div>
|
||||||
|
{% elif question.question_type == 'matching' and question.options %}
|
||||||
|
<label class="visually-hidden" for="q{{ question.id }}">Answer for question {{ question.order }}</label><select id="q{{ question.id }}" name="q{{ question.id }}" class="exam-text-input"><option value="">Select an option</option>{% for option in question.options %}<option value="{{ option }}">{{ option }}</option>{% endfor %}</select>
|
||||||
|
{% elif question.question_type == 'gap' or question.question_type == 'matching' %}
|
||||||
|
<label class="visually-hidden" for="q{{ question.id }}">Answer for question {{ question.order }}</label><input id="q{{ question.id }}" type="text" name="q{{ question.id }}" class="exam-text-input" spellcheck="false">
|
||||||
|
{% elif question.question_type == 'speaking' %}
|
||||||
|
<label class="visually-hidden" for="q{{ question.id }}">Notes for question {{ question.order }}</label><textarea id="q{{ question.id }}" name="q{{ question.id }}" rows="5" class="exam-textarea" placeholder="Optional notes or typed response..."></textarea>
|
||||||
|
<div class="speaking-upload" data-speaking-recorder><label for="q{{ question.id }}_audio"><i class="bi bi-mic"></i><span><strong>Record or upload your response</strong><small>Record in your browser, or upload MP3, M4A, WAV, WebM, or OGG · maximum 20 MB</small></span></label><div class="speaking-recorder"><button type="button" class="speaking-record-button" aria-pressed="false" aria-label="Start recording"><i class="bi bi-mic-fill"></i></button><div class="speaking-recorder__center"><div class="speaking-waveform" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><span class="speaking-record-status" role="status" aria-live="polite">Tap the microphone to record</span></div><time class="speaking-record-time" aria-label="Recording duration">00:00</time></div><audio class="speaking-record-preview" controls hidden></audio><div class="speaking-upload__divider"><span>or upload a file</span></div><input id="q{{ question.id }}_audio" type="file" name="q{{ question.id }}_audio" accept="audio/mpeg,audio/mp4,audio/wav,audio/webm,audio/ogg,.m4a"></div>
|
||||||
|
{% else %}
|
||||||
|
<label class="visually-hidden" for="q{{ question.id }}">Response for question {{ question.order }}</label><textarea id="q{{ question.id }}" name="q{{ question.id }}" rows="8" class="exam-textarea" placeholder="Write your response here..."></textarea>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if section.section_type != 'reading' and section.section_type != 'listening' %}<div class="exam-submit-area"><p><i class="bi bi-shield-check"></i> Your answers are submitted securely when you finish this section.</p><button id="submit-section" type="submit">Submit section <i class="bi bi-arrow-right"></i></button></div>{% endif %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if section.section_type == 'reading' or section.section_type == 'listening' %}
|
||||||
|
<footer class="exam-question-nav" aria-label="Question navigation">
|
||||||
|
<div class="exam-question-numbers">
|
||||||
|
{% for question in section.questions.all %}<button type="button" class="exam-question-number{% if forloop.first %} is-current{% endif %}" data-target="question-{{ question.id }}" aria-label="Go to question {{ question.order }}">{{ question.order }}</button>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="exam-question-actions">
|
||||||
|
<button id="question-prev" type="button" class="exam-nav-button" disabled><i class="bi bi-arrow-left"></i><span>Previous</span></button>
|
||||||
|
<button id="question-next" type="button" class="exam-nav-button"><span>Next</span><i class="bi bi-arrow-right"></i></button>
|
||||||
|
<button id="submit-section" type="submit" class="exam-bottom-submit">Submit section <i class="bi bi-send"></i></button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const session = document.getElementById('exam-session');
|
||||||
|
const form = document.getElementById('exam-form');
|
||||||
|
const submitButton = document.getElementById('submit-section');
|
||||||
|
const timer = document.getElementById('timer');
|
||||||
|
let remaining = Math.max(0, Number(timer.dataset.seconds) || 0);
|
||||||
|
let submitted = false;
|
||||||
|
|
||||||
|
const renderTimer = () => {
|
||||||
|
const minutes = Math.floor(remaining / 60);
|
||||||
|
const seconds = remaining % 60;
|
||||||
|
timer.textContent = `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
timer.classList.toggle('is-warning', remaining <= 300 && remaining > 0);
|
||||||
|
};
|
||||||
|
const submitAutomatically = () => {
|
||||||
|
if (submitted) return;
|
||||||
|
submitted = true;
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = 'Submitting...';
|
||||||
|
form.requestSubmit();
|
||||||
|
};
|
||||||
|
renderTimer();
|
||||||
|
if (remaining === 0) submitAutomatically();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
remaining = Math.max(0, remaining - 1);
|
||||||
|
renderTimer();
|
||||||
|
if (remaining === 0) {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
submitAutomatically();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
if (!submitted && !window.confirm('Submit this section? You cannot return to change these answers.')) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitted = true;
|
||||||
|
submitButton.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberButtons = [...document.querySelectorAll('.exam-question-number')];
|
||||||
|
const cards = numberButtons.map((button) => document.getElementById(button.dataset.target));
|
||||||
|
const questionPanel = document.getElementById('question-panel');
|
||||||
|
const previousButton = document.getElementById('question-prev');
|
||||||
|
const nextButton = document.getElementById('question-next');
|
||||||
|
let currentIndex = 0;
|
||||||
|
|
||||||
|
const setCurrent = (index) => {
|
||||||
|
if (!numberButtons.length) return;
|
||||||
|
currentIndex = Math.max(0, Math.min(index, numberButtons.length - 1));
|
||||||
|
numberButtons.forEach((button, buttonIndex) => button.classList.toggle('is-current', buttonIndex === currentIndex));
|
||||||
|
previousButton.disabled = currentIndex === 0;
|
||||||
|
nextButton.disabled = currentIndex === numberButtons.length - 1;
|
||||||
|
};
|
||||||
|
const goToQuestion = (index) => {
|
||||||
|
setCurrent(index);
|
||||||
|
cards[currentIndex].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
cards[currentIndex].focus({preventScroll: true});
|
||||||
|
};
|
||||||
|
const updateAnswered = (card, index) => {
|
||||||
|
const radioAnswered = Boolean(card.querySelector('input[type="radio"]:checked'));
|
||||||
|
const textAnswered = [...card.querySelectorAll('input[type="text"], textarea, select')].some((input) => input.value.trim());
|
||||||
|
const fileAnswered = [...card.querySelectorAll('input[type="file"]')].some((input) => input.files.length);
|
||||||
|
const answered = radioAnswered || textAnswered || fileAnswered;
|
||||||
|
numberButtons[index].classList.toggle('is-answered', answered);
|
||||||
|
card.classList.toggle('is-answered', answered);
|
||||||
|
};
|
||||||
|
numberButtons.forEach((button, index) => button.addEventListener('click', () => goToQuestion(index)));
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
card.querySelectorAll('input, textarea, select').forEach((input) => {
|
||||||
|
input.addEventListener('input', () => updateAnswered(card, index));
|
||||||
|
input.addEventListener('change', () => updateAnswered(card, index));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (previousButton) previousButton.addEventListener('click', () => goToQuestion(currentIndex - 1));
|
||||||
|
if (nextButton) nextButton.addEventListener('click', () => goToQuestion(currentIndex + 1));
|
||||||
|
if (numberButtons.length && 'IntersectionObserver' in window) {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||||
|
if (visible) setCurrent(cards.indexOf(visible.target));
|
||||||
|
}, {root: questionPanel, threshold: [0.45, 0.7]});
|
||||||
|
cards.forEach((card) => observer.observe(card));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenButton = document.getElementById('fullscreen-toggle');
|
||||||
|
if (fullscreenButton) {
|
||||||
|
fullscreenButton.addEventListener('click', async () => {
|
||||||
|
if (!document.fullscreenElement) await session.requestFullscreen();
|
||||||
|
else await document.exitFullscreen();
|
||||||
|
});
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const active = Boolean(document.fullscreenElement);
|
||||||
|
fullscreenButton.querySelector('i').className = active ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen';
|
||||||
|
fullscreenButton.querySelector('span').textContent = active ? 'Exit fullscreen' : 'Fullscreen';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = document.getElementById('exam-workspace');
|
||||||
|
const resizer = document.getElementById('exam-resizer');
|
||||||
|
if (resizer) {
|
||||||
|
let resizing = false;
|
||||||
|
resizer.addEventListener('pointerdown', (event) => {
|
||||||
|
resizing = true;
|
||||||
|
resizer.setPointerCapture(event.pointerId);
|
||||||
|
document.body.classList.add('is-resizing-exam');
|
||||||
|
});
|
||||||
|
resizer.addEventListener('pointermove', (event) => {
|
||||||
|
if (!resizing) return;
|
||||||
|
const bounds = workspace.getBoundingClientRect();
|
||||||
|
const percentage = ((event.clientX - bounds.left) / bounds.width) * 100;
|
||||||
|
workspace.style.setProperty('--passage-width', `${Math.max(30, Math.min(70, percentage))}%`);
|
||||||
|
});
|
||||||
|
const stopResize = () => {
|
||||||
|
resizing = false;
|
||||||
|
document.body.classList.remove('is-resizing-exam');
|
||||||
|
};
|
||||||
|
resizer.addEventListener('pointerup', stopResize);
|
||||||
|
resizer.addEventListener('pointercancel', stopResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotationAreas = [...document.querySelectorAll('.exam-passage-text, .exam-questions')];
|
||||||
|
const selectionToolbar = document.getElementById('passage-selection-toolbar');
|
||||||
|
const highlightButton = document.getElementById('passage-highlight');
|
||||||
|
const addNoteButton = document.getElementById('passage-add-note');
|
||||||
|
const clearPassageButton = document.getElementById('passage-clear');
|
||||||
|
const noteEditor = document.getElementById('passage-note-editor');
|
||||||
|
const noteQuote = document.getElementById('passage-note-quote');
|
||||||
|
const noteText = document.getElementById('passage-note-text');
|
||||||
|
const noteSaveButton = document.getElementById('passage-note-save');
|
||||||
|
const noteCancelButton = document.getElementById('passage-note-cancel');
|
||||||
|
let selectedPassageRange = null;
|
||||||
|
let lastPointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||||
|
const nativeHighlightsSupported = Boolean(window.CSS?.highlights && window.Highlight);
|
||||||
|
const nativePassageHighlights = nativeHighlightsSupported ? new Highlight() : null;
|
||||||
|
if (nativePassageHighlights) CSS.highlights.set('ielts-passage-highlight', nativePassageHighlights);
|
||||||
|
|
||||||
|
const selectionIsInAnnotationArea = (range) => {
|
||||||
|
if (!annotationAreas.length || !range) return false;
|
||||||
|
const container = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
||||||
|
? range.commonAncestorContainer.parentElement
|
||||||
|
: range.commonAncestorContainer;
|
||||||
|
return annotationAreas.some((area) => area.contains(container));
|
||||||
|
};
|
||||||
|
const hideSelectionToolbar = () => {
|
||||||
|
if (selectionToolbar) selectionToolbar.hidden = true;
|
||||||
|
};
|
||||||
|
const showSelectionToolbar = (event = null) => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
hideSelectionToolbar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!selection.toString().trim() || !selectionIsInAnnotationArea(range)) {
|
||||||
|
hideSelectionToolbar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedPassageRange = range.cloneRange();
|
||||||
|
const selectionBox = range.getBoundingClientRect();
|
||||||
|
if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
|
||||||
|
lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
|
} else if (selectionBox.width || selectionBox.height) {
|
||||||
|
lastPointer = { x: selectionBox.right, y: selectionBox.bottom };
|
||||||
|
}
|
||||||
|
const toolbarWidth = 166;
|
||||||
|
selectionToolbar.style.left = `${Math.max(10, Math.min(window.innerWidth - toolbarWidth - 10, lastPointer.x + 12))}px`;
|
||||||
|
selectionToolbar.style.top = `${Math.max(10, Math.min(window.innerHeight - 48, lastPointer.y + 12))}px`;
|
||||||
|
selectionToolbar.hidden = false;
|
||||||
|
};
|
||||||
|
const addHighlight = (range, note = '') => {
|
||||||
|
if (!range || range.collapsed) return null;
|
||||||
|
if (nativePassageHighlights) {
|
||||||
|
const savedRange = range.cloneRange();
|
||||||
|
nativePassageHighlights.add(savedRange);
|
||||||
|
return savedRange;
|
||||||
|
}
|
||||||
|
const mark = document.createElement('mark');
|
||||||
|
mark.className = 'exam-highlight';
|
||||||
|
if (note) {
|
||||||
|
mark.dataset.note = 'true';
|
||||||
|
mark.title = note;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
range.surroundContents(mark);
|
||||||
|
} catch (error) {
|
||||||
|
const contents = range.extractContents();
|
||||||
|
mark.appendChild(contents);
|
||||||
|
range.insertNode(mark);
|
||||||
|
}
|
||||||
|
return mark;
|
||||||
|
};
|
||||||
|
const clearPassageAnnotations = () => {
|
||||||
|
if (nativePassageHighlights) nativePassageHighlights.clear();
|
||||||
|
annotationAreas.forEach((area) => {
|
||||||
|
area.querySelectorAll('.exam-highlight').forEach((mark) => mark.replaceWith(...mark.childNodes));
|
||||||
|
});
|
||||||
|
noteEditor.hidden = true;
|
||||||
|
hideSelectionToolbar();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectionToolbar && annotationAreas.length) {
|
||||||
|
annotationAreas.forEach((area) => area.addEventListener('pointermove', (event) => { lastPointer = { x: event.clientX, y: event.clientY }; }));
|
||||||
|
const capturePassageSelection = (event) => {
|
||||||
|
if (event) lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
|
window.setTimeout(() => showSelectionToolbar(event), 0);
|
||||||
|
};
|
||||||
|
document.addEventListener('mouseup', capturePassageSelection);
|
||||||
|
document.addEventListener('touchend', capturePassageSelection, { passive: true });
|
||||||
|
document.addEventListener('keyup', capturePassageSelection);
|
||||||
|
document.addEventListener('selectionchange', () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !selection.toString().trim()) {
|
||||||
|
hideSelectionToolbar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (selectionIsInAnnotationArea(range)) {
|
||||||
|
window.requestAnimationFrame(() => showSelectionToolbar());
|
||||||
|
} else {
|
||||||
|
hideSelectionToolbar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('scroll', hideSelectionToolbar, true);
|
||||||
|
selectionToolbar.addEventListener('mousedown', (event) => event.preventDefault());
|
||||||
|
highlightButton.addEventListener('click', () => {
|
||||||
|
addHighlight(selectedPassageRange);
|
||||||
|
hideSelectionToolbar();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
});
|
||||||
|
addNoteButton.addEventListener('click', () => {
|
||||||
|
if (!selectedPassageRange) return;
|
||||||
|
noteQuote.textContent = `“${selectedPassageRange.toString().trim().slice(0, 160)}”`;
|
||||||
|
noteText.value = '';
|
||||||
|
noteEditor.style.left = `${Math.max(10, Math.min(window.innerWidth - 310, lastPointer.x + 12))}px`;
|
||||||
|
noteEditor.style.top = `${Math.max(10, Math.min(window.innerHeight - 230, lastPointer.y + 16))}px`;
|
||||||
|
noteEditor.hidden = false;
|
||||||
|
hideSelectionToolbar();
|
||||||
|
noteText.focus();
|
||||||
|
});
|
||||||
|
noteCancelButton.addEventListener('click', () => {
|
||||||
|
noteEditor.hidden = true;
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
});
|
||||||
|
noteSaveButton.addEventListener('click', () => {
|
||||||
|
const text = noteText.value.trim();
|
||||||
|
if (!text || !selectedPassageRange) return;
|
||||||
|
addHighlight(selectedPassageRange, text);
|
||||||
|
noteEditor.hidden = true;
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
});
|
||||||
|
clearPassageButton.addEventListener('click', clearPassageAnnotations);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-speaking-recorder]').forEach((container) => {
|
||||||
|
const fileInput = container.querySelector('input[type="file"]');
|
||||||
|
const recordButton = container.querySelector('.speaking-record-button');
|
||||||
|
const status = container.querySelector('.speaking-record-status');
|
||||||
|
const recordTime = container.querySelector('.speaking-record-time');
|
||||||
|
const preview = container.querySelector('.speaking-record-preview');
|
||||||
|
let recorder = null;
|
||||||
|
let stream = null;
|
||||||
|
let chunks = [];
|
||||||
|
let previewUrl = null;
|
||||||
|
let timerInterval = null;
|
||||||
|
let recordingStartedAt = null;
|
||||||
|
|
||||||
|
const setStatus = (message, isRecording = false) => {
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.toggle('is-recording', isRecording);
|
||||||
|
};
|
||||||
|
const stopTracks = () => {
|
||||||
|
if (stream) stream.getTracks().forEach((track) => track.stop());
|
||||||
|
stream = null;
|
||||||
|
};
|
||||||
|
const renderRecordingTime = () => {
|
||||||
|
const elapsed = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
||||||
|
recordTime.textContent = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const stopTimer = () => {
|
||||||
|
if (timerInterval) window.clearInterval(timerInterval);
|
||||||
|
timerInterval = null;
|
||||||
|
};
|
||||||
|
const finishRecording = () => {
|
||||||
|
const type = recorder?.mimeType || 'audio/webm';
|
||||||
|
const recording = new File([new Blob(chunks, { type })], `speaking-response-${Date.now()}.webm`, { type: 'audio/webm' });
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(recording);
|
||||||
|
fileInput.files = transfer.files;
|
||||||
|
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = URL.createObjectURL(recording);
|
||||||
|
preview.src = previewUrl;
|
||||||
|
preview.hidden = false;
|
||||||
|
recordButton.classList.remove('is-recording');
|
||||||
|
recordButton.setAttribute('aria-pressed', 'false');
|
||||||
|
recordButton.setAttribute('aria-label', 'Record again');
|
||||||
|
setStatus('Recording ready to submit');
|
||||||
|
stopTimer();
|
||||||
|
stopTracks();
|
||||||
|
recorder = null;
|
||||||
|
chunks = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||||
|
recordButton.disabled = true;
|
||||||
|
recordButton.title = 'Your browser does not support audio recording.';
|
||||||
|
setStatus('Recording is not supported in this browser. You can still upload audio.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordButton.addEventListener('click', async () => {
|
||||||
|
if (recorder?.state === 'recording') {
|
||||||
|
recorder.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
chunks = [];
|
||||||
|
recorder = new MediaRecorder(stream);
|
||||||
|
recorder.addEventListener('dataavailable', (event) => {
|
||||||
|
if (event.data.size) chunks.push(event.data);
|
||||||
|
});
|
||||||
|
recorder.addEventListener('stop', finishRecording, { once: true });
|
||||||
|
recorder.start();
|
||||||
|
recordButton.classList.add('is-recording');
|
||||||
|
recordButton.setAttribute('aria-pressed', 'true');
|
||||||
|
recordButton.setAttribute('aria-label', 'Stop recording');
|
||||||
|
setStatus('Recording… click Stop when you are finished.', true);
|
||||||
|
recordingStartedAt = Date.now();
|
||||||
|
renderRecordingTime();
|
||||||
|
timerInterval = window.setInterval(renderRecordingTime, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
stopTracks();
|
||||||
|
stopTimer();
|
||||||
|
setStatus('Microphone access was not granted. You can still upload an audio file.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
if (!fileInput.files.length) return;
|
||||||
|
if (recorder?.state === 'recording') recorder.stop();
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = URL.createObjectURL(fileInput.files[0]);
|
||||||
|
preview.src = previewUrl;
|
||||||
|
preview.hidden = false;
|
||||||
|
recordButton.setAttribute('aria-label', 'Record again');
|
||||||
|
setStatus('Audio file ready to submit');
|
||||||
|
stopTimer();
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (recorder?.state !== 'recording') return;
|
||||||
|
event.preventDefault();
|
||||||
|
recorder.stop();
|
||||||
|
setStatus('Recording saved. Submit the section again when ready.');
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
from django import template
|
||||||
|
from django.utils.html import escape, linebreaks
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from exams.forms import RICH_TEXT_ATTRIBUTES, RICH_TEXT_TAGS
|
||||||
|
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
RICH_TAG_PATTERN = re.compile(r"</?(?:p|br|strong|em|u|h2|h3|ul|ol|li|blockquote|a)\b", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def render_rich_text(value):
|
||||||
|
"""Render sanitized admin formatting while preserving legacy plain-text passages."""
|
||||||
|
value = value or ""
|
||||||
|
if not RICH_TAG_PATTERN.search(value):
|
||||||
|
# django.utils.html.linebreaks returns generated HTML, but a custom
|
||||||
|
# template filter must explicitly mark that generated markup safe or
|
||||||
|
# Django will display the <p> tags as literal text.
|
||||||
|
return mark_safe(linebreaks(value))
|
||||||
|
cleaned = bleach.clean(
|
||||||
|
value,
|
||||||
|
tags=RICH_TEXT_TAGS,
|
||||||
|
attributes=RICH_TEXT_ATTRIBUTES,
|
||||||
|
protocols=["http", "https", "mailto"],
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
return mark_safe(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_TAGS = ["p", "br", "strong", "em", "u", "h2", "h3", "h4", "ul", "ol", "li", "blockquote", "a", "div", "section", "table", "thead", "tbody", "tr", "th", "td", "caption", "span"]
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def render_question_group(group):
|
||||||
|
"""Render sanitized worksheet HTML and replace [[order]] tokens with real form controls."""
|
||||||
|
questions = list(group.questions.all())
|
||||||
|
cleaned = bleach.clean(group.layout_html or "", tags=GROUP_TAGS, attributes={}, strip=True)
|
||||||
|
for question in questions:
|
||||||
|
control_id = f"q{question.id}"
|
||||||
|
if question.question_type == "matching" and question.options:
|
||||||
|
options = ['<option value="">Select an option</option>'] + [
|
||||||
|
f'<option value="{escape(option)}">{escape(option)}</option>' for option in question.options
|
||||||
|
]
|
||||||
|
control = f'<span class="exam-inline-question" id="question-{question.id}" data-question-id="{question.id}" data-question-order="{question.order}" tabindex="-1"><span class="exam-inline-number">{question.order}</span><label class="visually-hidden" for="{control_id}">Answer for question {question.order}</label><select id="{control_id}" name="q{question.id}" class="exam-inline-input">{"".join(options)}</select></span>'
|
||||||
|
else:
|
||||||
|
control = f'<span class="exam-inline-question" id="question-{question.id}" data-question-id="{question.id}" data-question-order="{question.order}" tabindex="-1"><span class="exam-inline-number">{question.order}</span><label class="visually-hidden" for="{control_id}">Answer for question {question.order}</label><input id="{control_id}" name="q{question.id}" class="exam-inline-input" type="text" placeholder="{question.order}" spellcheck="false"></span>'
|
||||||
|
cleaned = cleaned.replace(f"[[{question.order}]]", control)
|
||||||
|
first = questions[0].order if questions else ""
|
||||||
|
last = questions[-1].order if questions else ""
|
||||||
|
heading = f"Questions {first}–{last}" if first != last else f"Question {first}"
|
||||||
|
instructions = bleach.clean(group.instructions or "", tags=["strong", "em", "br", "p"], strip=True)
|
||||||
|
return mark_safe(f'<article class="exam-question-group" data-layout="{escape(group.layout_type)}"><div class="exam-group-instructions"><h2>{heading}</h2>{instructions}</div><div class="exam-group-sheet">{cleaned}</div></article>')
|
||||||
+837
@@ -0,0 +1,837 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.template import Context, Template
|
||||||
|
|
||||||
|
from accounts.models import PremiumEntitlement
|
||||||
|
from .models import ExamSet, Question, QuestionGroup, Section, StudentAnswer, StudentAttempt
|
||||||
|
from .html_importer import create_exam_from_payload, parse_ielts_html
|
||||||
|
from .excel_importer import build_excel_template, parse_excel_test
|
||||||
|
from .views import SUBMISSION_GRACE_SECONDS
|
||||||
|
from .grading import academic_reading_band
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT_HTML = b'''<!doctype html><html><body>
|
||||||
|
<div class="passage-content"><h2>Music history</h2><p>This is the reading passage.</p></div>
|
||||||
|
<div class="question-group"><p>Choose the correct answer.</p>
|
||||||
|
<div class="question" data-question="1"><div class="question-text">1. Which option is correct?</div><label><input type="radio" name="q1" value="A"> A. First choice</label><label><input type="radio" name="q1" value="B"> B. Second choice</label></div>
|
||||||
|
<div class="question" data-question="2"><div class="question-text">2. Complete this ____ <input type="text" name="q2">.</div></div>
|
||||||
|
</div><script>const correctAnswers = {q1: "B", q2: "answer"};</script></body></html>'''
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlImportTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.admin_user = User.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parser_extracts_real_question_types_and_answers(self):
|
||||||
|
section = parse_ielts_html(IMPORT_HTML, "reading.html")
|
||||||
|
|
||||||
|
self.assertEqual(section.passage_text, "Music history\n\nThis is the reading passage.")
|
||||||
|
self.assertEqual(len(section.questions), 2)
|
||||||
|
self.assertEqual(section.questions[0].question_type, "mcq")
|
||||||
|
self.assertEqual(section.questions[0].options, ["A. First choice", "B. Second choice"])
|
||||||
|
self.assertEqual(section.questions[0].correct_answer, "B. Second choice")
|
||||||
|
self.assertEqual(section.questions[1].question_type, "gap")
|
||||||
|
self.assertEqual(section.questions[1].correct_answer, "answer")
|
||||||
|
|
||||||
|
def test_listening_import_does_not_require_a_reading_passage(self):
|
||||||
|
listening_html = b'''<div class="question" data-question="1"><div class="question-text">1. Choose one.</div><label><input type="radio" value="A"> A</label><label><input type="radio" value="B"> B</label></div><script>const correctAnswers={q1:"B"};</script>'''
|
||||||
|
section = parse_ielts_html(listening_html, "listening.html", section_type="listening")
|
||||||
|
exam = create_exam_from_payload({"title":"Listening import", "description":"", "category":"listening", "section_type":"listening", "time_limit_minutes":10, "publish":False, "sections":[section.as_payload()]})
|
||||||
|
|
||||||
|
self.assertEqual(exam.sections.get().section_type, "listening")
|
||||||
|
self.assertEqual(exam.sections.get().questions.get().correct_answer, "B")
|
||||||
|
|
||||||
|
def test_payload_creates_a_publishable_exam(self):
|
||||||
|
section = parse_ielts_html(IMPORT_HTML, "reading.html")
|
||||||
|
exam = create_exam_from_payload(
|
||||||
|
{
|
||||||
|
"title": "Imported reading test",
|
||||||
|
"description": "Imported from HTML",
|
||||||
|
"category": "reading",
|
||||||
|
"section_type": "reading",
|
||||||
|
"time_limit_minutes": 20,
|
||||||
|
"publish": True,
|
||||||
|
"sections": [section.as_payload()],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(exam.is_published)
|
||||||
|
self.assertEqual(exam.sections.count(), 1)
|
||||||
|
self.assertEqual(exam.sections.get().questions.count(), 2)
|
||||||
|
|
||||||
|
def test_admin_home_uses_clean_unfold_dashboard(self):
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("admin:index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Testpoint")
|
||||||
|
self.assertContains(response, "Tests")
|
||||||
|
self.assertContains(response, "/static/unfold/css/styles.css")
|
||||||
|
|
||||||
|
def test_category_quick_create_prefills_the_exam_category(self):
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("admin:exams_examset_add") + "?category=listening")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, '<option value="listening" selected>', html=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ExactHtmlExamTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.student = User.objects.create_user(username="exact-student", password="test-pass-123")
|
||||||
|
self.exam = ExamSet.objects.create(
|
||||||
|
title="Original HTML test",
|
||||||
|
category="reading",
|
||||||
|
is_published=True,
|
||||||
|
delivery_mode="exact_html",
|
||||||
|
source_html="<!doctype html><html><body><h1 id='kept'>Keep me exact</h1></body></html>",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.student)
|
||||||
|
|
||||||
|
def test_exact_test_opens_in_the_dedicated_viewer_and_serves_raw_html(self):
|
||||||
|
start = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
self.assertRedirects(start, reverse("exact_exam", args=[attempt.id]), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
content = self.client.get(reverse("exact_exam_content", args=[attempt.id]))
|
||||||
|
self.assertEqual(content.status_code, 200)
|
||||||
|
self.assertEqual(content.content.decode(), self.exam.source_html)
|
||||||
|
self.assertIn("sandbox", content.headers["Content-Security-Policy"])
|
||||||
|
|
||||||
|
def test_exact_test_can_be_marked_complete(self):
|
||||||
|
self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("complete_exact_exam", args=[attempt.id]))
|
||||||
|
|
||||||
|
attempt.refresh_from_db()
|
||||||
|
self.assertTrue(attempt.is_complete)
|
||||||
|
self.assertRedirects(response, reverse("exam_list"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingBandTests(TestCase):
|
||||||
|
def test_full_reading_uses_the_40_question_band_table(self):
|
||||||
|
self.assertEqual(academic_reading_band(40, 40), (9.0, 40, False))
|
||||||
|
self.assertEqual(academic_reading_band(35, 40), (8.0, 35, False))
|
||||||
|
self.assertEqual(academic_reading_band(30, 40), (7.0, 30, False))
|
||||||
|
self.assertEqual(academic_reading_band(23, 40), (6.0, 23, False))
|
||||||
|
|
||||||
|
def test_single_passage_keeps_raw_total_and_estimates_band(self):
|
||||||
|
self.assertEqual(academic_reading_band(13, 13), (9.0, 40, True))
|
||||||
|
self.assertEqual(academic_reading_band(10, 13), (7.0, 31, True))
|
||||||
|
self.assertEqual(academic_reading_band(0, 13), (0.0, 0, True))
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelImportTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.admin_user = User.objects.create_superuser(
|
||||||
|
username="excel-admin", email="excel@example.com", password="admin-pass-123"
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
def test_template_round_trip_creates_editable_native_test(self):
|
||||||
|
workbook = build_excel_template()
|
||||||
|
payload = parse_excel_test(workbook, publish=True)
|
||||||
|
|
||||||
|
exam = create_exam_from_payload(payload)
|
||||||
|
|
||||||
|
self.assertEqual(exam.delivery_mode, "native")
|
||||||
|
self.assertEqual(exam.category, "reading")
|
||||||
|
self.assertTrue(exam.is_published)
|
||||||
|
self.assertEqual(exam.sections.count(), 1)
|
||||||
|
self.assertEqual(exam.sections.get().questions.count(), 2)
|
||||||
|
self.assertEqual(exam.sections.get().questions.get(order=1).explanation, "Explain why Option B is correct.")
|
||||||
|
self.assertEqual(exam.sections.get().question_groups.count(), 1)
|
||||||
|
self.assertEqual(exam.sections.get().questions.filter(group__key="notes_1").count(), 2)
|
||||||
|
|
||||||
|
def test_group_renderer_replaces_excel_placeholders_with_question_inputs(self):
|
||||||
|
payload = parse_excel_test(build_excel_template(), publish=True)
|
||||||
|
exam = create_exam_from_payload(payload)
|
||||||
|
section = exam.sections.get()
|
||||||
|
group = section.question_groups.get(key="notes_1")
|
||||||
|
rendered = Template("{% load exam_content %}{{ group|render_question_group }}").render(Context({"group": group}))
|
||||||
|
self.assertIn('class="exam-question-group"', rendered)
|
||||||
|
self.assertIn(f'name="q{section.questions.get(order=1).id}"', rendered)
|
||||||
|
self.assertIn(f'id="question-{section.questions.get(order=2).id}"', rendered)
|
||||||
|
self.assertNotIn("[[1]]", rendered)
|
||||||
|
|
||||||
|
def test_excel_time_accepts_friendly_text_and_blank_defaults(self):
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
workbook_file = build_excel_template()
|
||||||
|
workbook = load_workbook(workbook_file)
|
||||||
|
workbook["Sections"]["C2"] = "20 minutes"
|
||||||
|
friendly = BytesIO()
|
||||||
|
workbook.save(friendly)
|
||||||
|
friendly.seek(0)
|
||||||
|
self.assertEqual(parse_excel_test(friendly)["sections"][0]["time_limit_minutes"], 20)
|
||||||
|
|
||||||
|
workbook["Sections"]["C2"] = ""
|
||||||
|
blank = BytesIO()
|
||||||
|
workbook.save(blank)
|
||||||
|
blank.seek(0)
|
||||||
|
self.assertEqual(parse_excel_test(blank)["sections"][0]["time_limit_minutes"], 60)
|
||||||
|
|
||||||
|
def test_legacy_workbook_without_groups_still_imports(self):
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
workbook = load_workbook(build_excel_template())
|
||||||
|
del workbook["Groups"]
|
||||||
|
workbook["Questions"].delete_cols(10)
|
||||||
|
legacy = BytesIO()
|
||||||
|
workbook.save(legacy)
|
||||||
|
legacy.seek(0)
|
||||||
|
payload = parse_excel_test(legacy)
|
||||||
|
self.assertNotIn("groups", payload["sections"][0])
|
||||||
|
self.assertTrue(all(not question.get("group_key") for question in payload["sections"][0]["questions"]))
|
||||||
|
|
||||||
|
def test_preserved_excel_importer_creates_a_draft_exam(self):
|
||||||
|
workbook = build_excel_template()
|
||||||
|
payload = parse_excel_test(workbook, publish=False)
|
||||||
|
exam = create_exam_from_payload(payload)
|
||||||
|
|
||||||
|
self.assertEqual(exam.title, "Academic Reading Practice 1")
|
||||||
|
self.assertFalse(exam.is_published)
|
||||||
|
|
||||||
|
def test_plain_excel_content_renders_as_paragraphs_not_literal_html_tags(self):
|
||||||
|
rendered = Template(
|
||||||
|
"{% load exam_content %}<div>{{ value|render_rich_text }}</div>"
|
||||||
|
).render(Context({"value": "First paragraph.\n\nSecond paragraph."}))
|
||||||
|
|
||||||
|
self.assertIn("<p>First paragraph.</p>", rendered)
|
||||||
|
self.assertIn("<p>Second paragraph.</p>", rendered)
|
||||||
|
self.assertNotIn("<p>", rendered)
|
||||||
|
|
||||||
|
class ExamConstraintTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.student = User.objects.create_user(username="student", password="test-pass-123")
|
||||||
|
self.exam = ExamSet.objects.create(title="IELTS Mock 1")
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=60,
|
||||||
|
passage_text="The evidence sentence is in this reading passage.",
|
||||||
|
)
|
||||||
|
self.question = Question.objects.create(
|
||||||
|
section=self.section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Complete the sentence.",
|
||||||
|
correct_answer="answer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_one_active_attempt_is_allowed_per_student_and_exam(self):
|
||||||
|
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError), transaction.atomic():
|
||||||
|
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
def test_completed_attempt_does_not_block_a_new_attempt(self):
|
||||||
|
StudentAttempt.objects.create(
|
||||||
|
student=self.student,
|
||||||
|
exam_set=self.exam,
|
||||||
|
is_complete=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
def test_only_one_answer_is_allowed_per_attempt_and_question(self):
|
||||||
|
attempt = StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
StudentAnswer.objects.create(
|
||||||
|
attempt=attempt,
|
||||||
|
question=self.question,
|
||||||
|
answer_text="answer",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError), transaction.atomic():
|
||||||
|
StudentAnswer.objects.create(
|
||||||
|
attempt=attempt,
|
||||||
|
question=self.question,
|
||||||
|
answer_text="duplicate",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_choice_configuration_is_validated(self):
|
||||||
|
question = Question(
|
||||||
|
section=self.section,
|
||||||
|
order=2,
|
||||||
|
question_type="mcq",
|
||||||
|
prompt="Choose one.",
|
||||||
|
options=["A"],
|
||||||
|
correct_answer="B",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
question.full_clean()
|
||||||
|
|
||||||
|
def test_manual_score_is_restricted_to_reviewed_response_types(self):
|
||||||
|
attempt = StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
answer = StudentAnswer(
|
||||||
|
attempt=attempt,
|
||||||
|
question=self.question,
|
||||||
|
answer_text="answer",
|
||||||
|
manual_score=7.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
answer.full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
class ExamNavigationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.student = User.objects.create_user(username="navigator", password="test-pass-123")
|
||||||
|
self.exam = ExamSet.objects.create(title="Ordered IELTS Mock", is_published=True)
|
||||||
|
|
||||||
|
# Create these in reverse sequence to prove navigation does not rely on IDs.
|
||||||
|
self.second_section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=2,
|
||||||
|
section_type="listening",
|
||||||
|
time_limit_minutes=30,
|
||||||
|
)
|
||||||
|
self.first_section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=60,
|
||||||
|
passage_text="A short reading passage for interface testing.",
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=self.first_section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="First section question",
|
||||||
|
correct_answer="answer",
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=self.second_section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Second section question",
|
||||||
|
correct_answer="answer",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.student)
|
||||||
|
|
||||||
|
def test_start_exam_opens_lowest_ordered_section(self):
|
||||||
|
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("take_section", args=[attempt.id, self.first_section.id]),
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_start_exam_rejects_get_requests(self):
|
||||||
|
response = self.client.get(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
def test_completed_test_can_be_retaken_without_replacing_the_old_attempt(self):
|
||||||
|
completed_attempt = StudentAttempt.objects.create(
|
||||||
|
student=self.student,
|
||||||
|
exam_set=self.exam,
|
||||||
|
is_complete=True,
|
||||||
|
submitted_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
active_attempt = StudentAttempt.objects.get(
|
||||||
|
student=self.student,
|
||||||
|
exam_set=self.exam,
|
||||||
|
is_complete=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(active_attempt.id, completed_attempt.id)
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("take_section", args=[active_attempt.id, self.first_section.id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_retake_button_replaces_only_the_unfinished_attempt(self):
|
||||||
|
active_attempt = StudentAttempt.objects.create(
|
||||||
|
student=self.student,
|
||||||
|
exam_set=self.exam,
|
||||||
|
current_section=self.second_section,
|
||||||
|
section_started_at=timezone.now(),
|
||||||
|
section_deadline=timezone.now() + timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("retake_exam", args=[active_attempt.id]))
|
||||||
|
fresh_attempt = StudentAttempt.objects.get(
|
||||||
|
student=self.student,
|
||||||
|
exam_set=self.exam,
|
||||||
|
is_complete=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(StudentAttempt.objects.filter(id=active_attempt.id).exists())
|
||||||
|
self.assertNotEqual(fresh_attempt.id, active_attempt.id)
|
||||||
|
self.assertEqual(fresh_attempt.current_section, self.first_section)
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("take_section", args=[fresh_attempt.id, self.first_section.id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reading_section_uses_fullscreen_workspace_and_question_navigator(self):
|
||||||
|
self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("take_section", args=[attempt.id, self.first_section.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, "exam-session--reading")
|
||||||
|
self.assertContains(response, 'id="fullscreen-toggle"')
|
||||||
|
self.assertContains(response, 'class="exam-question-nav"')
|
||||||
|
self.assertContains(response, 'data-target="question-')
|
||||||
|
self.assertContains(response, 'id="exam-resizer"')
|
||||||
|
self.assertContains(response, 'id="passage-selection-toolbar"')
|
||||||
|
self.assertContains(response, 'id="passage-highlight"')
|
||||||
|
self.assertContains(response, 'id="passage-add-note"')
|
||||||
|
self.assertContains(response, 'id="passage-clear"')
|
||||||
|
|
||||||
|
def test_submitting_section_opens_next_ordered_section(self):
|
||||||
|
attempt = StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
attempt.current_section = self.first_section
|
||||||
|
attempt.section_started_at = timezone.now()
|
||||||
|
attempt.section_deadline = timezone.now() + timedelta(minutes=60)
|
||||||
|
attempt.save()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, self.first_section.id]),
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("take_section", args=[attempt.id, self.second_section.id]),
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExamCatalogueTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.student = User.objects.create_user(
|
||||||
|
username="catalogue", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.exam = ExamSet.objects.create(
|
||||||
|
title="Academic Practice Test 1", is_published=True
|
||||||
|
)
|
||||||
|
section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=60,
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Complete the answer.",
|
||||||
|
correct_answer="answer",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.student)
|
||||||
|
|
||||||
|
def test_catalogue_shows_real_test_metadata(self):
|
||||||
|
response = self.client.get(reverse("exam_list"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Academic Practice Test 1")
|
||||||
|
self.assertContains(response, "1</strong> section")
|
||||||
|
self.assertContains(response, "1</strong> question")
|
||||||
|
self.assertContains(response, "60</strong> min")
|
||||||
|
self.assertNotContains(response, "рџ")
|
||||||
|
|
||||||
|
def test_catalogue_status_filter_uses_the_students_attempt(self):
|
||||||
|
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("exam_list") + "?status=in_progress")
|
||||||
|
|
||||||
|
self.assertContains(response, "Academic Practice Test 1")
|
||||||
|
self.assertContains(response, "Continue test")
|
||||||
|
self.assertEqual(response.context["counts"]["in_progress"], 1)
|
||||||
|
|
||||||
|
def test_invalid_filter_falls_back_to_all(self):
|
||||||
|
response = self.client.get(reverse("exam_list") + "?status=unknown")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["status_filter"], "all")
|
||||||
|
|
||||||
|
def test_skill_filter_shows_only_the_selected_category(self):
|
||||||
|
self.exam.category = "full"
|
||||||
|
self.exam.save(update_fields=["category"])
|
||||||
|
reading_exam = ExamSet.objects.create(
|
||||||
|
title="Reading Only", category="reading", is_published=True
|
||||||
|
)
|
||||||
|
reading_section = Section.objects.create(
|
||||||
|
exam_set=reading_exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=20,
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=reading_section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Answer",
|
||||||
|
correct_answer="yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("exam_list") + "?skill=reading")
|
||||||
|
|
||||||
|
self.assertContains(response, "Reading Only")
|
||||||
|
self.assertNotContains(response, self.exam.title)
|
||||||
|
self.assertEqual(response.context["skill_filter"], "reading")
|
||||||
|
self.assertEqual(response.context["skill_counts"]["reading"], 1)
|
||||||
|
|
||||||
|
def test_unpublished_tests_are_not_visible_or_startable(self):
|
||||||
|
self.exam.is_published = False
|
||||||
|
self.exam.save(update_fields=["is_published"])
|
||||||
|
|
||||||
|
catalogue = self.client.get(reverse("exam_list"))
|
||||||
|
start = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
|
||||||
|
self.assertNotContains(catalogue, self.exam.title)
|
||||||
|
self.assertEqual(start.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerDeadlineTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.student = User.objects.create_user(username="timed", password="test-pass-123")
|
||||||
|
self.exam = ExamSet.objects.create(title="Timed IELTS Mock", is_published=True)
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=60,
|
||||||
|
passage_text="The evidence sentence is in this reading passage.",
|
||||||
|
)
|
||||||
|
self.question = Question.objects.create(
|
||||||
|
section=self.section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Complete this answer.",
|
||||||
|
correct_answer="valid",
|
||||||
|
explanation="The passage gives the exact word used in the answer.",
|
||||||
|
passage_reference="The evidence sentence is in this reading passage.",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.student)
|
||||||
|
|
||||||
|
def start_attempt(self):
|
||||||
|
self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
return StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
|
||||||
|
def test_refresh_does_not_reset_server_deadline(self):
|
||||||
|
attempt = self.start_attempt()
|
||||||
|
original_deadline = attempt.section_deadline
|
||||||
|
|
||||||
|
self.client.get(reverse("take_section", args=[attempt.id, self.section.id]))
|
||||||
|
attempt.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(attempt.section_deadline, original_deadline)
|
||||||
|
|
||||||
|
def test_late_submission_does_not_accept_new_answer_text(self):
|
||||||
|
attempt = self.start_attempt()
|
||||||
|
late_time = attempt.section_deadline + timedelta(seconds=SUBMISSION_GRACE_SECONDS + 1)
|
||||||
|
|
||||||
|
with patch("exams.views.timezone.now", return_value=late_time):
|
||||||
|
self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, self.section.id]),
|
||||||
|
data={f"q{self.question.id}": "valid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = StudentAnswer.objects.get(attempt=attempt, question=self.question)
|
||||||
|
self.assertEqual(answer.answer_text, "")
|
||||||
|
self.assertFalse(answer.is_correct)
|
||||||
|
|
||||||
|
def test_user_cannot_skip_to_a_later_section(self):
|
||||||
|
later_section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=2,
|
||||||
|
section_type="listening",
|
||||||
|
time_limit_minutes=30,
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=later_section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Later question",
|
||||||
|
correct_answer="answer",
|
||||||
|
)
|
||||||
|
attempt = self.start_attempt()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("take_section", args=[attempt.id, later_section.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("take_section", args=[attempt.id, self.section.id]),
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_completion_awards_xp_and_updates_streak_once(self):
|
||||||
|
attempt = self.start_attempt()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, self.section.id]),
|
||||||
|
data={f"q{self.question.id}": "valid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.student.studentprofile.refresh_from_db()
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("results", args=[attempt.id]),
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.student.studentprofile.xp, 60)
|
||||||
|
self.assertEqual(self.student.studentprofile.streak, 1)
|
||||||
|
self.assertEqual(self.student.studentprofile.last_activity_date, timezone.localdate())
|
||||||
|
|
||||||
|
def test_results_review_shows_correct_answer_explanation_and_passage_link(self):
|
||||||
|
attempt = self.start_attempt()
|
||||||
|
self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, self.section.id]),
|
||||||
|
data={f"q{self.question.id}": "wrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("results", args=[attempt.id]))
|
||||||
|
|
||||||
|
self.assertContains(response, "Correct answer")
|
||||||
|
self.assertNotContains(response, self.question.explanation)
|
||||||
|
self.assertContains(response, self.question.passage_reference)
|
||||||
|
self.assertContains(response, 'class="reader-show-evidence"')
|
||||||
|
self.assertContains(response, f'data-passage-section="{self.section.id}"')
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnosticSeedTests(TestCase):
|
||||||
|
def test_seed_command_creates_one_ready_published_diagnostic(self):
|
||||||
|
call_command("seed_diagnostic_exam", verbosity=0)
|
||||||
|
call_command("seed_diagnostic_exam", verbosity=0)
|
||||||
|
|
||||||
|
exam = ExamSet.objects.get(title="IELTS Skills Diagnostic")
|
||||||
|
self.assertTrue(exam.is_published)
|
||||||
|
self.assertTrue(exam.is_ready)
|
||||||
|
self.assertEqual(exam.sections.count(), 3)
|
||||||
|
self.assertEqual(Question.objects.filter(section__exam_set=exam).count(), 5)
|
||||||
|
|
||||||
|
|
||||||
|
class PracticeLibrarySeedTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.media_directory = TemporaryDirectory()
|
||||||
|
self.media_override = override_settings(MEDIA_ROOT=self.media_directory.name)
|
||||||
|
self.media_override.enable()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.media_override.disable()
|
||||||
|
self.media_directory.cleanup()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def test_seed_library_creates_all_five_separate_categories(self):
|
||||||
|
call_command("seed_practice_library", verbosity=0)
|
||||||
|
call_command("seed_practice_library", verbosity=0)
|
||||||
|
|
||||||
|
published = ExamSet.objects.filter(is_published=True)
|
||||||
|
self.assertEqual(published.count(), 5)
|
||||||
|
self.assertEqual(
|
||||||
|
set(published.values_list("category", flat=True)),
|
||||||
|
{"reading", "listening", "writing", "speaking", "full"},
|
||||||
|
)
|
||||||
|
full_mock = published.get(category="full")
|
||||||
|
self.assertEqual(
|
||||||
|
list(full_mock.sections.values_list("section_type", flat=True)),
|
||||||
|
["listening", "reading", "writing", "speaking"],
|
||||||
|
)
|
||||||
|
listening = published.get(category="listening").sections.get()
|
||||||
|
self.assertTrue(bool(listening.audio_file))
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteDiagnosticJourneyTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.media_directory = TemporaryDirectory()
|
||||||
|
self.media_override = override_settings(MEDIA_ROOT=self.media_directory.name)
|
||||||
|
self.media_override.enable()
|
||||||
|
call_command("seed_diagnostic_exam", verbosity=0)
|
||||||
|
self.exam = ExamSet.objects.get(title="IELTS Skills Diagnostic")
|
||||||
|
self.student = User.objects.create_user(
|
||||||
|
username="journey", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.client.force_login(self.student)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.media_override.disable()
|
||||||
|
self.media_directory.cleanup()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def test_student_can_complete_the_published_diagnostic(self):
|
||||||
|
detail = self.client.get(reverse("exam_detail", args=[self.exam.id]))
|
||||||
|
self.assertContains(detail, "Begin test")
|
||||||
|
|
||||||
|
start = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
reading, writing, speaking = list(self.exam.sections.all())
|
||||||
|
self.assertRedirects(
|
||||||
|
start,
|
||||||
|
reverse("take_section", args=[attempt.id, reading.id]),
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
reading_answers = {
|
||||||
|
f"q{question.id}": question.correct_answer
|
||||||
|
for question in reading.questions.all()
|
||||||
|
}
|
||||||
|
reading_submit = self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, reading.id]),
|
||||||
|
data=reading_answers,
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
reading_submit,
|
||||||
|
reverse("take_section", args=[attempt.id, writing.id]),
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
writing_question = writing.questions.get()
|
||||||
|
self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, writing.id]),
|
||||||
|
data={f"q{writing_question.id}": "A structured diagnostic essay response."},
|
||||||
|
)
|
||||||
|
speaking_question = speaking.questions.get()
|
||||||
|
finish = self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, speaking.id]),
|
||||||
|
data={f"q{speaking_question.id}": "Structured notes for a spoken response."},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt.refresh_from_db()
|
||||||
|
self.student.studentprofile.refresh_from_db()
|
||||||
|
self.assertTrue(attempt.is_complete)
|
||||||
|
self.assertEqual(attempt.answers.count(), 5)
|
||||||
|
self.assertEqual(attempt.answers.filter(is_correct=True).count(), 3)
|
||||||
|
self.assertEqual(self.student.studentprofile.xp, 100)
|
||||||
|
self.assertContains(finish, "3 / 3")
|
||||||
|
self.assertContains(finish, "Awaiting review")
|
||||||
|
self.assertContains(finish, "Pending instructor review", count=2)
|
||||||
|
|
||||||
|
def test_speaking_audio_upload_is_stored_and_invalid_types_are_rejected(self):
|
||||||
|
self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
|
||||||
|
reading, writing, speaking = list(self.exam.sections.all())
|
||||||
|
reading_answers = {
|
||||||
|
f"q{question.id}": question.correct_answer
|
||||||
|
for question in reading.questions.all()
|
||||||
|
}
|
||||||
|
self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, reading.id]),
|
||||||
|
data=reading_answers,
|
||||||
|
)
|
||||||
|
writing_question = writing.questions.get()
|
||||||
|
self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, writing.id]),
|
||||||
|
data={f"q{writing_question.id}": "Essay response"},
|
||||||
|
)
|
||||||
|
speaking_question = speaking.questions.get()
|
||||||
|
invalid = SimpleUploadedFile("response.exe", b"not audio")
|
||||||
|
rejected = self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, speaking.id]),
|
||||||
|
data={f"q{speaking_question.id}_audio": invalid},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertContains(rejected, "File extension")
|
||||||
|
attempt.refresh_from_db()
|
||||||
|
self.assertFalse(attempt.is_complete)
|
||||||
|
|
||||||
|
valid = SimpleUploadedFile("response.webm", b"small audio placeholder", "audio/webm")
|
||||||
|
self.client.post(
|
||||||
|
reverse("take_section", args=[attempt.id, speaking.id]),
|
||||||
|
data={f"q{speaking_question.id}_audio": valid},
|
||||||
|
)
|
||||||
|
answer = StudentAnswer.objects.get(attempt=attempt, question=speaking_question)
|
||||||
|
self.assertTrue(answer.audio_response.name.endswith(".webm"))
|
||||||
|
|
||||||
|
|
||||||
|
class PremiumExamAccessTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="free-student",
|
||||||
|
password="test-password",
|
||||||
|
)
|
||||||
|
self.exam = ExamSet.objects.create(
|
||||||
|
title="Premium Reading Test",
|
||||||
|
category="reading",
|
||||||
|
access_level=ExamSet.ACCESS_PREMIUM,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
exam_set=self.exam,
|
||||||
|
order=1,
|
||||||
|
section_type="reading",
|
||||||
|
time_limit_minutes=20,
|
||||||
|
passage_text="A short reading passage.",
|
||||||
|
)
|
||||||
|
Question.objects.create(
|
||||||
|
section=self.section,
|
||||||
|
order=1,
|
||||||
|
question_type="gap",
|
||||||
|
prompt="Complete the sentence.",
|
||||||
|
correct_answer="answer",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_free_user_sees_locked_test_and_cannot_start_it(self):
|
||||||
|
catalogue = self.client.get(reverse("exam_list"))
|
||||||
|
self.assertContains(catalogue, "Premium Reading Test")
|
||||||
|
self.assertContains(catalogue, "Unlock Premium Reading Test")
|
||||||
|
|
||||||
|
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
self.assertRedirects(response, reverse("premium"))
|
||||||
|
self.assertFalse(
|
||||||
|
StudentAttempt.objects.filter(student=self.user, exam_set=self.exam).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_premium_user_can_start_premium_test(self):
|
||||||
|
PremiumEntitlement.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
source=PremiumEntitlement.SOURCE_PAYMENT,
|
||||||
|
order_reference="ORDER-3003",
|
||||||
|
)
|
||||||
|
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.user, exam_set=self.exam)
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("take_section", args=[attempt.id, self.section.id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_active_attempt_remains_available_after_revocation(self):
|
||||||
|
entitlement = PremiumEntitlement.objects.create(user=self.user)
|
||||||
|
self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.user, exam_set=self.exam)
|
||||||
|
entitlement.revoked_at = timezone.now()
|
||||||
|
entitlement.save(update_fields=["revoked_at"])
|
||||||
|
|
||||||
|
detail = self.client.get(reverse("exam_detail", args=[self.exam.id]))
|
||||||
|
self.assertContains(detail, "Continue test")
|
||||||
|
continuation = self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
self.assertRedirects(
|
||||||
|
continuation,
|
||||||
|
reverse("take_section", args=[attempt.id, self.section.id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_revoked_user_cannot_replace_attempt_with_retake(self):
|
||||||
|
entitlement = PremiumEntitlement.objects.create(user=self.user)
|
||||||
|
self.client.post(reverse("start_exam", args=[self.exam.id]))
|
||||||
|
attempt = StudentAttempt.objects.get(student=self.user, exam_set=self.exam)
|
||||||
|
entitlement.revoked_at = timezone.now()
|
||||||
|
entitlement.save(update_fields=["revoked_at"])
|
||||||
|
|
||||||
|
response = self.client.post(reverse("retake_exam", args=[attempt.id]))
|
||||||
|
self.assertRedirects(response, reverse("premium"))
|
||||||
|
self.assertTrue(StudentAttempt.objects.filter(id=attempt.id).exists())
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.exam_list, name='exam_list'),
|
||||||
|
path('<int:exam_set_id>/', views.exam_detail, name='exam_detail'),
|
||||||
|
path('start/<int:exam_set_id>/', views.start_exam, name='start_exam'),
|
||||||
|
path('attempt/<int:attempt_id>/section/<int:section_id>/', views.take_section, name='take_section'),
|
||||||
|
path('attempt/<int:attempt_id>/retake/', views.retake_exam, name='retake_exam'),
|
||||||
|
path('attempt/<int:attempt_id>/exact/', views.exact_exam, name='exact_exam'),
|
||||||
|
path('attempt/<int:attempt_id>/exact/content/', views.exact_exam_content, name='exact_exam_content'),
|
||||||
|
path('attempt/<int:attempt_id>/exact/complete/', views.complete_exact_exam, name='complete_exact_exam'),
|
||||||
|
path('results/<int:attempt_id>/', views.results, name='results'),
|
||||||
|
]
|
||||||
+486
@@ -0,0 +1,486 @@
|
|||||||
|
import math
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
|
from django.db.models import Avg
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import ExamSet, Section, StudentAnswer, StudentAttempt
|
||||||
|
from .grading import academic_reading_band
|
||||||
|
from accounts.access import can_access_exam, has_lifetime_premium
|
||||||
|
from accounts.models import StudentProfile
|
||||||
|
|
||||||
|
|
||||||
|
SUBMISSION_GRACE_SECONDS = 15
|
||||||
|
MAX_AUDIO_RESPONSE_SIZE = 20 * 1024 * 1024
|
||||||
|
AUDIO_RESPONSE_VALIDATOR = FileExtensionValidator(["mp3", "m4a", "wav", "webm", "ogg"])
|
||||||
|
|
||||||
|
|
||||||
|
def activate_section(attempt, section, now=None):
|
||||||
|
"""Start a section once and persist its authoritative server deadline."""
|
||||||
|
if attempt.current_section_id == section.id and attempt.section_deadline:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = now or timezone.now()
|
||||||
|
attempt.current_section = section
|
||||||
|
attempt.section_started_at = now
|
||||||
|
attempt.section_deadline = now + timedelta(minutes=section.time_limit_minutes)
|
||||||
|
attempt.save(
|
||||||
|
update_fields=["current_section", "section_started_at", "section_deadline"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def exam_list(request):
|
||||||
|
status_filter = request.GET.get("status", "all")
|
||||||
|
skill_filter = request.GET.get("skill", "all")
|
||||||
|
valid_filters = {"all", "not_started", "in_progress", "completed"}
|
||||||
|
valid_skills = {"all", "reading", "listening", "writing", "speaking", "full"}
|
||||||
|
if status_filter not in valid_filters:
|
||||||
|
status_filter = "all"
|
||||||
|
if skill_filter not in valid_skills:
|
||||||
|
skill_filter = "all"
|
||||||
|
|
||||||
|
published_exams = ExamSet.objects.filter(is_published=True)
|
||||||
|
user_has_premium = has_lifetime_premium(request.user)
|
||||||
|
skill_counts = {
|
||||||
|
category: published_exams.filter(category=category).count()
|
||||||
|
for category in ("reading", "listening", "writing", "speaking", "full")
|
||||||
|
}
|
||||||
|
all_total = published_exams.count()
|
||||||
|
if skill_filter != "all":
|
||||||
|
published_exams = published_exams.filter(category=skill_filter)
|
||||||
|
exam_sets = published_exams.prefetch_related("sections__questions")
|
||||||
|
latest_attempts = {}
|
||||||
|
for attempt in StudentAttempt.objects.filter(student=request.user).order_by("-started_at"):
|
||||||
|
latest_attempts.setdefault(attempt.exam_set_id, attempt)
|
||||||
|
|
||||||
|
exam_data = []
|
||||||
|
counts = {"not_started": 0, "in_progress": 0, "completed": 0}
|
||||||
|
|
||||||
|
for exam_set in exam_sets:
|
||||||
|
sections = list(exam_set.sections.all())
|
||||||
|
is_exact = exam_set.delivery_mode == "exact_html"
|
||||||
|
attempt = latest_attempts.get(exam_set.id)
|
||||||
|
if attempt is None:
|
||||||
|
status = "not_started"
|
||||||
|
elif attempt.is_complete:
|
||||||
|
status = "completed"
|
||||||
|
else:
|
||||||
|
status = "in_progress"
|
||||||
|
|
||||||
|
counts[status] += 1
|
||||||
|
score = None
|
||||||
|
if status == "completed":
|
||||||
|
answers = attempt.answers.exclude(is_correct=None)
|
||||||
|
total = answers.count()
|
||||||
|
correct = answers.filter(is_correct=True).count()
|
||||||
|
score = f"{correct}/{total}" if total else None
|
||||||
|
|
||||||
|
question_orders = [question.order for section in sections for question in section.questions.all()]
|
||||||
|
part = "all"
|
||||||
|
if len(sections) == 1 and question_orders and exam_set.category in {"reading", "listening"}:
|
||||||
|
first_order = min(question_orders)
|
||||||
|
part = "p3" if first_order >= 27 else "p2" if first_order >= 14 else "p1"
|
||||||
|
|
||||||
|
exam_data.append(
|
||||||
|
{
|
||||||
|
"exam_set": exam_set,
|
||||||
|
"status": status,
|
||||||
|
"attempt": attempt,
|
||||||
|
"score": score,
|
||||||
|
"is_exact": is_exact,
|
||||||
|
"section_count": 1 if is_exact else len(sections),
|
||||||
|
"question_count": sum(len(section.questions.all()) for section in sections),
|
||||||
|
"total_minutes": sum(section.time_limit_minutes for section in sections),
|
||||||
|
"section_types": ["Original HTML"] if is_exact else [section.get_section_type_display() for section in sections],
|
||||||
|
"part": part,
|
||||||
|
"requires_premium": exam_set.access_level == ExamSet.ACCESS_PREMIUM,
|
||||||
|
"is_locked": not (
|
||||||
|
exam_set.access_level == ExamSet.ACCESS_FREE
|
||||||
|
or user_has_premium
|
||||||
|
or attempt is not None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_filter != "all":
|
||||||
|
exam_data = [item for item in exam_data if item["status"] == status_filter]
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exams/exam_list.html",
|
||||||
|
{
|
||||||
|
"exam_data": exam_data,
|
||||||
|
"counts": counts,
|
||||||
|
"total": len(exam_sets),
|
||||||
|
"all_total": all_total,
|
||||||
|
"status_filter": status_filter,
|
||||||
|
"skill_filter": skill_filter,
|
||||||
|
"skill_counts": skill_counts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def exam_detail(request, exam_set_id):
|
||||||
|
exam_set = get_object_or_404(
|
||||||
|
ExamSet.objects.prefetch_related("sections__questions"),
|
||||||
|
id=exam_set_id,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
sections = list(exam_set.sections.all())
|
||||||
|
active_attempt = StudentAttempt.objects.filter(
|
||||||
|
student=request.user,
|
||||||
|
exam_set=exam_set,
|
||||||
|
is_complete=False,
|
||||||
|
).first()
|
||||||
|
can_open = can_access_exam(request.user, exam_set) or active_attempt is not None
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exams/exam_detail.html",
|
||||||
|
{
|
||||||
|
"exam_set": exam_set,
|
||||||
|
"sections": sections,
|
||||||
|
"active_attempt": active_attempt,
|
||||||
|
"total_minutes": sum(section.time_limit_minutes for section in sections),
|
||||||
|
"question_count": sum(len(section.questions.all()) for section in sections),
|
||||||
|
"is_ready": exam_set.is_ready,
|
||||||
|
"can_access_exam": can_open,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def start_exam(request, exam_set_id):
|
||||||
|
exam_set = get_object_or_404(
|
||||||
|
ExamSet.objects.prefetch_related("sections__questions"),
|
||||||
|
id=exam_set_id,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
if not exam_set.is_ready:
|
||||||
|
messages.error(request, "This test is not ready to begin. Please contact support.")
|
||||||
|
return redirect("exam_detail", exam_set_id=exam_set.id)
|
||||||
|
attempt = StudentAttempt.objects.filter(
|
||||||
|
student=request.user,
|
||||||
|
exam_set=exam_set,
|
||||||
|
is_complete=False,
|
||||||
|
).first()
|
||||||
|
if attempt is None and not can_access_exam(request.user, exam_set):
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
"This test is included with Lifetime Premium. Upgrade once for permanent access.",
|
||||||
|
)
|
||||||
|
return redirect("premium")
|
||||||
|
if attempt is None:
|
||||||
|
attempt = StudentAttempt.objects.create(
|
||||||
|
student=request.user,
|
||||||
|
exam_set=exam_set,
|
||||||
|
)
|
||||||
|
if exam_set.delivery_mode == "exact_html":
|
||||||
|
return redirect("exact_exam", attempt_id=attempt.id)
|
||||||
|
first_section = exam_set.sections.order_by("order", "id").first()
|
||||||
|
if first_section is None:
|
||||||
|
return redirect("exam_list")
|
||||||
|
|
||||||
|
if attempt.current_section_id is None:
|
||||||
|
activate_section(attempt, first_section)
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
"take_section",
|
||||||
|
attempt_id=attempt.id,
|
||||||
|
section_id=attempt.current_section_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def retake_exam(request, attempt_id):
|
||||||
|
"""Replace an unfinished attempt with a fresh one; completed attempts remain saved."""
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
StudentAttempt.objects.select_related("exam_set"),
|
||||||
|
id=attempt_id,
|
||||||
|
student=request.user,
|
||||||
|
is_complete=False,
|
||||||
|
)
|
||||||
|
exam_set = attempt.exam_set
|
||||||
|
if not can_access_exam(request.user, exam_set):
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
"A new attempt for this test requires Lifetime Premium.",
|
||||||
|
)
|
||||||
|
return redirect("premium")
|
||||||
|
attempt.delete()
|
||||||
|
fresh_attempt = StudentAttempt.objects.create(student=request.user, exam_set=exam_set)
|
||||||
|
if exam_set.delivery_mode == "exact_html":
|
||||||
|
return redirect("exact_exam", attempt_id=fresh_attempt.id)
|
||||||
|
first_section = exam_set.sections.order_by("order", "id").first()
|
||||||
|
if first_section is None:
|
||||||
|
fresh_attempt.delete()
|
||||||
|
return redirect("exam_list")
|
||||||
|
activate_section(fresh_attempt, first_section)
|
||||||
|
return redirect(
|
||||||
|
"take_section",
|
||||||
|
attempt_id=fresh_attempt.id,
|
||||||
|
section_id=first_section.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def exact_exam(request, attempt_id):
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
StudentAttempt.objects.select_related("exam_set"), id=attempt_id,
|
||||||
|
student=request.user, is_complete=False, exam_set__delivery_mode="exact_html",
|
||||||
|
)
|
||||||
|
return render(request, "exams/exact_exam.html", {"attempt": attempt, "exam_set": attempt.exam_set})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@xframe_options_sameorigin
|
||||||
|
def exact_exam_content(request, attempt_id):
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
StudentAttempt.objects.select_related("exam_set"), id=attempt_id,
|
||||||
|
student=request.user, is_complete=False, exam_set__delivery_mode="exact_html",
|
||||||
|
)
|
||||||
|
response = HttpResponse(attempt.exam_set.source_html, content_type="text/html; charset=utf-8")
|
||||||
|
response["Content-Security-Policy"] = (
|
||||||
|
"sandbox allow-scripts allow-forms allow-modals allow-downloads; "
|
||||||
|
"default-src 'self' data: blob: https:; script-src 'unsafe-inline' https:; "
|
||||||
|
"style-src 'unsafe-inline' https:; img-src data: blob: https:; "
|
||||||
|
"media-src data: blob: https:; connect-src https:"
|
||||||
|
)
|
||||||
|
response["Cache-Control"] = "private, no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def complete_exact_exam(request, attempt_id):
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
StudentAttempt.objects.select_related("exam_set"), id=attempt_id,
|
||||||
|
student=request.user, is_complete=False, exam_set__delivery_mode="exact_html",
|
||||||
|
)
|
||||||
|
attempt.is_complete = True
|
||||||
|
attempt.submitted_at = timezone.now()
|
||||||
|
attempt.save(update_fields=["is_complete", "submitted_at"])
|
||||||
|
profile, _ = StudentProfile.objects.get_or_create(user=request.user)
|
||||||
|
profile.xp += 50
|
||||||
|
profile.save(update_fields=["xp"])
|
||||||
|
messages.success(request, "Exact HTML test marked as complete.")
|
||||||
|
return redirect("exam_list")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def take_section(request, attempt_id, section_id):
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
StudentAttempt.objects.select_related("exam_set"),
|
||||||
|
id=attempt_id,
|
||||||
|
student=request.user,
|
||||||
|
is_complete=False,
|
||||||
|
)
|
||||||
|
section = get_object_or_404(
|
||||||
|
Section.objects.prefetch_related("questions", "question_groups__questions"),
|
||||||
|
id=section_id,
|
||||||
|
exam_set=attempt.exam_set,
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt.current_section_id is None:
|
||||||
|
first_section = attempt.exam_set.sections.order_by("order", "id").first()
|
||||||
|
if first_section is None:
|
||||||
|
return redirect("exam_list")
|
||||||
|
activate_section(attempt, first_section)
|
||||||
|
|
||||||
|
if section.id != attempt.current_section_id:
|
||||||
|
return redirect(
|
||||||
|
"take_section",
|
||||||
|
attempt_id=attempt.id,
|
||||||
|
section_id=attempt.current_section_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
deadline = attempt.section_deadline
|
||||||
|
remaining_seconds = max(0, math.ceil((deadline - now).total_seconds()))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
submission_is_late = now > deadline + timedelta(seconds=SUBMISSION_GRACE_SECONDS)
|
||||||
|
|
||||||
|
for question in section.questions.filter(question_type="speaking"):
|
||||||
|
uploaded_audio = request.FILES.get(f"q{question.id}_audio")
|
||||||
|
if uploaded_audio is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
AUDIO_RESPONSE_VALIDATOR(uploaded_audio)
|
||||||
|
if uploaded_audio.size > MAX_AUDIO_RESPONSE_SIZE:
|
||||||
|
raise ValidationError("Audio responses must be 20 MB or smaller.")
|
||||||
|
except ValidationError as error:
|
||||||
|
messages.error(request, error.messages[0])
|
||||||
|
return redirect(
|
||||||
|
"take_section",
|
||||||
|
attempt_id=attempt.id,
|
||||||
|
section_id=section.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for question in section.questions.all():
|
||||||
|
answer_text = request.POST.get(f"q{question.id}", "").strip()
|
||||||
|
audio_response = request.FILES.get(f"q{question.id}_audio")
|
||||||
|
if submission_is_late:
|
||||||
|
answer_text = ""
|
||||||
|
audio_response = None
|
||||||
|
is_correct = None
|
||||||
|
if question.question_type in {"mcq", "gap", "matching"}:
|
||||||
|
expected = (question.correct_answer or "").strip().casefold()
|
||||||
|
is_correct = answer_text.casefold() == expected
|
||||||
|
|
||||||
|
defaults = {"answer_text": answer_text, "is_correct": is_correct}
|
||||||
|
if audio_response is not None:
|
||||||
|
defaults["audio_response"] = audio_response
|
||||||
|
StudentAnswer.objects.update_or_create(
|
||||||
|
attempt=attempt,
|
||||||
|
question=question,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_section = (
|
||||||
|
Section.objects.filter(
|
||||||
|
exam_set=attempt.exam_set,
|
||||||
|
order__gt=section.order,
|
||||||
|
)
|
||||||
|
.order_by("order", "id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if next_section:
|
||||||
|
activate_section(attempt, next_section, now=now)
|
||||||
|
return redirect(
|
||||||
|
"take_section",
|
||||||
|
attempt_id=attempt.id,
|
||||||
|
section_id=next_section.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt.is_complete = True
|
||||||
|
attempt.submitted_at = now
|
||||||
|
attempt.current_section = None
|
||||||
|
attempt.section_started_at = None
|
||||||
|
attempt.section_deadline = None
|
||||||
|
attempt.save(
|
||||||
|
update_fields=[
|
||||||
|
"is_complete",
|
||||||
|
"submitted_at",
|
||||||
|
"current_section",
|
||||||
|
"section_started_at",
|
||||||
|
"section_deadline",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
profile, _ = StudentProfile.objects.get_or_create(user=request.user)
|
||||||
|
today = timezone.localdate(now)
|
||||||
|
if profile.last_activity_date != today:
|
||||||
|
if profile.last_activity_date == today - timedelta(days=1):
|
||||||
|
profile.streak += 1
|
||||||
|
else:
|
||||||
|
profile.streak = 1
|
||||||
|
profile.last_activity_date = today
|
||||||
|
profile.xp += 50 + (attempt.answers.count() * 10)
|
||||||
|
profile.save(update_fields=["xp", "streak", "last_activity_date"])
|
||||||
|
return redirect("results", attempt_id=attempt.id)
|
||||||
|
|
||||||
|
grouped_question_ids = set()
|
||||||
|
question_blocks = []
|
||||||
|
for group in section.question_groups.all():
|
||||||
|
group_questions = list(group.questions.all())
|
||||||
|
if not group_questions:
|
||||||
|
continue
|
||||||
|
grouped_question_ids.update(question.id for question in group_questions)
|
||||||
|
question_blocks.append({"kind": "group", "group": group, "order": min(question.order for question in group_questions)})
|
||||||
|
for question in section.questions.all():
|
||||||
|
if question.id not in grouped_question_ids:
|
||||||
|
question_blocks.append({"kind": "question", "question": question, "order": question.order})
|
||||||
|
question_blocks.sort(key=lambda block: (block["order"], 0 if block["kind"] == "group" else 1))
|
||||||
|
question_orders = list(section.questions.values_list("order", flat=True))
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exams/take_section.html",
|
||||||
|
{
|
||||||
|
"attempt": attempt,
|
||||||
|
"section": section,
|
||||||
|
"remaining_seconds": remaining_seconds,
|
||||||
|
"section_number": list(
|
||||||
|
attempt.exam_set.sections.order_by("order", "id").values_list(
|
||||||
|
"id", flat=True
|
||||||
|
)
|
||||||
|
).index(section.id)
|
||||||
|
+ 1,
|
||||||
|
"section_total": attempt.exam_set.sections.count(),
|
||||||
|
"question_blocks": question_blocks,
|
||||||
|
"first_question_order": min(question_orders) if question_orders else None,
|
||||||
|
"last_question_order": max(question_orders) if question_orders else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def results(request, attempt_id):
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
StudentAttempt.objects.select_related("exam_set"),
|
||||||
|
id=attempt_id,
|
||||||
|
student=request.user,
|
||||||
|
is_complete=True,
|
||||||
|
)
|
||||||
|
answers = attempt.answers.select_related("question__section").order_by(
|
||||||
|
"question__section__order", "question__order", "id"
|
||||||
|
)
|
||||||
|
objective_answers = answers.exclude(is_correct=None)
|
||||||
|
manually_graded = answers.filter(manual_score__isnull=False)
|
||||||
|
pending_manual = answers.filter(
|
||||||
|
is_correct=None,
|
||||||
|
manual_score__isnull=True,
|
||||||
|
).count()
|
||||||
|
average_manual_score = manually_graded.aggregate(average=Avg("manual_score"))["average"]
|
||||||
|
correct_count = objective_answers.filter(is_correct=True).count()
|
||||||
|
total_gradable = objective_answers.count()
|
||||||
|
reading_answers = objective_answers.filter(question__section__section_type="reading")
|
||||||
|
reading_band = reading_equivalent = None
|
||||||
|
reading_band_estimated = False
|
||||||
|
if reading_answers.exists():
|
||||||
|
reading_correct = reading_answers.filter(is_correct=True).count()
|
||||||
|
reading_band, reading_equivalent, reading_band_estimated = academic_reading_band(
|
||||||
|
reading_correct, reading_answers.count()
|
||||||
|
)
|
||||||
|
reading_passages = list(
|
||||||
|
attempt.exam_set.sections.filter(section_type="reading")
|
||||||
|
.exclude(passage_text__isnull=True)
|
||||||
|
.exclude(passage_text="")
|
||||||
|
)
|
||||||
|
reading_review_answers = list(
|
||||||
|
answers.filter(question__section__section_type="reading")
|
||||||
|
)
|
||||||
|
is_reading_only = not attempt.exam_set.sections.exclude(
|
||||||
|
section_type="reading"
|
||||||
|
).exists()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exams/results.html",
|
||||||
|
{
|
||||||
|
"attempt": attempt,
|
||||||
|
"answers": answers,
|
||||||
|
"correct_count": correct_count,
|
||||||
|
"total_gradable": total_gradable,
|
||||||
|
"pending_manual": pending_manual,
|
||||||
|
"average_manual_score": average_manual_score,
|
||||||
|
"reading_band": reading_band,
|
||||||
|
"reading_equivalent": reading_equivalent,
|
||||||
|
"reading_band_estimated": reading_band_estimated,
|
||||||
|
"reading_passages": reading_passages,
|
||||||
|
"reading_review_answers": reading_review_answers,
|
||||||
|
"use_split_reading_review": bool(
|
||||||
|
is_reading_only and reading_passages and reading_review_answers
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ingress.yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: testpoint-ingress
|
||||||
|
namespace: testpoint
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "20m"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- testpoint.uzbutterfly.com
|
||||||
|
secretName: testpoint-tls
|
||||||
|
rules:
|
||||||
|
- host: testpoint.uzbutterfly.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: testpoint-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# migrate-job.yaml
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: testpoint-migrate
|
||||||
|
namespace: testpoint
|
||||||
|
spec:
|
||||||
|
backoffLimit: 2
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: migrate
|
||||||
|
image: localhost:5000/testpoint:1.0.3
|
||||||
|
command: ["python", "manage.py", "migrate", "--noinput"]
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: testpoint-config
|
||||||
|
- secretRef:
|
||||||
|
name: testpoint-secrets
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""WSGI entry point for cPanel's Phusion Passenger application manager."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parent
|
||||||
|
if str(APP_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(APP_ROOT))
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
|
||||||
|
|
||||||
|
from core.wsgi import application # noqa: E402, F401
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# postgres.yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: testpoint
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:17-alpine
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-secrets
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
subPath: postgres
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "250m"
|
||||||
|
memory: "256Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "testpoint_user", "-d", "testpoint"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: postgres-data
|
||||||
|
spec:
|
||||||
|
storageClassName: local-path
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: testpoint
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
clusterIP: None
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Django==5.2.7
|
||||||
|
django-unfold==1.0.0
|
||||||
|
django-allauth==65.10.0
|
||||||
|
dj-database-url==3.1.2
|
||||||
|
gunicorn==26.0.0; platform_system != "Windows"
|
||||||
|
psycopg[binary]==3.3.4
|
||||||
|
PyMySQL==1.2.0
|
||||||
|
whitenoise==6.12.0
|
||||||
|
bleach==6.2.0
|
||||||
|
openpyxl==3.1.5
|
||||||
|
requests==2.32.3
|
||||||
|
PyJWT==2.10.1
|
||||||
|
cryptography==49.0.0
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$backupDir = Join-Path $PSScriptRoot "..\backups\$timestamp"
|
||||||
|
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
|
||||||
|
|
||||||
|
$dbUser = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "ielts_mock" }
|
||||||
|
$dbName = if ($env:POSTGRES_DB) { $env:POSTGRES_DB } else { "ielts_mock" }
|
||||||
|
|
||||||
|
docker compose exec -T db pg_dump -U $dbUser -d $dbName -Fc -f /tmp/ielts-mock.dump
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
docker compose cp "db:/tmp/ielts-mock.dump" (Join-Path $backupDir "database.dump")
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
docker compose exec -T db rm -f /tmp/ielts-mock.dump
|
||||||
|
|
||||||
|
docker compose exec -T web tar -czf /tmp/ielts-media.tar.gz -C /app media
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
docker compose cp "web:/tmp/ielts-media.tar.gz" (Join-Path $backupDir "media.tar.gz")
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
docker compose exec -T web rm -f /tmp/ielts-media.tar.gz
|
||||||
|
|
||||||
|
Write-Host "Backup completed: $backupDir"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
backup_dir="./backups/$timestamp"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
db_user="${POSTGRES_USER:-ielts_mock}"
|
||||||
|
db_name="${POSTGRES_DB:-ielts_mock}"
|
||||||
|
|
||||||
|
docker compose exec -T db pg_dump -U "$db_user" -d "$db_name" -Fc -f /tmp/ielts-mock.dump
|
||||||
|
docker compose cp db:/tmp/ielts-mock.dump "$backup_dir/database.dump"
|
||||||
|
docker compose exec -T db rm -f /tmp/ielts-mock.dump
|
||||||
|
|
||||||
|
docker compose exec -T web tar -czf /tmp/ielts-media.tar.gz -C /app media
|
||||||
|
docker compose cp web:/tmp/ielts-media.tar.gz "$backup_dir/media.tar.gz"
|
||||||
|
docker compose exec -T web rm -f /tmp/ielts-media.tar.gz
|
||||||
|
|
||||||
|
echo "Backup completed: $backup_dir"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$backupDir = Join-Path $projectRoot "backups\local-$timestamp"
|
||||||
|
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
|
||||||
|
|
||||||
|
$database = Join-Path $projectRoot "db.sqlite3"
|
||||||
|
if (Test-Path -LiteralPath $database) {
|
||||||
|
Copy-Item -LiteralPath $database -Destination (Join-Path $backupDir "db.sqlite3")
|
||||||
|
}
|
||||||
|
|
||||||
|
$media = Join-Path $projectRoot "media"
|
||||||
|
if (Test-Path -LiteralPath $media) {
|
||||||
|
Compress-Archive -LiteralPath $media -DestinationPath (Join-Path $backupDir "media.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Local backup completed: $backupDir"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$env:DJANGO_DEBUG = "False"
|
||||||
|
$env:DJANGO_SECRET_KEY = "local-deployment-check-secret-with-more-than-fifty-characters-123456789"
|
||||||
|
$env:DJANGO_ALLOWED_HOSTS = "localhost,127.0.0.1,testserver"
|
||||||
|
$env:DJANGO_CSRF_TRUSTED_ORIGINS = "https://example.com"
|
||||||
|
$env:DJANGO_SECURE_HSTS_SECONDS = "3600"
|
||||||
|
$env:DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS = "True"
|
||||||
|
$env:DJANGO_SECURE_HSTS_PRELOAD = "True"
|
||||||
|
|
||||||
|
python manage.py makemigrations --check --dry-run
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
python manage.py check --deploy
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
$env:DJANGO_DEBUG = "True"
|
||||||
|
$env:DJANGO_SECURE_SSL_REDIRECT = "False"
|
||||||
|
$env:DJANGO_ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||||
|
python manage.py test
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# service.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: testpoint-service
|
||||||
|
namespace: testpoint
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: testpoint
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: 8000
|
||||||
|
type: ClusterIP
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.skip-link{position:fixed;z-index:9999;top:10px;left:10px;padding:10px 15px;border-radius:7px;background:#10213e;color:#fff;text-decoration:none;transform:translateY(-150%);transition:transform .2s}.skip-link:focus{transform:translateY(0);color:#fff}a:focus-visible,button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid rgba(18,100,232,.4);outline-offset:3px}html{scroll-behavior:smooth;scroll-padding-top:90px}@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
:root{--assessment-blue:#1264e8;--assessment-blue-soft:#eaf2ff;--assessment-ink:#10213e;--assessment-muted:#718097;--assessment-line:#dce5f0;--assessment-surface:#fff;--assessment-canvas:#f5f8fd}
|
||||||
|
.assessment-page{color:var(--assessment-ink);background:var(--assessment-canvas)}
|
||||||
|
.assessment-bar{position:relative;z-index:20;display:flex;min-height:76px;align-items:center;justify-content:space-between;gap:24px;padding:12px clamp(18px,3.4vw,52px);border-bottom:1px solid var(--assessment-line);background:var(--assessment-surface);box-shadow:none}
|
||||||
|
.assessment-bar__title{display:flex;min-width:0;align-items:center;gap:12px}.assessment-bar__title>div{min-width:0}
|
||||||
|
.assessment-bar__title>div>span,.assessment-bar__title small{display:block;margin:0 0 3px;color:var(--assessment-blue);font-size:11px;font-weight:750;letter-spacing:.075em;line-height:1.2;text-transform:uppercase}
|
||||||
|
.assessment-bar__title h1{display:block;overflow:hidden;margin:0;color:var(--assessment-ink);font-size:16px;font-weight:750;line-height:1.25;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.assessment-bar__title p{display:block;overflow:hidden;max-width:360px;margin:2px 0 0;color:var(--assessment-muted);font-size:12px;line-height:1.3;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.assessment-bar__back,.assessment-bar__icon{display:grid;width:36px;height:36px;flex:0 0 36px;place-items:center;border-radius:8px}
|
||||||
|
.assessment-bar__back{border:1px solid var(--assessment-line);color:#50647d;background:#fff;text-decoration:none}.assessment-bar__back:hover{color:var(--assessment-blue);border-color:#9fc2f2;background:#f6faff}
|
||||||
|
.assessment-bar__icon{color:var(--assessment-blue);background:var(--assessment-blue-soft);font-size:16px}
|
||||||
|
.assessment-page .exam-session-controls{display:flex;align-items:center;gap:10px}
|
||||||
|
.assessment-page .exam-fullscreen-button,.assessment-page .exam-retake-button,.assessment-bar__action button{display:inline-flex;min-height:38px;align-items:center;justify-content:center;gap:7px;padding:0 13px;border:1px solid #cbd8e8;border-radius:8px;color:#405670;background:#fff;font:inherit;font-size:12px;font-weight:700;cursor:pointer}
|
||||||
|
.assessment-page .exam-retake-button{color:var(--assessment-blue);border-color:#a9c9f4;background:#f8fbff}.assessment-bar__action button{color:#fff;border-color:var(--assessment-blue);background:var(--assessment-blue)}
|
||||||
|
.assessment-page .exam-timer-wrap{min-width:94px;padding:5px 11px;border:1px solid var(--assessment-line);border-radius:8px;background:#f8fafc;text-align:center}
|
||||||
|
.assessment-page .exam-timer-wrap>span{color:var(--assessment-muted);font-size:10px}.assessment-page .exam-timer-wrap strong{color:var(--assessment-ink);font-size:16px}
|
||||||
|
.assessment-page--test:not(.exam-session--reading){min-height:calc(100vh - 76px)}.assessment-page--test:not(.exam-session--reading) .exam-session-form{padding-top:20px}
|
||||||
|
.assessment-page--test.exam-session--reading .exam-session-header{min-height:76px;padding:12px clamp(18px,3.4vw,52px)}.assessment-page--test.exam-session--reading .exam-session-form{height:calc(100vh - 76px)}
|
||||||
|
.assessment-page--test:not(.exam-session--reading) .listening-part-banner{max-width:920px;margin:0 auto 14px}.assessment-page--test:not(.exam-session--reading) .exam-workspace{padding-top:0}
|
||||||
|
.assessment-page--test .exam-question-card{border-color:var(--assessment-line);border-radius:10px;box-shadow:none}.assessment-page--test .exam-options label,.assessment-page--test .exam-text-input,.assessment-page--test .exam-textarea{border-radius:7px}
|
||||||
|
.assessment-page--review .reader-review-head{height:88px;grid-template-columns:minmax(300px,1.3fr) minmax(150px,.55fr) minmax(175px,.65fr) auto}
|
||||||
|
.assessment-page--review .reader-review-workspace{height:calc(100% - 150px)}.assessment-page--review .reader-head-icon{width:36px;height:36px;flex-basis:36px;border-radius:8px}
|
||||||
|
.assessment-page--review .assessment-metric{padding-left:20px;border-left:1px solid var(--assessment-line)}.assessment-page--review .reader-review-card{border-radius:10px;box-shadow:none}
|
||||||
|
.assessment-bar--results{min-height:88px}.assessment-bar__summary{display:flex;align-items:center;gap:8px;margin-left:auto}.assessment-bar__summary span{padding:7px 9px;border-radius:7px;color:#52657d;background:#f1f5fa;font-size:11px;font-weight:650}
|
||||||
|
.assessment-page--review.results-page{padding:0 0 64px}.assessment-page--review .results-shell{width:min(1080px,calc(100% - 40px));padding-top:28px}
|
||||||
|
.assessment-page--review .results-header h2{margin:4px 0 7px;font-size:24px}.assessment-page--review .results-summary article,.assessment-page--review .answer-review{border-radius:10px;box-shadow:none}
|
||||||
|
.assessment-page--review .answer-card{padding:20px 0}.assessment-page--review .answer-response{border:1px solid #e5ebf3;border-radius:7px;background:#f8fafc}
|
||||||
|
.assessment-page--review .results-actions .results-primary{color:#fff;border-color:var(--assessment-blue);background:var(--assessment-blue)}
|
||||||
|
.assessment-page .exam-nav-button,.assessment-page .exam-bottom-submit,.assessment-page .exam-submit-area button,.assessment-page--review .reader-review-pager button,.assessment-page--review .reader-show-evidence,.assessment-page--review .results-actions a{display:inline-flex;min-height:38px;align-items:center;justify-content:center;gap:7px;padding:0 13px;border:1px solid #b9cff0;border-radius:8px;color:var(--assessment-blue);background:#fff;font:inherit;font-size:12px;font-weight:700;line-height:1;text-decoration:none;box-shadow:none;cursor:pointer}
|
||||||
|
.assessment-page #question-next,.assessment-page .exam-bottom-submit,.assessment-page .exam-submit-area button,.assessment-page--review .reader-review-pager #reader-next,.assessment-page--review .results-actions .results-primary{color:#fff;border-color:var(--assessment-blue);background:var(--assessment-blue)}
|
||||||
|
.assessment-page #question-next:hover,.assessment-page .exam-bottom-submit:hover,.assessment-page .exam-submit-area button:hover,.assessment-page--review .reader-review-pager #reader-next:hover,.assessment-page--review .results-actions .results-primary:hover{border-color:#0e56ca;background:#0e56ca}
|
||||||
|
.assessment-page .exam-nav-button:hover:not(:disabled),.assessment-page--review .reader-review-pager button:hover:not(:disabled),.assessment-page--review .reader-show-evidence:hover,.assessment-page--review .results-actions a:hover{color:#0e56ca;border-color:#86afea;background:#f4f8ff}
|
||||||
|
.assessment-page .exam-nav-button:disabled,.assessment-page--review .reader-review-pager button:disabled{color:#93a1b4;border-color:#dce3ec;background:#f4f6f8;opacity:1;cursor:not-allowed}
|
||||||
|
.assessment-page--review .reader-show-evidence{width:max-content;margin-top:9px}.assessment-page--review .reader-show-evidence.is-unmatched{color:#6f7d90;border-color:#d6dee8;background:#f5f7fa;cursor:default}
|
||||||
|
@media(max-width:900px){.assessment-bar{gap:14px}.assessment-bar__title p,.assessment-bar__summary{display:none}.assessment-page--review .reader-review-head{height:78px;grid-template-columns:1fr auto}.assessment-page--review .reader-review-workspace{height:calc(100% - 140px)}.assessment-page--review .assessment-metric{display:none}}
|
||||||
|
@media(max-width:680px){.assessment-bar{min-height:68px;padding:10px 14px}.assessment-bar__icon,.assessment-page .exam-fullscreen-button span,.assessment-page .exam-retake-button span{display:none}.assessment-bar__title h1{max-width:42vw;font-size:14px}.assessment-page .exam-session-controls{gap:6px}.assessment-page .exam-fullscreen-button,.assessment-page .exam-retake-button{width:36px;padding:0}.assessment-page .exam-timer-wrap{min-width:74px;padding-inline:7px}.assessment-bar--results .assessment-bar__action button{width:38px;padding:0;font-size:0}.assessment-bar--results .assessment-bar__action button i{font-size:14px}.assessment-page--review .results-shell{width:min(100% - 28px,1080px);padding-top:22px}.assessment-page--review .results-header h2{font-size:21px}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.auth-page{min-height:680px;display:grid;place-items:center;padding:55px 20px;background:radial-gradient(circle at 15% 20%,#e8f1ff 0,transparent 28%),linear-gradient(135deg,#f8faff,#eef5ff)}.auth-card{width:100%;max-width:470px;padding:36px 38px;border:1px solid #e2e8f1;border-radius:16px;background:#fff;box-shadow:0 20px 55px rgba(16,33,62,.09)}.auth-card--compact{max-width:430px}.auth-card__header{text-align:center;margin-bottom:27px}.auth-card__icon{width:44px;height:44px;display:grid;place-items:center;margin:0 auto 15px;border-radius:11px;background:#1264e8;color:#fff;font-size:22px;box-shadow:0 8px 18px #1264e835}.auth-card h1{font-size:25px;font-weight:700;margin:0 0 7px;color:#10213e}.auth-card__header p{font-size:13px;color:#687487;margin:0}.auth-field{margin-bottom:17px}.auth-field label{display:block;margin-bottom:7px;font-size:13px;font-weight:600;color:#26354d}.auth-label-row{display:flex;justify-content:space-between;align-items:center}.auth-label-row a{font-size:12px;color:#1264e8;text-decoration:none}.auth-field input{width:100%;height:46px;border:1px solid #dce3ed;border-radius:9px;padding:0 13px;font-size:14px;color:#1c2b42;outline:0;transition:border .2s,box-shadow .2s}.auth-field input:focus{border-color:#1264e8;box-shadow:0 0 0 3px #1264e81c}.auth-check{display:flex;align-items:center;gap:7px;margin:2px 0 20px;font-size:12px;color:#566276}.auth-check input{accent-color:#1264e8}.auth-submit{width:100%;height:46px;border:0;border-radius:9px;background:#1264e8;color:#fff;font-size:14px;font-weight:600;box-shadow:0 8px 18px #1264e82b;transition:transform .2s,background .2s}.auth-submit:hover{background:#0f58cf;transform:translateY(-1px)}.auth-card__switch{text-align:center;margin:21px 0 0;font-size:12px;color:#687487}.auth-card__switch a{color:#1264e8;font-weight:600;text-decoration:none}.auth-error{font-size:11px;color:#c52c3a;margin:5px 0 0}.auth-alert{padding:10px 12px;margin-bottom:18px;border-radius:8px;background:#fff0f1;color:#a92735;font-size:12px}.auth-alert ul{margin:0;padding-left:17px}@media(max-width:500px){.auth-page{padding:35px 15px}.auth-card{padding:29px 22px}.auth-card h1{font-size:22px}}
|
||||||
|
.auth-submit--link{display:block;text-align:center;text-decoration:none}.auth-submit--link:hover{color:#fff}.auth-alert--info{color:#315f98;background:#eef5ff;border-color:#d5e6fb}
|
||||||
|
.email-address-list{display:grid;gap:8px;margin-bottom:12px}.email-address-item{display:flex;align-items:center;gap:10px;padding:11px;border:1px solid #e2e8f1;border-radius:9px;cursor:pointer}.email-address-item:has(input:checked){border-color:#72aef6;background:#f2f7ff}.email-address-item input{accent-color:#0871ef}.email-address-item strong,.email-address-item small{display:block}.email-address-item strong{font-size:.76rem}.email-address-item small{margin-top:2px;color:#7f8c9f;font-size:.62rem}.email-actions{display:flex;flex-wrap:wrap;gap:7px}.email-actions button{padding:7px 9px;border:1px solid #bcd4f2;border-radius:7px;color:#0871ef;background:#fff;font:inherit;font-size:.62rem;font-weight:600}.email-actions button.email-remove{color:#bf4248;border-color:#efc6c9}.auth-divider{display:flex;align-items:center;gap:9px;margin:22px 0 17px;color:#8793a5;font-size:.62rem}.auth-divider:before,.auth-divider:after{content:"";height:1px;background:#e5eaf1;flex:1}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.catalogue-grid--compact {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogue-compact-body h3 {
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogue-compact-tags > span,
|
||||||
|
.catalogue-compact-tags > b {
|
||||||
|
font-size: .62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogue-compact-tags em {
|
||||||
|
font-size: .64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogue-compact-body p {
|
||||||
|
font-size: .68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.catalogue-grid--compact {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.catalogue-grid--compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.catalogue-search-panel{margin-bottom:14px;padding:12px;border:1px solid #dce5ef;border-radius:11px;background:#fff}.catalogue-search-box{display:flex;height:42px;align-items:center;gap:10px;padding:0 12px;border:1px solid #d5dfeb;border-radius:8px;color:#718096}.catalogue-search-box:focus-within{border-color:#74acf0;box-shadow:0 0 0 3px rgba(8,113,239,.1)}.catalogue-search-box input{width:100%;border:0;outline:0;color:#243750;background:transparent;font:inherit;font-size:.75rem}.catalogue-part-filters{display:flex;align-items:center;gap:4px;margin-top:9px;color:#607089;font-size:.66rem}.catalogue-part-filters span{margin-right:3px}.catalogue-part-filters button{padding:5px 9px;border:0;border-radius:6px;color:#56667d;background:#f4f7fb;font:inherit;font-size:.65rem;cursor:pointer}.catalogue-part-filters button.is-active{color:#0871ef;background:#eaf3ff;font-weight:700}.catalogue-grid--compact{grid-template-columns:repeat(3,minmax(0,1fr));gap:11px}.catalogue-compact-card{display:flex;min-width:0;min-height:112px;align-items:center;gap:12px;padding:15px;border:1px solid #dce5ef;border-radius:11px;background:#fff;box-shadow:0 5px 15px rgba(16,33,62,.025)}.catalogue-compact-card:hover{border-color:#b8d4f6;box-shadow:0 9px 22px rgba(16,92,205,.08)}.catalogue-compact-icon{display:grid;width:44px;height:44px;flex:0 0 44px;place-items:center;border-radius:9px;color:#0871ef;background:#eaf3ff;font-size:1rem}.catalogue-compact-body{min-width:0;flex:1}.catalogue-compact-body h3{overflow:hidden;margin:0 0 7px;font-size:.76rem;line-height:1.4;text-overflow:ellipsis}.catalogue-compact-tags{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.catalogue-compact-tags>span,.catalogue-compact-tags>b{padding:3px 6px;border-radius:9px;color:#0871ef;background:#eaf3ff;font-size:.51rem;font-style:normal;font-weight:700;text-transform:uppercase}.catalogue-compact-tags>b{color:#176b78;background:#e7f4f7}.catalogue-compact-tags em{color:#7d8999;font-size:.54rem;font-style:normal}.catalogue-compact-tags em.is-complete{color:#168d4a}.catalogue-compact-tags em.is-progress{color:#bd6816}.catalogue-compact-body p{margin:7px 0 0;color:#758398;font-size:.57rem}.catalogue-compact-body p i{margin-right:3px;color:#0871ef}.catalogue-compact-body p span{margin:0 3px}.catalogue-compact-action{display:grid;width:30px;height:40px;flex:0 0 30px;place-items:center;color:#607089;text-decoration:none}.catalogue-compact-action:hover{color:#0871ef}.catalogue-search-empty{padding:35px;border:1px solid #dce5ef;border-radius:11px;background:#fff;text-align:center}.catalogue-search-empty i,.catalogue-search-empty strong,.catalogue-search-empty span{display:block}.catalogue-search-empty i{margin-bottom:7px;color:#0871ef;font-size:1.1rem}.catalogue-search-empty strong{font-size:.8rem}.catalogue-search-empty span{margin-top:4px;color:#7b899c;font-size:.65rem}@media(max-width:1180px){.catalogue-grid--compact{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.catalogue-grid--compact{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:760px){.catalogue-grid--compact{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:520px){.catalogue-grid--compact{grid-template-columns:1fr}.catalogue-search-panel{padding:10px}.catalogue-compact-card{min-height:98px;padding:13px}.catalogue-compact-icon{width:40px;height:40px;flex-basis:40px}}
|
||||||
|
.catalogue-compact-card{position:relative}.catalogue-premium-mark{position:absolute;top:8px;right:9px;display:inline-flex;align-items:center;gap:3px;padding:3px 6px;border-radius:7px;color:#7b4d08;background:#fff2d5;font-size:.49rem;font-weight:750}.catalogue-compact-action.is-locked{color:#7b4d08;background:#fff5df}.catalogue-compact-action.is-locked:hover{color:#5d3903;background:#ffe9b5}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user