Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@ SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
GITHUB_REPO=owner/repo-name

# OAuth providers (optional - set these only if you plan to use them)
# GitHub: https://github.com/settings/applications/new
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_SECRET=
# Google: https://console.developers.google.com/apis/credentials
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_SECRET=
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ numpy>=1.24.0
RestrictedPython>=6.0
dj-database-url>=2.0.0
psycopg2-binary>=2.9.0
django-allauth[socialaccount]>=65.0.0
9 changes: 9 additions & 0 deletions study/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@ def github_repo(request):
return {
'GITHUB_REPO': getattr(settings, 'GITHUB_REPO', ''),
}


def oauth_providers(request):
"""Make configured OAuth provider names available in all templates."""
providers = getattr(settings, 'SOCIALACCOUNT_PROVIDERS', {})
return {
'oauth_github_enabled': 'github' in providers,
'oauth_google_enabled': 'google' in providers,
}
16 changes: 16 additions & 0 deletions study/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from .models import Course, Topic, Flashcard, CardFeedback, Skill


class RegistrationForm(UserCreationForm):
"""Custom registration form with optional email and privacy warning."""
email = forms.EmailField(
required=False,
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'Optional email address'}),
help_text='Optional. Providing an email allows password recovery. Without it, there is no way to reset a forgotten password.',
)

class Meta:
model = User
fields = ['username', 'email', 'password1', 'password2']


class CourseForm(forms.ModelForm):
class Meta:
model = Course
Expand Down
1 change: 0 additions & 1 deletion study/templates/study/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@ <h1>📚 Study Platform</h1>
{% if user.is_staff %}
<li><a href="{% url 'admin_feedback_review' %}">Feedback Review</a></li>
{% endif %}
<li><a href="{% url 'admin:index' %}">Admin</a></li>
<li>
<form method="post" action="{% url 'logout' %}" style="display: inline;">
{% csrf_token %}
Expand Down
2 changes: 1 addition & 1 deletion study/templates/study/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h3 style="font-size: 1.5rem; margin-bottom: 1rem;">📝 Study Sessions</h3>
<div style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); padding: 2rem; border-radius: 10px; color: white;">
<h3 style="font-size: 1.5rem; margin-bottom: 1rem;">🎯 Quick Start</h3>
<p style="margin-bottom: 1rem;">Jump into studying</p>
<a href="{% url 'admin:index' %}" class="btn" style="background: white; color: #4facfe;">Manage Content</a>
<a href="{% url 'course_create' %}" class="btn" style="background: white; color: #4facfe;">Create Course</a>
</div>
</div>

Expand Down
27 changes: 27 additions & 0 deletions study/templates/study/login.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
{% extends 'study/base.html' %}
{% load socialaccount %}

{% block title %}Login - Study Platform{% endblock %}

{% block content %}
<div style="max-width: 500px; margin: 0 auto;">
<h1 style="color: #667eea; margin-bottom: 1.5rem; text-align: center;">Login</h1>

{% if oauth_github_enabled or oauth_google_enabled %}
<!-- OAuth Sign-In Options -->
<div style="background: #f8f9fa; padding: 2rem; border-radius: 10px; margin-bottom: 1.5rem;">
<p style="text-align: center; font-weight: 500; margin-bottom: 1rem;">Sign in with a social account</p>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
{% if oauth_github_enabled %}
<a href="{% provider_login_url 'github' %}" class="btn" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: #24292e; color: white; text-decoration: none; padding: 0.75rem; border-radius: 5px;">
<svg height="20" width="20" viewBox="0 0 16 16" fill="white"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Sign in with GitHub
</a>
{% endif %}
{% if oauth_google_enabled %}
<a href="{% provider_login_url 'google' %}" class="btn" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: #4285f4; color: white; text-decoration: none; padding: 0.75rem; border-radius: 5px;">
<svg height="20" width="20" viewBox="0 0 48 48"><path fill="#fff" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></svg>
Sign in with Google
</a>
{% endif %}
</div>
</div>

<div style="text-align: center; margin-bottom: 1.5rem; position: relative;">
<hr style="border: none; border-top: 1px solid #ced4da;">
<span style="background: white; padding: 0 1rem; position: relative; top: -0.75rem; color: #666;">or sign in with username</span>
</div>
{% endif %}

<form method="post" style="background: #f8f9fa; padding: 2rem; border-radius: 10px;">
{% csrf_token %}

Expand Down
39 changes: 37 additions & 2 deletions study/templates/study/register.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
{% extends 'study/base.html' %}
{% load socialaccount %}

{% block title %}Register - Study Platform{% endblock %}

{% block content %}
<div style="max-width: 500px; margin: 0 auto;">
<h1 style="color: #667eea; margin-bottom: 1.5rem; text-align: center;">Register</h1>

