From 402c3ec6ede1500473c519bb23410c01451e6ab6 Mon Sep 17 00:00:00 2001 From: kddd21a Date: Wed, 29 Jul 2026 10:47:40 +0000 Subject: [PATCH] Initial commit --- .dockerignore | 14 + .env.example | 35 + .gitignore | 48 + CPANEL_DEPLOYMENT.md | 98 ++ DEPLOYMENT.md | 49 + Dockerfile | 22 + PROFESSIONAL_REVIEW.md | 44 + Procfile | 1 + README.md | 50 ++ accounts/__init__.py | 0 accounts/access.py | 20 + accounts/admin.py | 62 ++ accounts/apps.py | 9 + accounts/forms.py | 65 ++ accounts/leaderboard.py | 130 +++ accounts/management/__init__.py | 1 + accounts/management/commands/__init__.py | 1 + .../commands/grant_lifetime_premium.py | 50 ++ accounts/migrations/0001_initial.py | 28 + .../0002_studentprofile_last_activity_date.py | 18 + accounts/migrations/0003_dailymissionclaim.py | 26 + .../migrations/0004_premiumentitlement.py | 51 ++ ...005_studentprofile_leaderboard_identity.py | 20 + accounts/migrations/__init__.py | 0 accounts/models.py | 73 ++ accounts/signals.py | 11 + accounts/templates/accounts/leaderboard.html | 62 ++ accounts/templates/accounts/premium.html | 54 ++ .../templates/accounts/student_dashboard.html | 165 ++++ .../templates/accounts/student_settings.html | 54 ++ accounts/tests.py | 347 ++++++++ accounts/urls.py | 11 + accounts/views.py | 414 +++++++++ build_tuatara_workbook.mjs | 103 +++ compose.yaml | 44 + configmap.yaml | 11 + core/__init__.py | 7 + core/admin_dashboard.py | 80 ++ core/asgi.py | 16 + core/context_processors.py | 14 + core/forms.py | 15 + core/settings.py | 322 +++++++ core/tests.py | 150 ++++ core/urls.py | 25 + core/views.py | 117 +++ core/wsgi.py | 16 + deployment.yaml | 46 + entrypoint.sh | 12 + exams/__init__.py | 0 exams/admin.py | 428 +++++++++ exams/apps.py | 5 + exams/excel_importer.py | 277 ++++++ exams/forms.py | 172 ++++ exams/grading.py | 28 + exams/html_importer.py | 343 +++++++ exams/management/__init__.py | 1 + exams/management/commands/__init__.py | 1 + .../commands/seed_diagnostic_exam.py | 125 +++ .../commands/seed_practice_library.py | 210 +++++ exams/migrations/0001_initial.py | 69 ++ exams/migrations/0002_section_passage_text.py | 18 + ...ue_answer_per_attempt_question_and_more.py | 23 + ..._options_alter_section_options_and_more.py | 34 + ...studentattempt_current_section_and_more.py | 29 + ...scription_examset_is_published_and_more.py | 29 + ..._explanation_question_passage_reference.py | 18 + .../0007_studentanswer_audio_response.py | 19 + exams/migrations/0008_examset_category.py | 18 + exams/migrations/0009_examset_exact_html.py | 22 + ...0_questiongroup_question_group_and_more.py | 39 + exams/migrations/0011_examset_access_level.py | 23 + exams/migrations/__init__.py | 0 exams/models.py | 198 +++++ .../dashboard/student_dashboard.html | 759 ++++++++++++++++ exams/templates/exams/exact_exam.html | 3 + exams/templates/exams/exam_detail.html | 56 ++ exams/templates/exams/exam_list.html | 82 ++ exams/templates/exams/results.html | 62 ++ exams/templates/exams/take_section.html | 448 ++++++++++ exams/templatetags/__init__.py | 1 + exams/templatetags/exam_content.py | 56 ++ exams/tests.py | 837 ++++++++++++++++++ exams/urls.py | 14 + exams/views.py | 486 ++++++++++ ingress.yaml | 26 + manage.py | 22 + migrate-job.yaml | 20 + passenger_wsgi.py | 14 + postgres.yaml | 63 ++ requirements.txt | 13 + scripts/backup_docker.ps1 | 21 + scripts/backup_docker.sh | 18 + scripts/backup_local.ps1 | 17 + scripts/check_deployment.ps1 | 20 + service.yaml | 14 + static/css/accessibility.css | 1 + static/css/assessment-workspace.css | 35 + static/css/auth.css | 3 + static/css/catalogue-card-type.css | 33 + static/css/catalogue-compact.css | 2 + static/css/catalogue-density.css | 1 + static/css/contact.css | 1 + static/css/daily-missions.css | 1 + static/css/dashboard.css | 25 + static/css/exact-exam.css | 1 + static/css/exam-catalogue.css | 5 + static/css/exam-content-typography.css | 197 +++++ static/css/exam-layout-polish.css | 7 + static/css/exam-retake.css | 1 + static/css/exam-session.css | 13 + static/css/exams.css | 0 static/css/footer.css | 5 + static/css/grouped-questions.css | 3 + static/css/hero-device.css | 1 + static/css/hero.css | 10 + static/css/how-it-works.css | 56 ++ static/css/ielts-question-paper.css | 105 +++ static/css/landing-v2.css | 713 +++++++++++++++ static/css/leaderboard.css | 15 + static/css/listening-real-exam.css | 172 ++++ static/css/listening-worksheet.css | 4 + static/css/mobile.css | 1 + static/css/navbar.css | 40 + static/css/premium.css | 1 + static/css/progress-analytics.css | 1 + static/css/reading-review.css | 24 + static/css/reference.css | 1 + static/css/review-mode.css | 1 + static/css/selection-annotations.css | 1 + static/css/site-polish.css | 1 + static/css/skill-navigation.css | 1 + static/css/speaking-recorder.css | 1 + static/css/style.css | 52 ++ static/css/unfold-dashboard.css | 36 + static/images/dashboard-real.png | Bin 0 -> 441600 bytes static/images/favicon.svg | 1 + static/js/catalogue-search.js | 29 + static/js/reading-review.js | 85 ++ templates/404.html | 1 + templates/500.html | 1 + templates/account/email.html | 5 + templates/account/email_confirm.html | 3 + templates/account/login.html | 12 + templates/account/logout.html | 3 + templates/account/password_change.html | 3 + templates/account/password_reset.html | 3 + templates/account/password_reset_done.html | 3 + .../account/password_reset_from_key.html | 3 + .../account/password_reset_from_key_done.html | 3 + templates/account/signup.html | 13 + templates/account/verification_sent.html | 3 + .../admin/exams/examset/import_excel.html | 49 + .../admin/exams/examset/import_html.html | 51 ++ templates/admin/index.html | 54 ++ templates/base.html | 48 + templates/components/available_tests.html | 11 + templates/components/cta.html | 11 + templates/components/features.html | 11 + templates/components/footer.html | 14 + templates/components/hero.html | 15 + templates/components/how_it_works.html | 11 + templates/components/leaderboard_preview.html | 21 + templates/components/navbar.html | 30 + templates/components/progress.html | 135 +++ templates/components/sidebar.html | 74 ++ templates/contact.html | 14 + templates/faq.html | 12 + templates/home.html | 16 + templates/privacy.html | 11 + templates/sitemap.xml | 4 + templates/terms.html | 10 + 171 files changed, 10536 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CPANEL_DEPLOYMENT.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 PROFESSIONAL_REVIEW.md create mode 100644 Procfile create mode 100644 README.md create mode 100644 accounts/__init__.py create mode 100644 accounts/access.py create mode 100644 accounts/admin.py create mode 100644 accounts/apps.py create mode 100644 accounts/forms.py create mode 100644 accounts/leaderboard.py create mode 100644 accounts/management/__init__.py create mode 100644 accounts/management/commands/__init__.py create mode 100644 accounts/management/commands/grant_lifetime_premium.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/0002_studentprofile_last_activity_date.py create mode 100644 accounts/migrations/0003_dailymissionclaim.py create mode 100644 accounts/migrations/0004_premiumentitlement.py create mode 100644 accounts/migrations/0005_studentprofile_leaderboard_identity.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/signals.py create mode 100644 accounts/templates/accounts/leaderboard.html create mode 100644 accounts/templates/accounts/premium.html create mode 100644 accounts/templates/accounts/student_dashboard.html create mode 100644 accounts/templates/accounts/student_settings.html create mode 100644 accounts/tests.py create mode 100644 accounts/urls.py create mode 100644 accounts/views.py create mode 100644 build_tuatara_workbook.mjs create mode 100644 compose.yaml create mode 100644 configmap.yaml create mode 100644 core/__init__.py create mode 100644 core/admin_dashboard.py create mode 100644 core/asgi.py create mode 100644 core/context_processors.py create mode 100644 core/forms.py create mode 100644 core/settings.py create mode 100644 core/tests.py create mode 100644 core/urls.py create mode 100644 core/views.py create mode 100644 core/wsgi.py create mode 100644 deployment.yaml create mode 100644 entrypoint.sh create mode 100644 exams/__init__.py create mode 100644 exams/admin.py create mode 100644 exams/apps.py create mode 100644 exams/excel_importer.py create mode 100644 exams/forms.py create mode 100644 exams/grading.py create mode 100644 exams/html_importer.py create mode 100644 exams/management/__init__.py create mode 100644 exams/management/commands/__init__.py create mode 100644 exams/management/commands/seed_diagnostic_exam.py create mode 100644 exams/management/commands/seed_practice_library.py create mode 100644 exams/migrations/0001_initial.py create mode 100644 exams/migrations/0002_section_passage_text.py create mode 100644 exams/migrations/0003_studentanswer_unique_answer_per_attempt_question_and_more.py create mode 100644 exams/migrations/0004_alter_question_options_alter_section_options_and_more.py create mode 100644 exams/migrations/0005_studentattempt_current_section_and_more.py create mode 100644 exams/migrations/0006_examset_description_examset_is_published_and_more.py create mode 100644 exams/migrations/0007_question_explanation_question_passage_reference.py create mode 100644 exams/migrations/0007_studentanswer_audio_response.py create mode 100644 exams/migrations/0008_examset_category.py create mode 100644 exams/migrations/0009_examset_exact_html.py create mode 100644 exams/migrations/0010_questiongroup_question_group_and_more.py create mode 100644 exams/migrations/0011_examset_access_level.py create mode 100644 exams/migrations/__init__.py create mode 100644 exams/models.py create mode 100644 exams/templates/dashboard/student_dashboard.html create mode 100644 exams/templates/exams/exact_exam.html create mode 100644 exams/templates/exams/exam_detail.html create mode 100644 exams/templates/exams/exam_list.html create mode 100644 exams/templates/exams/results.html create mode 100644 exams/templates/exams/take_section.html create mode 100644 exams/templatetags/__init__.py create mode 100644 exams/templatetags/exam_content.py create mode 100644 exams/tests.py create mode 100644 exams/urls.py create mode 100644 exams/views.py create mode 100644 ingress.yaml create mode 100644 manage.py create mode 100644 migrate-job.yaml create mode 100644 passenger_wsgi.py create mode 100644 postgres.yaml create mode 100644 requirements.txt create mode 100644 scripts/backup_docker.ps1 create mode 100644 scripts/backup_docker.sh create mode 100644 scripts/backup_local.ps1 create mode 100644 scripts/check_deployment.ps1 create mode 100644 service.yaml create mode 100644 static/css/accessibility.css create mode 100644 static/css/assessment-workspace.css create mode 100644 static/css/auth.css create mode 100644 static/css/catalogue-card-type.css create mode 100644 static/css/catalogue-compact.css create mode 100644 static/css/catalogue-density.css create mode 100644 static/css/contact.css create mode 100644 static/css/daily-missions.css create mode 100644 static/css/dashboard.css create mode 100644 static/css/exact-exam.css create mode 100644 static/css/exam-catalogue.css create mode 100644 static/css/exam-content-typography.css create mode 100644 static/css/exam-layout-polish.css create mode 100644 static/css/exam-retake.css create mode 100644 static/css/exam-session.css create mode 100644 static/css/exams.css create mode 100644 static/css/footer.css create mode 100644 static/css/grouped-questions.css create mode 100644 static/css/hero-device.css create mode 100644 static/css/hero.css create mode 100644 static/css/how-it-works.css create mode 100644 static/css/ielts-question-paper.css create mode 100644 static/css/landing-v2.css create mode 100644 static/css/leaderboard.css create mode 100644 static/css/listening-real-exam.css create mode 100644 static/css/listening-worksheet.css create mode 100644 static/css/mobile.css create mode 100644 static/css/navbar.css create mode 100644 static/css/premium.css create mode 100644 static/css/progress-analytics.css create mode 100644 static/css/reading-review.css create mode 100644 static/css/reference.css create mode 100644 static/css/review-mode.css create mode 100644 static/css/selection-annotations.css create mode 100644 static/css/site-polish.css create mode 100644 static/css/skill-navigation.css create mode 100644 static/css/speaking-recorder.css create mode 100644 static/css/style.css create mode 100644 static/css/unfold-dashboard.css create mode 100644 static/images/dashboard-real.png create mode 100644 static/images/favicon.svg create mode 100644 static/js/catalogue-search.js create mode 100644 static/js/reading-review.js create mode 100644 templates/404.html create mode 100644 templates/500.html create mode 100644 templates/account/email.html create mode 100644 templates/account/email_confirm.html create mode 100644 templates/account/login.html create mode 100644 templates/account/logout.html create mode 100644 templates/account/password_change.html create mode 100644 templates/account/password_reset.html create mode 100644 templates/account/password_reset_done.html create mode 100644 templates/account/password_reset_from_key.html create mode 100644 templates/account/password_reset_from_key_done.html create mode 100644 templates/account/signup.html create mode 100644 templates/account/verification_sent.html create mode 100644 templates/admin/exams/examset/import_excel.html create mode 100644 templates/admin/exams/examset/import_html.html create mode 100644 templates/admin/index.html create mode 100644 templates/base.html create mode 100644 templates/components/available_tests.html create mode 100644 templates/components/cta.html create mode 100644 templates/components/features.html create mode 100644 templates/components/footer.html create mode 100644 templates/components/hero.html create mode 100644 templates/components/how_it_works.html create mode 100644 templates/components/leaderboard_preview.html create mode 100644 templates/components/navbar.html create mode 100644 templates/components/progress.html create mode 100644 templates/components/sidebar.html create mode 100644 templates/contact.html create mode 100644 templates/faq.html create mode 100644 templates/home.html create mode 100644 templates/privacy.html create mode 100644 templates/sitemap.xml create mode 100644 templates/terms.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c85628 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.env +.venv +venv +__pycache__ +*.py[cod] +*.sqlite3 +*.zip +staticfiles +media +.idea +.vscode +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..02cfeca --- /dev/null +++ b/.env.example @@ -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 +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cd90b3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CPANEL_DEPLOYMENT.md b/CPANEL_DEPLOYMENT.md new file mode 100644 index 0000000..8f87ce8 --- /dev/null +++ b/CPANEL_DEPLOYMENT.md @@ -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 +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. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..70ce4ac --- /dev/null +++ b/DEPLOYMENT.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a732e8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/PROFESSIONAL_REVIEW.md b/PROFESSIONAL_REVIEW.md new file mode 100644 index 0000000..ee6128e --- /dev/null +++ b/PROFESSIONAL_REVIEW.md @@ -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. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..d024a71 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn core.wsgi:application --bind 0.0.0.0:$PORT --workers 3 --timeout 120 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee86260 --- /dev/null +++ b/README.md @@ -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 diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/access.py b/accounts/access.py new file mode 100644 index 0000000..4d161e5 --- /dev/null +++ b/accounts/access.py @@ -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) + ) diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..f050ee5 --- /dev/null +++ b/accounts/admin.py @@ -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",) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..9c21671 --- /dev/null +++ b/accounts/apps.py @@ -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 \ No newline at end of file diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..99900fa --- /dev/null +++ b/accounts/forms.py @@ -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 diff --git a/accounts/leaderboard.py b/accounts/leaderboard.py new file mode 100644 index 0000000..07b2c5d --- /dev/null +++ b/accounts/leaderboard.py @@ -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 diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/accounts/management/__init__.py @@ -0,0 +1 @@ + diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/accounts/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/accounts/management/commands/grant_lifetime_premium.py b/accounts/management/commands/grant_lifetime_premium.py new file mode 100644 index 0000000..b512f04 --- /dev/null +++ b/accounts/management/commands/grant_lifetime_premium.py @@ -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}.") + ) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..93794d6 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/accounts/migrations/0002_studentprofile_last_activity_date.py b/accounts/migrations/0002_studentprofile_last_activity_date.py new file mode 100644 index 0000000..e20b26b --- /dev/null +++ b/accounts/migrations/0002_studentprofile_last_activity_date.py @@ -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), + ), + ] diff --git a/accounts/migrations/0003_dailymissionclaim.py b/accounts/migrations/0003_dailymissionclaim.py new file mode 100644 index 0000000..9ee7776 --- /dev/null +++ b/accounts/migrations/0003_dailymissionclaim.py @@ -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"), + ), + ] diff --git a/accounts/migrations/0004_premiumentitlement.py b/accounts/migrations/0004_premiumentitlement.py new file mode 100644 index 0000000..cfcb2f5 --- /dev/null +++ b/accounts/migrations/0004_premiumentitlement.py @@ -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, + ), + ), + ], + ), + ] diff --git a/accounts/migrations/0005_studentprofile_leaderboard_identity.py b/accounts/migrations/0005_studentprofile_leaderboard_identity.py new file mode 100644 index 0000000..e8d5ab8 --- /dev/null +++ b/accounts/migrations/0005_studentprofile_leaderboard_identity.py @@ -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), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..67c4d68 --- /dev/null +++ b/accounts/models.py @@ -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}" diff --git a/accounts/signals.py b/accounts/signals.py new file mode 100644 index 0000000..5a19fb3 --- /dev/null +++ b/accounts/signals.py @@ -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) \ No newline at end of file diff --git a/accounts/templates/accounts/leaderboard.html b/accounts/templates/accounts/leaderboard.html new file mode 100644 index 0000000..4ec856d --- /dev/null +++ b/accounts/templates/accounts/leaderboard.html @@ -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 %}{% endblock %} +{% block content %} +
+
+ + +
+
+

