Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
pymysql = None
|
||||
|
||||
if pymysql is not None:
|
||||
pymysql.install_as_MySQLdb()
|
||||
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
from exams.models import ExamSet, StudentAttempt
|
||||
|
||||
|
||||
def _chart_points(values, maximum, width=680, height=170, padding=18):
|
||||
"""Return stable SVG points for a seven-day chart, including an empty state."""
|
||||
maximum = max(maximum, 1)
|
||||
usable_width = width - padding * 2
|
||||
usable_height = height - padding * 2
|
||||
points = []
|
||||
for index, value in enumerate(values):
|
||||
x = padding + usable_width * index / max(len(values) - 1, 1)
|
||||
y = padding + usable_height - (value / maximum * usable_height)
|
||||
points.append(f"{x:.1f},{y:.1f}")
|
||||
return " ".join(points)
|
||||
|
||||
|
||||
def dashboard_callback(request, context):
|
||||
today = timezone.localdate()
|
||||
days = [today - timedelta(days=offset) for offset in range(6, -1, -1)]
|
||||
starts = [timezone.make_aware(datetime.combine(day, time.min)) for day in days]
|
||||
ends = [start + timedelta(days=1) for start in starts]
|
||||
|
||||
attempt_counts = []
|
||||
active_student_counts = []
|
||||
for start, end in zip(starts, ends):
|
||||
attempts = StudentAttempt.objects.filter(started_at__gte=start, started_at__lt=end)
|
||||
attempt_counts.append(attempts.count())
|
||||
active_student_counts.append(attempts.values("student_id").distinct().count())
|
||||
|
||||
total_attempts = StudentAttempt.objects.count()
|
||||
seven_day_attempts = sum(attempt_counts)
|
||||
active_students = StudentAttempt.objects.filter(started_at__gte=starts[0]).values("student_id").distinct().count()
|
||||
recent_tests = (
|
||||
ExamSet.objects.annotate(
|
||||
question_count=Count("sections__questions", distinct=True),
|
||||
attempt_count=Count("studentattempt", distinct=True),
|
||||
)
|
||||
.order_by("-created_at")[:6]
|
||||
)
|
||||
chart_maximum = max([*attempt_counts, *active_student_counts, 1])
|
||||
context.update(
|
||||
{
|
||||
"dashboard_metrics": [
|
||||
{
|
||||
"label": "Published tests",
|
||||
"value": ExamSet.objects.filter(is_published=True).count(),
|
||||
"detail": "Tests available to students",
|
||||
"icon": "description",
|
||||
"link": "/admin/exams/examset/?is_published__exact=1",
|
||||
},
|
||||
{
|
||||
"label": "Student attempts",
|
||||
"value": total_attempts,
|
||||
"detail": f"{seven_day_attempts} started in the last 7 days",
|
||||
"icon": "monitoring",
|
||||
"link": "/admin/exams/studentattempt/",
|
||||
},
|
||||
{
|
||||
"label": "Active students",
|
||||
"value": active_students,
|
||||
"detail": "Students active in the last 7 days",
|
||||
"icon": "group",
|
||||
"link": "/admin/auth/user/",
|
||||
},
|
||||
],
|
||||
"chart_labels": [f"{day.strftime('%b')} {day.day}" for day in days],
|
||||
"attempt_points": _chart_points(attempt_counts, chart_maximum),
|
||||
"active_points": _chart_points(active_student_counts, chart_maximum),
|
||||
"has_chart_activity": any(attempt_counts),
|
||||
"recent_tests": recent_tests,
|
||||
"total_users": get_user_model().objects.count(),
|
||||
}
|
||||
)
|
||||
return context
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for core project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.conf import settings
|
||||
|
||||
from accounts.access import has_lifetime_premium
|
||||
|
||||
|
||||
def site_settings(request):
|
||||
return {
|
||||
"site_support_email": settings.SUPPORT_EMAIL,
|
||||
"social_facebook_url": settings.SOCIAL_FACEBOOK_URL,
|
||||
"social_instagram_url": settings.SOCIAL_INSTAGRAM_URL,
|
||||
"social_youtube_url": settings.SOCIAL_YOUTUBE_URL,
|
||||
"social_telegram_url": settings.SOCIAL_TELEGRAM_URL,
|
||||
"has_lifetime_premium": has_lifetime_premium(request.user),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
name = forms.CharField(max_length=100)
|
||||
email = forms.EmailField()
|
||||
subject = forms.CharField(max_length=160)
|
||||
message = forms.CharField(max_length=4000, widget=forms.Textarea)
|
||||
website = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
def clean_website(self):
|
||||
value = self.cleaned_data.get("website", "")
|
||||
if value:
|
||||
raise forms.ValidationError("Invalid submission.")
|
||||
return value
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Django settings for core project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.7.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse, reverse_lazy
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def env_bool(name, default=False):
|
||||
return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def env_list(name, default=""):
|
||||
return [value.strip() for value in os.getenv(name, default).split(",") if value.strip()]
|
||||
|
||||
|
||||
def load_local_env(path):
|
||||
"""Load a simple KEY=VALUE .env file without overriding server variables."""
|
||||
if not path.exists():
|
||||
return
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
load_local_env(BASE_DIR / ".env")
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
DEVELOPMENT_SECRET_KEY = 'django-insecure-development-only-change-me'
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', DEVELOPMENT_SECRET_KEY)
|
||||
DEBUG = env_bool('DJANGO_DEBUG', True)
|
||||
ALLOWED_HOSTS = env_list('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost')
|
||||
|
||||
if not DEBUG and SECRET_KEY == DEVELOPMENT_SECRET_KEY:
|
||||
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a secure value in production.")
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'unfold',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.socialaccount',
|
||||
'allauth.socialaccount.providers.google',
|
||||
|
||||
'exams',
|
||||
'accounts.apps.AccountsConfig',
|
||||
]
|
||||
|
||||
# Keep the back office clean and close to Unfold's maintained defaults.
|
||||
UNFOLD = {
|
||||
"SITE_TITLE": "Testpoint Admin",
|
||||
"SITE_HEADER": "Testpoint",
|
||||
"SITE_ICON": lambda request: static("images/favicon.svg"),
|
||||
"SITE_SYMBOL": "menu_book",
|
||||
"SHOW_HISTORY": True,
|
||||
"SHOW_VIEW_ON_SITE": True,
|
||||
"DASHBOARD_CALLBACK": "core.admin_dashboard.dashboard_callback",
|
||||
"STYLES": [lambda request: "/static/css/unfold-dashboard.css"],
|
||||
"SIDEBAR": {
|
||||
"show_search": True,
|
||||
"show_all_applications": False,
|
||||
"navigation": [
|
||||
{
|
||||
"title": "Navigation",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{"title": "Dashboard", "icon": "dashboard", "link": reverse_lazy("admin:index")},
|
||||
{"title": "Tests", "icon": "description", "link": reverse_lazy("admin:exams_examset_changelist")},
|
||||
{"title": "Questions", "icon": "quiz", "link": reverse_lazy("admin:exams_question_changelist")},
|
||||
{"title": "Results", "icon": "monitoring", "link": reverse_lazy("admin:exams_studentattempt_changelist")},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Create a test",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Reading test", "icon": "menu_book", "link": lambda request: reverse("admin:exams_examset_add") + "?category=reading"},
|
||||
{"title": "Listening test", "icon": "headphones", "link": lambda request: reverse("admin:exams_examset_add") + "?category=listening"},
|
||||
{"title": "Writing test", "icon": "edit_note", "link": lambda request: reverse("admin:exams_examset_add") + "?category=writing"},
|
||||
{"title": "Speaking test", "icon": "mic", "link": lambda request: reverse("admin:exams_examset_add") + "?category=speaking"},
|
||||
{"title": "Full Mock Test", "icon": "assignment", "link": lambda request: reverse("admin:exams_examset_add") + "?category=full"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Import content",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "Import Excel workbook",
|
||||
"icon": "upload_file",
|
||||
"link": reverse_lazy("admin:exams_examset_import_excel"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Users & settings",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{"title": "Users", "icon": "group", "link": reverse_lazy("admin:auth_user_changelist")},
|
||||
{"title": "Student profiles", "icon": "school", "link": reverse_lazy("admin:accounts_studentprofile_changelist")},
|
||||
{"title": "Site settings", "icon": "settings", "link": reverse_lazy("admin:sites_site_changelist")},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"COLORS": {"primary": {
|
||||
"50": "oklch(97.2% .016 251)", "100": "oklch(93.8% .043 251)",
|
||||
"200": "oklch(88.8% .086 251)", "300": "oklch(80.7% .14 251)",
|
||||
"400": "oklch(70.4% .177 251)", "500": "oklch(60.3% .195 251)",
|
||||
"600": "oklch(52.7% .19 251)", "700": "oklch(45.2% .16 251)",
|
||||
"800": "oklch(38.2% .127 251)", "900": "oklch(32.5% .091 251)",
|
||||
"950": "oklch(23.8% .057 251)"
|
||||
}},
|
||||
}
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'allauth.account.middleware.AccountMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
if not DEBUG:
|
||||
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"core.context_processors.site_settings",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# Database
|
||||
DATABASE_URL = os.getenv('DATABASE_URL')
|
||||
if DATABASE_URL:
|
||||
import dj_database_url
|
||||
|
||||
database_ssl_default = not DEBUG and DATABASE_URL.startswith(
|
||||
("postgres://", "postgresql://")
|
||||
)
|
||||
DATABASES = {
|
||||
'default': dj_database_url.parse(
|
||||
DATABASE_URL,
|
||||
conn_max_age=600,
|
||||
conn_health_checks=True,
|
||||
ssl_require=env_bool('DJANGO_DB_SSL_REQUIRE', database_ssl_default),
|
||||
)
|
||||
}
|
||||
if DATABASES['default']['ENGINE'] == 'django.db.backends.mysql':
|
||||
mysql_options = DATABASES['default'].setdefault('OPTIONS', {})
|
||||
mysql_options.setdefault('charset', 'utf8mb4')
|
||||
mysql_options.setdefault('init_command', "SET sql_mode='STRICT_TRANS_TABLES'")
|
||||
else:
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'Asia/Tashkent'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
# Where collectstatic will put files
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
# Extra places Django will look for static files (your app-level static folders)
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "static",
|
||||
]
|
||||
|
||||
STORAGES = {
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": (
|
||||
"django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
if DEBUG
|
||||
else "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440
|
||||
|
||||
# Authentication
|
||||
LOGIN_REDIRECT_URL = '/accounts/dashboard/'
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
|
||||
|
||||
ACCOUNT_EMAIL_VERIFICATION = os.getenv(
|
||||
'DJANGO_ACCOUNT_EMAIL_VERIFICATION',
|
||||
'mandatory' if not DEBUG else 'none',
|
||||
)
|
||||
ACCOUNT_UNIQUE_EMAIL = True
|
||||
ACCOUNT_LOGIN_METHODS = {'email'}
|
||||
ACCOUNT_SIGNUP_FIELDS = ['email*', 'username*', 'password1*', 'password2*']
|
||||
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
'DJANGO_EMAIL_BACKEND',
|
||||
(
|
||||
'django.core.mail.backends.console.EmailBackend'
|
||||
if DEBUG
|
||||
else 'django.core.mail.backends.smtp.EmailBackend'
|
||||
),
|
||||
)
|
||||
EMAIL_HOST = os.getenv('DJANGO_EMAIL_HOST', 'localhost')
|
||||
EMAIL_PORT = int(os.getenv('DJANGO_EMAIL_PORT', '587'))
|
||||
EMAIL_HOST_USER = os.getenv('DJANGO_EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('DJANGO_EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_USE_TLS = env_bool('DJANGO_EMAIL_USE_TLS', True)
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DJANGO_DEFAULT_FROM_EMAIL', 'Testpoint <noreply@localhost>')
|
||||
SERVER_EMAIL = os.getenv('DJANGO_SERVER_EMAIL', DEFAULT_FROM_EMAIL)
|
||||
SUPPORT_EMAIL = os.getenv('DJANGO_SUPPORT_EMAIL', 'support@ieltsmock.com')
|
||||
SOCIAL_FACEBOOK_URL = os.getenv('SOCIAL_FACEBOOK_URL', '')
|
||||
SOCIAL_INSTAGRAM_URL = os.getenv('SOCIAL_INSTAGRAM_URL', '')
|
||||
SOCIAL_YOUTUBE_URL = os.getenv('SOCIAL_YOUTUBE_URL', '')
|
||||
SOCIAL_TELEGRAM_URL = os.getenv('SOCIAL_TELEGRAM_URL', '')
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
'google': {
|
||||
'SCOPE': ['profile', 'email'],
|
||||
'AUTH_PARAMS': {'access_type': 'online'},
|
||||
}
|
||||
}
|
||||
|
||||
# Production security. These remain development-friendly while DEBUG=True.
|
||||
CSRF_TRUSTED_ORIGINS = env_list('DJANGO_CSRF_TRUSTED_ORIGINS')
|
||||
SECURE_SSL_REDIRECT = env_bool('DJANGO_SECURE_SSL_REDIRECT', not DEBUG)
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
SECURE_HSTS_SECONDS = int(os.getenv('DJANGO_SECURE_HSTS_SECONDS', '3600' if not DEBUG else '0'))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool('DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
|
||||
SECURE_HSTS_PRELOAD = env_bool('DJANGO_SECURE_HSTS_PRELOAD', False)
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
|
||||
if env_bool('DJANGO_TRUST_PROXY_HEADERS', False):
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
LOG_LEVEL = os.getenv('DJANGO_LOG_LEVEL', 'INFO')
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'standard': {
|
||||
'format': '{asctime} {levelname} {name}: {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'standard',
|
||||
},
|
||||
},
|
||||
'root': {'handlers': ['console'], 'level': LOG_LEVEL},
|
||||
'loggers': {
|
||||
'django.request': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from django.core import mail
|
||||
|
||||
|
||||
class PublicNavigationTests(TestCase):
|
||||
def test_public_navigation_has_working_destinations_and_mobile_bundle(self):
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertContains(response, 'href="/#about"')
|
||||
self.assertContains(response, 'href="/exams/"')
|
||||
self.assertContains(response, 'href="/accounts/login/"')
|
||||
self.assertContains(response, 'href="/accounts/signup/"')
|
||||
self.assertContains(response, "bootstrap.bundle.min.js")
|
||||
self.assertContains(response, 'id="mainNavigation"')
|
||||
self.assertContains(response, "footer.css?v=20260727.2")
|
||||
self.assertNotContains(response, 'href="#"')
|
||||
|
||||
def test_authenticated_navigation_shows_dashboard_and_logout(self):
|
||||
user = User.objects.create_user(username="nav-user", password="test-pass-123")
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertContains(response, 'href="/accounts/dashboard/"')
|
||||
self.assertContains(response, 'href="/accounts/logout/"')
|
||||
self.assertNotContains(response, 'href="/accounts/login/"')
|
||||
|
||||
def test_admin_dashboard_uses_real_unfold_dashboard_shell(self):
|
||||
admin_user = User.objects.create_superuser(
|
||||
username="dashboard-admin",
|
||||
email="dashboard@example.com",
|
||||
password="test-pass-123",
|
||||
)
|
||||
self.client.force_login(admin_user)
|
||||
|
||||
response = self.client.get("/admin/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Activity overview")
|
||||
self.assertContains(response, "Published tests")
|
||||
self.assertContains(response, "No activity yet")
|
||||
self.assertContains(response, "/static/unfold/css/styles.css")
|
||||
|
||||
def test_navigation_marks_only_the_current_main_page_active(self):
|
||||
home_response = self.client.get("/")
|
||||
self.assertContains(home_response, 'class="nav-link active" href="/"')
|
||||
|
||||
user = User.objects.create_user(username="exam-nav", password="test-pass-123")
|
||||
self.client.force_login(user)
|
||||
exams_response = self.client.get("/exams/")
|
||||
self.assertContains(exams_response, 'class="nav-link active" href="/exams/"')
|
||||
self.assertNotContains(exams_response, 'class="nav-link active" href="/"')
|
||||
|
||||
def test_public_information_pages_resolve(self):
|
||||
faq_response = self.client.get("/faq/")
|
||||
privacy_response = self.client.get("/privacy/")
|
||||
terms_response = self.client.get("/terms/")
|
||||
|
||||
self.assertEqual(faq_response.status_code, 200)
|
||||
self.assertContains(faq_response, "Frequently Asked Questions")
|
||||
self.assertEqual(privacy_response.status_code, 200)
|
||||
self.assertContains(privacy_response, "Privacy Policy")
|
||||
self.assertEqual(terms_response.status_code, 200)
|
||||
self.assertContains(terms_response, "Terms of Use")
|
||||
self.assertContains(terms_response, "not an official IELTS examination service")
|
||||
|
||||
def test_footer_uses_configured_links_without_fake_social_destinations(self):
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertContains(response, "©", html=False)
|
||||
self.assertContains(response, "support@ieltsmock.com")
|
||||
self.assertNotContains(response, "https://www.facebook.com/")
|
||||
self.assertNotContains(response, "https://www.instagram.com/")
|
||||
|
||||
def test_account_pages_use_ielts_mock_branding(self):
|
||||
login_response = self.client.get("/accounts/login/")
|
||||
signup_response = self.client.get("/accounts/signup/")
|
||||
reset_response = self.client.get("/accounts/password/reset/")
|
||||
|
||||
self.assertContains(login_response, "Welcome back")
|
||||
self.assertNotContains(login_response, "SpcHub")
|
||||
self.assertContains(signup_response, "Create your account")
|
||||
self.assertContains(reset_response, "Reset your password")
|
||||
|
||||
def test_account_recovery_confirmation_screen_is_branded(self):
|
||||
response = self.client.post(
|
||||
"/accounts/password/reset/",
|
||||
{"email": "missing@example.com"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Check your email")
|
||||
self.assertContains(response, "Testpoint")
|
||||
|
||||
def test_health_endpoint_reports_database_readiness(self):
|
||||
response = self.client.get("/health/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok", "database": "ok"})
|
||||
|
||||
def test_missing_page_uses_branded_error_template(self):
|
||||
response = self.client.get("/this-page-does-not-exist/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertContains(response, "Page not found", status_code=404)
|
||||
|
||||
def test_home_has_search_and_accessibility_metadata(self):
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertContains(response, 'rel="canonical"')
|
||||
self.assertContains(response, 'property="og:title"')
|
||||
self.assertContains(response, 'href="/static/images/favicon.svg"')
|
||||
self.assertContains(response, "Skip to main content")
|
||||
self.assertContains(response, 'id="main-content"')
|
||||
|
||||
def test_robots_and_sitemap_resolve(self):
|
||||
robots_response = self.client.get("/robots.txt")
|
||||
sitemap_response = self.client.get("/sitemap.xml")
|
||||
|
||||
self.assertEqual(robots_response.status_code, 200)
|
||||
self.assertContains(robots_response, "Disallow: /admin/")
|
||||
self.assertContains(robots_response, "/sitemap.xml")
|
||||
self.assertEqual(sitemap_response.status_code, 200)
|
||||
self.assertEqual(sitemap_response["Content-Type"], "application/xml")
|
||||
self.assertContains(sitemap_response, "/faq/")
|
||||
self.assertContains(sitemap_response, "/terms/")
|
||||
|
||||
@override_settings(
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
SUPPORT_EMAIL="support@example.com",
|
||||
)
|
||||
def test_contact_form_sends_support_email(self):
|
||||
response = self.client.post(
|
||||
"/contact/",
|
||||
{
|
||||
"name": "Test Student",
|
||||
"email": "student@example.com",
|
||||
"subject": "Account help",
|
||||
"message": "Please help with my account.",
|
||||
"website": "",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, "/contact/")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["support@example.com"])
|
||||
self.assertEqual(mail.outbox[0].reply_to, ["student@example.com"])
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.urls import path, include
|
||||
from .views import contact, faq, health, home, privacy, robots, sitemap, terms
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
path('', home, name='home'),
|
||||
path('faq/', faq, name='faq'),
|
||||
path('privacy/', privacy, name='privacy'),
|
||||
path('terms/', terms, name='terms'),
|
||||
path('contact/', contact, name='contact'),
|
||||
path('health/', health, name='health'),
|
||||
path('robots.txt', robots, name='robots'),
|
||||
path('sitemap.xml', sitemap, name='sitemap'),
|
||||
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('exams/', include('exams.urls')),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db import connection
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from .forms import ContactForm
|
||||
from accounts.leaderboard import build_leaderboard
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def home(request):
|
||||
rows, current_row, _ = build_leaderboard(
|
||||
period="week",
|
||||
current_user=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
preview_user = current_row or {
|
||||
"rank": 24,
|
||||
"name": "Akhmed",
|
||||
"band": 7.5,
|
||||
"trend": "up",
|
||||
"initial": "A",
|
||||
"is_current": True,
|
||||
"demo": True,
|
||||
}
|
||||
return render(
|
||||
request,
|
||||
"home.html",
|
||||
{"leaderboard_preview": rows[:5], "leaderboard_preview_user": preview_user},
|
||||
)
|
||||
|
||||
|
||||
def faq(request):
|
||||
return render(request, "faq.html")
|
||||
|
||||
|
||||
def privacy(request):
|
||||
return render(request, "privacy.html")
|
||||
|
||||
|
||||
def terms(request):
|
||||
return render(request, "terms.html")
|
||||
|
||||
|
||||
def contact(request):
|
||||
form = ContactForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
body = (
|
||||
f"Name: {form.cleaned_data['name']}\n"
|
||||
f"Email: {form.cleaned_data['email']}\n\n"
|
||||
f"{form.cleaned_data['message']}"
|
||||
)
|
||||
email = EmailMessage(
|
||||
subject=f"Testpoint support: {form.cleaned_data['subject']}",
|
||||
body=body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[settings.SUPPORT_EMAIL],
|
||||
reply_to=[form.cleaned_data["email"]],
|
||||
)
|
||||
try:
|
||||
email.send(fail_silently=False)
|
||||
except Exception:
|
||||
logger.exception("Support contact email could not be sent")
|
||||
messages.error(
|
||||
request,
|
||||
"We could not send your message. Please try again shortly.",
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
"Your message has been sent. We'll reply as soon as possible.",
|
||||
)
|
||||
return redirect("contact")
|
||||
return render(
|
||||
request,
|
||||
"contact.html",
|
||||
{"form": form, "support_email": settings.SUPPORT_EMAIL},
|
||||
)
|
||||
|
||||
|
||||
def health(request):
|
||||
"""Lightweight process and database readiness check for the host."""
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
except Exception:
|
||||
return JsonResponse(
|
||||
{"status": "unhealthy", "database": "unavailable"}, status=503
|
||||
)
|
||||
return JsonResponse({"status": "ok", "database": "ok"})
|
||||
|
||||
|
||||
def robots(request):
|
||||
sitemap_url = request.build_absolute_uri(reverse("sitemap"))
|
||||
body = f"User-agent: *\nAllow: /\nDisallow: /admin/\nSitemap: {sitemap_url}\n"
|
||||
return HttpResponse(body, content_type="text/plain")
|
||||
|
||||
|
||||
def sitemap(request):
|
||||
urls = [
|
||||
request.build_absolute_uri(reverse("home")),
|
||||
request.build_absolute_uri(reverse("faq")),
|
||||
request.build_absolute_uri(reverse("privacy")),
|
||||
request.build_absolute_uri(reverse("terms")),
|
||||
request.build_absolute_uri(reverse("contact")),
|
||||
]
|
||||
return render(
|
||||
request,
|
||||
"sitemap.xml",
|
||||
{"urls": urls},
|
||||
content_type="application/xml",
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for core project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
Reference in New Issue
Block a user