{% if oauth_github_enabled or oauth_google_enabled %}
<!-- OAuth Sign-Up Options -->
<div style="background: #f8f9fa; padding: 2rem; border-radius: 10px; margin-bottom: 1.5rem;">
<p style="text-align: center; font-weight: 500; margin-bottom: 1rem;">Sign up with a social account</p>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
{% if oauth_github_enabled %}
<a href="{% provider_login_url 'github' %}" class="btn" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: #24292e; color: white; text-decoration: none; padding: 0.75rem; border-radius: 5px;">
<svg height="20" width="20" viewBox="0 0 16 16" fill="white"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Sign up with GitHub
</a>
{% endif %}
{% if oauth_google_enabled %}
<a href="{% provider_login_url 'google' %}" class="btn" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: #4285f4; color: white; text-decoration: none; padding: 0.75rem; border-radius: 5px;">
<svg height="20" width="20" viewBox="0 0 48 48"><path fill="#fff" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></svg>
Sign up with Google
</a>
{% endif %}
</div>
</div>

<div style="text-align: center; margin-bottom: 1.5rem; position: relative;">
<hr style="border: none; border-top: 1px solid #ced4da;">
<span style="background: white; padding: 0 1rem; position: relative; top: -0.75rem; color: #666;">or register with username</span>
</div>
{% endif %}

<form method="post" style="background: #f8f9fa; padding: 2rem; border-radius: 10px;">
{% csrf_token %}

Expand Down Expand Up @@ -33,6 +60,14 @@ <h1 style="color: #667eea; margin-bottom: 1.5rem; text-align: center;">Register<
{% endif %}
</div>
{% endfor %}

<!-- Email privacy warning -->
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 5px; padding: 1rem; margin-bottom: 1rem;">
<strong style="color: #856404;">⚠️ No email?</strong>
<p style="color: #856404; margin: 0.5rem 0 0 0; font-size: 0.9rem;">
If you do not provide an email address, there will be no way to recover your password if forgotten.
</p>
</div>

<button type="submit" class="btn" style="width: 100%; margin-top: 1rem;">Register</button>
</form>
Expand All @@ -43,15 +78,15 @@ <h1 style="color: #667eea; margin-bottom: 1.5rem; text-align: center;">Register<
</div>