Leaderboard

See how you rank, track your progress, and challenge yourself to reach your target band.

+
+ +
+
Your rank{% if current_row %}#{{ current_row.rank }}{% else %}—{% endif %}{% if current_row %}out of {{ rows|length }} students{% else %}Complete a scored test to join{% endif %}
+
Band score{% if current_row %}{{ current_row.band|floatformat:1 }}{% else %}—{% endif %} Keep improving each week
+
Tests completed
{{ completed_tests }}
Current streak
{{ profile.streak }} day{{ profile.streak|pluralize }}
+

{% if current_row %}You're making progress. Keep practicing to climb the leaderboard.{% else %}Your first completed mock test will establish your rank.{% endif %}

+
+ +
+ + +
+ + + + + {% for student in rows %} + + + + + + + + {% endfor %} + +
Current standings
RankStudentBand scoreTestsTrend
#{{ student.rank }}{{ student.initial }}{{ student.name }}{% if student.is_current %} (You){% endif %}{% if student.demo %}Preview{% endif %}{{ student.band|floatformat:1 }}{{ student.tests }}{{ student.trend }}
+
+
+
+
+
+{% endblock %} diff --git a/accounts/templates/accounts/premium.html b/accounts/templates/accounts/premium.html new file mode 100644 index 0000000..f675bb9 --- /dev/null +++ b/accounts/templates/accounts/premium.html @@ -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 %}{% endblock %} + +{% block content %} +
+
+
+ Back to tests + {% if has_lifetime_premium %} +
Lifetime Premium is active
+ {% else %} +
One-time access
+ {% endif %} +

One payment.
Every test, forever.

+

Unlock the complete Testpoint library—including every Premium test we add in the future. No subscription, renewal date, or recurring fee.

+
    +
  • Complete test libraryAccess Reading, Listening, Writing, Speaking, and Full Mock tests.
  • +
  • Unlimited practiceRepeat tests and review your results whenever you need.
  • +
  • Future content includedNew Premium tests are added to your account automatically.
  • +
+
+ +
+
+
One purchaseNo subscription contract
+
Account-linkedYour access follows your account
+
Admin verifiedEvery activation is recorded
+
+
+{% endblock %} diff --git a/accounts/templates/accounts/student_dashboard.html b/accounts/templates/accounts/student_dashboard.html new file mode 100644 index 0000000..b9865ac --- /dev/null +++ b/accounts/templates/accounts/student_dashboard.html @@ -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 %}{% endblock %} + +{% block content %} +
+
+ + +
+
+
+ +

Welcome back, {{ request.user.first_name|default:request.user.username }}!

+

Here’s where your practice stands today.

+
+ Browse tests +
+ +
+
+ +
Tests completed{{ tests_taken }}
+
+
+ +
Answer accuracy{{ accuracy }}%
+
+
+ +
Average test time{{ avg_time }}
+
+
+ +
Current streak{{ profile.streak }} day{{ profile.streak|pluralize }}
+
+
+ +
+
Today’s study path