<style>
input[type="text"], input[type="password"] {
input[type="text"], input[type="password"], input[type="email"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 1rem;
}

input[type="text"]:focus, input[type="password"]:focus {
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus {
outline: none;
border-color: #667eea;
}
Expand Down
190 changes: 190 additions & 0 deletions study/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
from django.test import TestCase, Client, override_settings
from django.contrib.auth.models import User
from django.urls import reverse
from .forms import RegistrationForm


class RegistrationFormTestCase(TestCase):
"""Test the custom registration form with optional email"""

def test_registration_without_email(self):
"""Registration should succeed without providing an email"""
form = RegistrationForm(data={
'username': 'newuser',
'password1': 'complexpass123!',
'password2': 'complexpass123!',
})
self.assertTrue(form.is_valid())

def test_registration_with_email(self):
"""Registration should succeed with an email provided"""
form = RegistrationForm(data={
'username': 'newuser',
'email': 'user@example.com',
'password1': 'complexpass123!',
'password2': 'complexpass123!',
})
self.assertTrue(form.is_valid())

def test_registration_with_empty_email(self):
"""Registration should succeed with empty email (explicitly blank)"""
form = RegistrationForm(data={
'username': 'newuser',
'email': '',
'password1': 'complexpass123!',
'password2': 'complexpass123!',
})
self.assertTrue(form.is_valid())

def test_registration_with_invalid_email(self):
"""Registration should fail with an invalid email format"""
form = RegistrationForm(data={
'username': 'newuser',
'email': 'not-an-email',
'password1': 'complexpass123!',
'password2': 'complexpass123!',
})
self.assertFalse(form.is_valid())
self.assertIn('email', form.errors)

def test_email_field_has_help_text(self):
"""Email field should include warning about password recovery"""
form = RegistrationForm()
self.assertIn('password recovery', form.fields['email'].help_text)


class RegistrationViewTestCase(TestCase):
"""Test the registration view"""

def setUp(self):
self.client = Client()

def test_register_page_loads(self):
"""Registration page should load successfully"""
response = self.client.get(reverse('register'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Register')

def test_register_page_hides_oauth_when_not_configured(self):
"""Registration page should hide OAuth buttons when providers are not configured"""
response = self.client.get(reverse('register'))
self.assertNotContains(response, 'Sign up with GitHub')
self.assertNotContains(response, 'Sign up with Google')

@override_settings(SOCIALACCOUNT_PROVIDERS={
'github': {'APP': {'client_id': 'test', 'secret': 'test'}},
'google': {'APP': {'client_id': 'test', 'secret': 'test'}},
})
def test_register_page_shows_oauth_when_configured(self):
"""Registration page should show OAuth buttons when providers are configured"""
response = self.client.get(reverse('register'))
self.assertContains(response, 'Sign up with GitHub')
self.assertContains(response, 'Sign up with Google')

def test_register_page_shows_email_warning(self):
"""Registration page should show warning about no email"""
response = self.client.get(reverse('register'))
self.assertContains(response, 'no way to recover your password')

def test_register_creates_user_without_email(self):
"""POST with valid data (no email) should create user and redirect"""
response = self.client.post(reverse('register'), {
'username': 'newuser',
'password1': 'complexpass123!',
'password2': 'complexpass123!',
})
self.assertEqual(response.status_code, 302)
self.assertTrue(User.objects.filter(username='newuser').exists())
user = User.objects.get(username='newuser')
self.assertEqual(user.email, '')

def test_register_creates_user_with_email(self):
"""POST with valid data (with email) should create user and redirect"""
response = self.client.post(reverse('register'), {
'username': 'newuser',
'email': 'user@example.com',
'password1': 'complexpass123!',
'password2': 'complexpass123!',
})
self.assertEqual(response.status_code, 302)
user = User.objects.get(username='newuser')
self.assertEqual(user.email, 'user@example.com')

def test_register_redirects_authenticated_user(self):
"""Authenticated users should be redirected from registration page"""
User.objects.create_user(username='existing', password='testpass')
self.client.login(username='existing', password='testpass')
response = self.client.get(reverse('register'))
self.assertEqual(response.status_code, 302)


class LoginViewTestCase(TestCase):
"""Test the login view"""

def setUp(self):
self.client = Client()

def test_login_page_loads(self):
"""Login page should load successfully"""
response = self.client.get(reverse('login'))
self.assertEqual(response.status_code, 200)

def test_login_page_hides_oauth_when_not_configured(self):
"""Login page should hide OAuth buttons when providers are not configured"""
response = self.client.get(reverse('login'))
self.assertNotContains(response, 'Sign in with GitHub')
self.assertNotContains(response, 'Sign in with Google')

@override_settings(SOCIALACCOUNT_PROVIDERS={
'github': {'APP': {'client_id': 'test', 'secret': 'test'}},
'google': {'APP': {'client_id': 'test', 'secret': 'test'}},
})
def test_login_page_shows_oauth_when_configured(self):
"""Login page should show OAuth buttons when providers are configured"""
response = self.client.get(reverse('login'))
self.assertContains(response, 'Sign in with GitHub')
self.assertContains(response, 'Sign in with Google')

def test_login_page_shows_username_section(self):
"""Login page should show the traditional username/password section"""
response = self.client.get(reverse('login'))
self.assertContains(response, 'Login')


class AdminHiddenFromNavigationTestCase(TestCase):
"""Test that Django admin link is hidden from main navigation"""

def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username='testuser', password='testpass')
self.client.login(username='testuser', password='testpass')

def test_admin_link_not_in_navigation(self):
"""Admin link should not appear in the navigation bar"""
response = self.client.get(reverse('home'))
# The navigation should not contain a direct link to admin
content = response.content.decode()
self.assertNotIn('href="/admin/"', content)

def test_admin_url_still_accessible(self):
"""Admin should still be accessible via direct URL"""
response = self.client.get('/admin/', follow=False)
# Should get a redirect to admin login (302) not a 404
self.assertIn(response.status_code, [200, 302])


class FeedbackURLRoutingTestCase(TestCase):
"""Test that feedback URLs are not captured by Django admin"""

def setUp(self):
self.client = Client()
self.staff_user = User.objects.create_user(
username='staffuser', password='testpass', is_staff=True
)
self.client.login(username='staffuser', password='testpass')

def test_feedback_review_url_resolves_correctly(self):
"""staff/feedback/review/ should resolve to the study app view, not Django admin"""
response = self.client.get(reverse('admin_feedback_review'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Feedback')
4 changes: 2 additions & 2 deletions study/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@

# Feedback URLs
path('flashcard/<int:flashcard_id>/feedback/', views.submit_feedback, name='submit_feedback'),
path('admin/feedback/review/', views.admin_feedback_review, name='admin_feedback_review'),
path('admin/feedback/<int:feedback_id>/update/', views.update_feedback_status, name='update_feedback_status'),
path('staff/feedback/review/', views.admin_feedback_review, name='admin_feedback_review'),
path('staff/feedback/<int:feedback_id>/update/', views.update_feedback_status, name='update_feedback_status'),

# Statistics
path('statistics/', views.statistics, name='statistics'),
Expand Down
Loading