Daily missions

Complete missions and claim bonus XP for consistent practice.

Resets daily
+
+ {% for mission in daily_missions %} +
+ +

{{ mission.title }}

+{{ mission.xp }} XP

{{ mission.description }}

{{ mission.progress }} / {{ mission.target }}
+ {% if mission.claimed %} Claimed{% elif mission.complete %}
{% csrf_token %}
{% else %}In progress{% endif %} +
+ {% endfor %} +
+
+ +
+
+
+
Performance

Skills overview

+ Based on graded answers +
+
+ {% for skill in skills %} +
+ +
+
{{ skill.name }}{{ skill.value }}%
+
+ +
+
+
+ {% endfor %} +
+
+ + +
+ +
+
Progress analytics

Learn from every attempt

These insights are calculated from your completed tests and graded answers.

{{ total_study_minutes }} minute{{ total_study_minutes|pluralize }} studied
+
+
+
Performance trend

Recent results

{% if trend_change is not None %} {{ trend_change|floatformat:0 }}%{% endif %}
+ {% if trend_attempts %}{% else %}

Complete graded tests to see your performance trend.

{% endif %} +
+
+
Question types

Accuracy breakdown

+ {% if question_type_accuracy %}
{% for item in question_type_accuracy %}
{{ item.name }}{{ item.value }}%
{% endfor %}
{% else %}

Objective answers will appear here once they are graded.

{% endif %} +
+
+
Focus area

Where to improve

+ {% if weakest_skill %}
{{ weakest_skill.name }}

{{ weakest_skill.value }}% current score

Prioritise {{ weakest_skill.name|lower }} practice to lift your overall result.

Practice {{ weakest_skill.name }} {% else %}

Your focus area will appear after graded answers are available.

{% endif %} +
+
+
Consistency

Study streak

{{ profile.streak }} day{{ profile.streak|pluralize }}
+
{% for day in streak_days %}{{ day.label }}{% endfor %}

Complete a test today to keep your practice habit active.

+
+
+
+ +
+
+
History

Recent tests

+ View all +
+ {% if recent_attempts %} +
+ {% for item in recent_attempts %} +
+ +
{{ item.attempt.exam_set.title }}Completed {{ item.attempt.submitted_at|date:"M j, Y" }}
+
Time{{ item.duration }}
+
Score{% if item.score is not None %}{{ item.score }}%{% else %}Pending{% endif %}
+ +
+ {% endfor %} +
+ {% else %} +
+ +

No completed tests yet

+

Your scores and test history will appear here after your first completed mock test.

+ Explore available tests +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/accounts/templates/accounts/student_settings.html b/accounts/templates/accounts/student_settings.html new file mode 100644 index 0000000..e89d224 --- /dev/null +++ b/accounts/templates/accounts/student_settings.html @@ -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 %}{% endblock %} + +{% block content %} +
+
+
+ Back to dashboard + Account +

Profile settings

+

Keep your personal details and IELTS study goals up to date.

+
+ +
+ + +
+

Personal information

Your username cannot be changed.

+
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} +
+
{{ form.first_name }}{{ form.first_name.errors }}
+
{{ form.last_name }}{{ form.last_name.errors }}
+
+
+
{{ form.display_name }}{{ form.display_name.help_text }}{{ form.display_name.errors }}
+
{{ form.country }}Optional. Used for leaderboard country filters.{{ form.country.errors }}
+
+ + +
+

Study goals

These values appear on your student dashboard.

+
+
{{ form.target_band }}Choose a score from 4.0 to 9.0 in 0.5-band increments.{{ form.target_band.errors }}
+
{{ form.daily_goal }}Between 10 and 300 minutes.{{ form.daily_goal.errors }}
+
+
Cancel
+
+
+
+
+
+{% endblock %} diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..90d3b4d --- /dev/null +++ b/accounts/tests.py @@ -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") diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..ac34f05 --- /dev/null +++ b/accounts/urls.py @@ -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//claim/", views.claim_daily_mission, name="claim_daily_mission"), + path("", include("allauth.urls")), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..37ff091 --- /dev/null +++ b/accounts/views.py @@ -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}, + ) diff --git a/build_tuatara_workbook.mjs b/build_tuatara_workbook.mjs new file mode 100644 index 0000000..838d3e6 --- /dev/null +++ b/build_tuatara_workbook.mjs @@ -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 = `

What conclusions can we draw?

The most important result of the tuatara research is that it frees our [[36]].

For example, there are many similarities between rats and [[37]]. Should we now go further and consider reintroducing [[38]] to our mainland rivers?

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.

A natural evolution

B creative thought

C indigenous plants

D trout

E pollution

F restoration

G native fish

H extinction

`; +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?","

Questions 36–40

Complete the summary using the list of words, A–H, below.

Write the correct letter, A–H, in boxes 36–40.

",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`); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..0e1291b --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/configmap.yaml b/configmap.yaml new file mode 100644 index 0000000..d4d8c03 --- /dev/null +++ b/configmap.yaml @@ -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" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..c02c0d9 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,7 @@ +try: + import pymysql +except ImportError: + pymysql = None + +if pymysql is not None: + pymysql.install_as_MySQLdb() diff --git a/core/admin_dashboard.py b/core/admin_dashboard.py new file mode 100644 index 0000000..b8c6d74 --- /dev/null +++ b/core/admin_dashboard.py @@ -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 diff --git a/core/asgi.py b/core/asgi.py new file mode 100644 index 0000000..9b89a2c --- /dev/null +++ b/core/asgi.py @@ -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() diff --git a/core/context_processors.py b/core/context_processors.py new file mode 100644 index 0000000..379e310 --- /dev/null +++ b/core/context_processors.py @@ -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), + } diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..f1a5ecc --- /dev/null +++ b/core/forms.py @@ -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 diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..f45c4c0 --- /dev/null +++ b/core/settings.py @@ -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 ') +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, + }, + }, +} diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..b2222ca --- /dev/null +++ b/core/tests.py @@ -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"]) diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..bc90ca1 --- /dev/null +++ b/core/urls.py @@ -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) diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..d5b3667 --- /dev/null +++ b/core/views.py @@ -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", + ) diff --git a/core/wsgi.py b/core/wsgi.py new file mode 100644 index 0000000..835c123 --- /dev/null +++ b/core/wsgi.py @@ -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() diff --git a/deployment.yaml b/deployment.yaml new file mode 100644 index 0000000..c2786e1 --- /dev/null +++ b/deployment.yaml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..fc45112 --- /dev/null +++ b/entrypoint.sh @@ -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 - diff --git a/exams/__init__.py b/exams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exams/admin.py b/exams/admin.py new file mode 100644 index 0000000..693725a --- /dev/null +++ b/exams/admin.py @@ -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 " 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.", "

Urban farming in Paris

Farm layout and production

  • Vertical tubes grow strawberries, [[1]] and herbs.
  • The daily harvest may reach [[2]] in weight.
"]) + 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 diff --git a/exams/forms.py b/exams/forms.py new file mode 100644 index 0000000..fc2dfde --- /dev/null +++ b/exams/forms.py @@ -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 diff --git a/exams/grading.py b/exams/grading.py new file mode 100644 index 0000000..987c306 --- /dev/null +++ b/exams/grading.py @@ -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 diff --git a/exams/html_importer.py b/exams/html_importer.py new file mode 100644 index 0000000..9a6c13e --- /dev/null +++ b/exams/html_importer.py @@ -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.*?)\}\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 diff --git a/exams/management/__init__.py b/exams/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/exams/management/__init__.py @@ -0,0 +1 @@ + diff --git a/exams/management/commands/__init__.py b/exams/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/exams/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/exams/management/commands/seed_diagnostic_exam.py b/exams/management/commands/seed_diagnostic_exam.py new file mode 100644 index 0000000..e5a9fb0 --- /dev/null +++ b/exams/management/commands/seed_diagnostic_exam.py @@ -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.")) diff --git a/exams/management/commands/seed_practice_library.py b/exams/management/commands/seed_practice_library.py new file mode 100644 index 0000000..a553366 --- /dev/null +++ b/exams/management/commands/seed_practice_library.py @@ -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)." + ) + ) diff --git a/exams/migrations/0001_initial.py b/exams/migrations/0001_initial.py new file mode 100644 index 0000000..7ec84e3 --- /dev/null +++ b/exams/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/exams/migrations/0002_section_passage_text.py b/exams/migrations/0002_section_passage_text.py new file mode 100644 index 0000000..755bc79 --- /dev/null +++ b/exams/migrations/0002_section_passage_text.py @@ -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), + ), + ] diff --git a/exams/migrations/0003_studentanswer_unique_answer_per_attempt_question_and_more.py b/exams/migrations/0003_studentanswer_unique_answer_per_attempt_question_and_more.py new file mode 100644 index 0000000..3a36f60 --- /dev/null +++ b/exams/migrations/0003_studentanswer_unique_answer_per_attempt_question_and_more.py @@ -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'), + ), + ] diff --git a/exams/migrations/0004_alter_question_options_alter_section_options_and_more.py b/exams/migrations/0004_alter_question_options_alter_section_options_and_more.py new file mode 100644 index 0000000..26b39b4 --- /dev/null +++ b/exams/migrations/0004_alter_question_options_alter_section_options_and_more.py @@ -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'), + ), + ] diff --git a/exams/migrations/0005_studentattempt_current_section_and_more.py b/exams/migrations/0005_studentattempt_current_section_and_more.py new file mode 100644 index 0000000..4459c36 --- /dev/null +++ b/exams/migrations/0005_studentattempt_current_section_and_more.py @@ -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), + ), + ] diff --git a/exams/migrations/0006_examset_description_examset_is_published_and_more.py b/exams/migrations/0006_examset_description_examset_is_published_and_more.py new file mode 100644 index 0000000..e93b146 --- /dev/null +++ b/exams/migrations/0006_examset_description_examset_is_published_and_more.py @@ -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)]), + ), + ] diff --git a/exams/migrations/0007_question_explanation_question_passage_reference.py b/exams/migrations/0007_question_explanation_question_passage_reference.py new file mode 100644 index 0000000..8911ad8 --- /dev/null +++ b/exams/migrations/0007_question_explanation_question_passage_reference.py @@ -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."), + ), + ] diff --git a/exams/migrations/0007_studentanswer_audio_response.py b/exams/migrations/0007_studentanswer_audio_response.py new file mode 100644 index 0000000..0fb080b --- /dev/null +++ b/exams/migrations/0007_studentanswer_audio_response.py @@ -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'])]), + ), + ] diff --git a/exams/migrations/0008_examset_category.py b/exams/migrations/0008_examset_category.py new file mode 100644 index 0000000..23661c5 --- /dev/null +++ b/exams/migrations/0008_examset_category.py @@ -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), + ), + ] diff --git a/exams/migrations/0009_examset_exact_html.py b/exams/migrations/0009_examset_exact_html.py new file mode 100644 index 0000000..952624d --- /dev/null +++ b/exams/migrations/0009_examset_exact_html.py @@ -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), + ), + ] diff --git a/exams/migrations/0010_questiongroup_question_group_and_more.py b/exams/migrations/0010_questiongroup_question_group_and_more.py new file mode 100644 index 0000000..1cc2f57 --- /dev/null +++ b/exams/migrations/0010_questiongroup_question_group_and_more.py @@ -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'), + ), + ] diff --git a/exams/migrations/0011_examset_access_level.py b/exams/migrations/0011_examset_access_level.py new file mode 100644 index 0000000..6954a17 --- /dev/null +++ b/exams/migrations/0011_examset_access_level.py @@ -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, + ), + ), + ] diff --git a/exams/migrations/__init__.py b/exams/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exams/models.py b/exams/models.py new file mode 100644 index 0000000..69fb480 --- /dev/null +++ b/exams/models.py @@ -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", + ), + ] diff --git a/exams/templates/dashboard/student_dashboard.html b/exams/templates/dashboard/student_dashboard.html new file mode 100644 index 0000000..6c5d9d3 --- /dev/null +++ b/exams/templates/dashboard/student_dashboard.html @@ -0,0 +1,759 @@ + + + + + + + + + + + + + + + + + + +
+ +

 

+ +
+ + + + diff --git a/exams/templates/exams/exact_exam.html b/exams/templates/exams/exact_exam.html new file mode 100644 index 0000000..7e98abd --- /dev/null +++ b/exams/templates/exams/exact_exam.html @@ -0,0 +1,3 @@ +{% load static %} +{{ exam_set.title }} | Testpoint +
Exact HTML test{{ exam_set.title }}
{% csrf_token %}
diff --git a/exams/templates/exams/exam_detail.html b/exams/templates/exams/exam_detail.html new file mode 100644 index 0000000..bdd9196 --- /dev/null +++ b/exams/templates/exams/exam_detail.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 %}{% endblock %} +{% block content %} +
+
+ Back to tests +
+
+ {% if exam_set.access_level == 'premium' %} Lifetime Premium{% else %}IELTS practice test{% endif %} +

{{ exam_set.title }}

+

{{ exam_set.description|default:"A timed IELTS practice test designed to help you understand your current performance." }}

+ {% if exam_set.delivery_mode == 'exact_html' %}

Original HTML test: this opens with the uploaded design, layout, questions, and controls intact.

{% endif %} +
+ {% if exam_set.delivery_mode == 'exact_html' %} +
Exact HTML
+
Original layout
+
Full screen
+ {% else %} +
{{ sections|length }} sections
+
{{ question_count }} questions
+
{{ total_minutes }} minutes
+ {% endif %} +
+ {% if exam_set.delivery_mode != 'exact_html' %}

Test sections

+
+ {% for section in sections %} +
{{ forloop.counter }}
{{ section.get_section_type_display }}{{ section.questions.count }} question{{ section.questions.count|pluralize }} · {{ section.time_limit_minutes }} minutes
+ {% empty %}

No sections have been published for this test.

{% endfor %} +
{% endif %} +
+ +
+
+
+{% endblock %} diff --git a/exams/templates/exams/exam_list.html b/exams/templates/exams/exam_list.html new file mode 100644 index 0000000..374d2f3 --- /dev/null +++ b/exams/templates/exams/exam_list.html @@ -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 %}{% endblock %} + +{% block content %} +
+
+ Practice centre +

IELTS Practice Tests

+

Choose a test, practise under realistic time limits, and review your results.

+
+
{{ total }}Available test{{ total|pluralize }}
+
{{ counts.in_progress }}In progress
+
{{ counts.completed }}Completed
+
+
+
+ +
+
+ + +
+
+
Test library

{% 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 %}

+ Dashboard +
+ + {% if exam_data %} +
+ +
Part:
+
+
+ {% for item in exam_data %} +
+ {% if item.requires_premium %} Premium{% endif %} + {{ item.section_count }} section{{ item.section_count|pluralize }}; {{ item.question_count }} question{{ item.question_count|pluralize }}; {{ item.total_minutes }} min{% if item.status == 'in_progress' %}; Continue test{% endif %} + +

{{ item.exam_set.title }}

{{ item.exam_set.get_category_display }}{% if item.part != 'all' %}{{ item.part|upper }}{% endif %}{% if item.status == 'completed' %}Completed{% if item.score %} · {{ item.score }}{% endif %}{% elif item.status == 'in_progress' %}In progress{% endif %}

{% if item.is_exact %} Exact HTML{% else %} {{ item.total_minutes }} min · {{ item.question_count }} question{{ item.question_count|pluralize }}{% endif %}

+ {% if item.status == 'completed' %}{% elif item.is_locked %}{% else %}{% endif %} +
+ {% endfor %} +
+ + {% else %} +
+ +

No tests in this category

+

{% if total %}Try another filter to see the rest of your mock tests.{% else %}Mock tests will appear here after they are published.{% endif %}

+ {% if total %}Show all tests{% else %}Return to dashboard{% endif %} +
+ {% endif %} +
+
+
+{% endblock %} +{% block extra_js %}{% endblock %} diff --git a/exams/templates/exams/results.html b/exams/templates/exams/results.html new file mode 100644 index 0000000..ad2fd45 --- /dev/null +++ b/exams/templates/exams/results.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% load static exam_content %} +{% block title %}Results | {{ attempt.exam_set.title }}{% endblock %} +{% block extra_css %}{% if use_split_reading_review %}{% endif %}{% endblock %} +{% block content %} +{% if use_split_reading_review %} +
+
+
Reading · Review

{{ attempt.exam_set.title }}

+
Raw score{{ correct_count }} / {{ total_gradable }}
+
{% if reading_band_estimated %}Estimated band{% else %}Reading band{% endif %}{{ reading_band|floatformat:1 }} / 9{% if reading_band_estimated %}Equivalent to {{ reading_equivalent }} / 40{% endif %}
+
{% csrf_token %}
+
+ +
+
+

{{ attempt.exam_set.title }}

+ {% for section in reading_passages %}
{{ section.passage_text|render_rich_text }}
{% endfor %} +
+
+ {% for answer in reading_review_answers %} +
+
+

Question {{ forloop.counter }} of {{ reading_review_answers|length }}

+
{{ answer.question.prompt|render_rich_text }}
+
+
Your answer{{ answer.answer_text|default:"No answer submitted" }}
Correct answer{{ answer.question.correct_answer|default:"Instructor reviewed" }}
+
+
{% if answer.question.passage_reference %}
{{ answer.question.passage_reference }}
{% else %}

No exact passage sentence has been attached to this question.

{% endif %}
+
+
+ {% endfor %} +
+
+ +
+ +
+
+
+ +{% else %} +
+
+
Completed test · Review

{{ attempt.exam_set.title }}

Review your responses and grading status.

+
{{ answers|length }} responses{% if pending_manual %}{{ pending_manual }} awaiting review{% endif %}
+
{% csrf_token %}
+
+
+
Performance summary

Your test results

Objective answers are scored immediately. Instructor-reviewed responses update after grading.

+
+
Objective score{% if total_gradable %}{{ correct_count }} / {{ total_gradable }}{% else %}Not applicable{% endif %}
+
Manual band average{% if average_manual_score is not None %}{{ average_manual_score|floatformat:1 }} / 9{% else %}Awaiting review{% endif %}
+
Awaiting review{{ pending_manual }}
+
+
Answer review

Your responses

+ {% for answer in answers %}
{{ answer.question.order }}
{{ answer.question.prompt|render_rich_text }}
Your answer

{{ answer.answer_text|default:"No typed answer submitted"|linebreaksbr }}

{% if answer.audio_response %}{% endif %}
{% if answer.is_correct is not None %}{% if answer.is_correct %} Correct{% else %} Incorrect{% endif %}{% elif answer.manual_score is not None %} Band {{ answer.manual_score|floatformat:1 }}{% else %} Pending instructor review{% endif %}
{% empty %}
No answers were submitted for this test.
{% endfor %} +
+ +
+{% endif %} +{% endblock %} diff --git a/exams/templates/exams/take_section.html b/exams/templates/exams/take_section.html new file mode 100644 index 0000000..19655c1 --- /dev/null +++ b/exams/templates/exams/take_section.html @@ -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 %}{% endblock %} +{% block content %} +
+
+
+ + +
Section {{ section_number }} of {{ section_total }} · Live test

{{ section.get_section_type_display }}

{{ section.exam_set.title }}

+
+
+ +
Time remaining--:--
+
{% csrf_token %}
+
+
+ +
+ {% csrf_token %} + {% if section.section_type == 'listening' %}
Part {{ section.order }}Listen to the audio and answer questions {{ first_question_order }}{% if first_question_order != last_question_order %}–{{ last_question_order }}{% endif %}.
{% endif %} + {% if section.section_type == 'reading' or section.section_type == 'listening' %} + + + {% endif %} +
+ {% if section.section_type == 'reading' and section.passage_text %} + + + {% endif %} + +
+ {% if section.audio_file %}
Listening audioUse headphones in a quiet place.
{% endif %} + {% for block in question_blocks %} + {% if block.kind == 'group' %} + {{ block.group|render_question_group }} + {% else %} + {% with question=block.question %} +
+ {{ question.order }}{{ question.prompt|render_rich_text }} + {% if question.question_type == 'mcq' %} +
{% for option in question.options %}{% endfor %}
+ {% elif question.question_type == 'matching' and question.options %} + + {% elif question.question_type == 'gap' or question.question_type == 'matching' %} + + {% elif question.question_type == 'speaking' %} + +
Tap the microphone to record
or upload a file
+ {% else %} + + {% endif %} +
+ {% endwith %} + {% endif %} + {% endfor %} + {% if section.section_type != 'reading' and section.section_type != 'listening' %}

Your answers are submitted securely when you finish this section.

{% endif %} +
+
+ + {% if section.section_type == 'reading' or section.section_type == 'listening' %} +
+
+ {% for question in section.questions.all %}{% endfor %} +
+
+ + + +
+
+ {% endif %} +
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/exams/templatetags/__init__.py b/exams/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/exams/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/exams/templatetags/exam_content.py b/exams/templatetags/exam_content.py new file mode 100644 index 0000000..04af017 --- /dev/null +++ b/exams/templatetags/exam_content.py @@ -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" 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 = [''] + [ + f'' for option in question.options + ] + control = f'{question.order}' + else: + control = f'{question.order}' + 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'

{heading}

{instructions}
{cleaned}
') diff --git a/exams/tests.py b/exams/tests.py new file mode 100644 index 0000000..414afea --- /dev/null +++ b/exams/tests.py @@ -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''' +

Music history

This is the reading passage.

+

Choose the correct answer.

+
1. Which option is correct?
+
2. Complete this ____ .
+
''' + + +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'''
1. Choose one.
''' + 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, '