Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
staticfiles/
app/staticfiles/

# Flask stuff:
instance/
Expand Down
1 change: 0 additions & 1 deletion app/__init__.py

This file was deleted.

75 changes: 69 additions & 6 deletions apps/users/forms.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,78 @@
"""Forms for StudyBuddy user accounts."""
"""Forms for StudyBuddy user registration and account workflows."""

from __future__ import annotations

from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm

from apps.users.models import CustomUser
CustomUser = get_user_model()


class CustomUserCreationForm(UserCreationForm):
"""Signup form for the custom user model."""
class UserSignUpForm(UserCreationForm):
"""Form used by visitors to create a StudyBuddy account."""

email = forms.EmailField(
label="Email address",
help_text="Use this email address when signing in.",
)
username = forms.CharField(
required=False,
help_text="Optional. If left blank, StudyBuddy derives one from your email.",
)

class Meta:
"""Form metadata for user signup."""

class Meta(UserCreationForm.Meta):
model = CustomUser
fields = ("email", "username")
fields = (
"email",
"username",
"first_name",
"last_name",
"password1",
"password2",
)

def clean_email(self) -> str:
"""Validate that the submitted email is unique."""
email = self.cleaned_data["email"].strip().lower()

if CustomUser.objects.filter(email__iexact=email).exists():
raise forms.ValidationError("A user with this email already exists.")

return email

def clean_username(self) -> str:
"""Validate an optional username only when the user supplies one."""
username = self.cleaned_data.get("username", "").strip()

if username and CustomUser.objects.filter(username__iexact=username).exists():
raise forms.ValidationError("A user with this username already exists.")

return username

def save(self, commit: bool = True):
"""Create a user with a safe username and email-first login."""
user = super().save(commit=False)
user.email = self.cleaned_data["email"]
user.username = self.cleaned_data.get("username") or self._unique_username()

if commit:
user.save()
self.save_m2m()

return user

def _unique_username(self) -> str:
"""Build a unique username from the email local part."""
email = self.cleaned_data["email"]
base_username = email.split("@", maxsplit=1)[0][:140] or "user"
candidate = base_username
suffix = 1

while CustomUser.objects.filter(username__iexact=candidate).exists():
suffix += 1
candidate = f"{base_username}-{suffix}"

return candidate
170 changes: 170 additions & 0 deletions apps/users/tests/test_auth_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Integration tests for user authentication views."""

from __future__ import annotations

import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse

from apps.users.factories import CustomUserFactory
from apps.users.forms import UserSignUpForm

CustomUser = get_user_model()


@pytest.mark.django_db
def test_signup_page_renders(client):
"""The signup page renders a registration form."""
response = client.get(reverse("users:signup"))

assert response.status_code == 200
assert b"Create Account" in response.content
assert b"New Account" in response.content


@pytest.mark.django_db
def test_signup_creates_user_and_redirects_to_dashboard(client):
"""A valid signup creates a user and sends them to the dashboard."""
response = client.post(
reverse("users:signup"),
{
"email": "new.user@example.com",
"username": "",
"first_name": "New",
"last_name": "User",
"password1": "StrongPassword123!",
"password2": "StrongPassword123!",
},
)

assert response.status_code == 302
assert response["Location"] == reverse("dashboard:index")
assert CustomUser.objects.filter(email="new.user@example.com").exists()


@pytest.mark.django_db
def test_signup_rejects_duplicate_email(client):
"""Signup rejects email addresses that already belong to another user."""
CustomUserFactory(email="duplicate@example.com")

response = client.post(
reverse("users:signup"),
{
"email": "duplicate@example.com",
"username": "duplicate-user",
"first_name": "Duplicate",
"last_name": "User",
"password1": "StrongPassword123!",
"password2": "StrongPassword123!",
},
)

assert response.status_code == 200
assert b"A user with this email already exists." in response.content


@pytest.mark.django_db
def test_signup_rejects_duplicate_username(client):
"""Signup rejects explicitly supplied usernames already in use."""
CustomUserFactory(username="existing-user")

response = client.post(
reverse("users:signup"),
{
"email": "unique@example.com",
"username": "existing-user",
"first_name": "Unique",
"last_name": "User",
"password1": "StrongPassword123!",
"password2": "StrongPassword123!",
},
)

assert response.status_code == 200
assert b"A user with this username already exists." in response.content


@pytest.mark.django_db
def test_signup_generates_unique_username_when_email_local_part_exists(client):
"""Blank usernames are derived from email and made unique."""
CustomUserFactory(email="learner@example.com", username="learner")

response = client.post(
reverse("users:signup"),
{
"email": "learner@studybuddy.test",
"username": "",
"first_name": "New",
"last_name": "Learner",
"password1": "StrongPassword123!",
"password2": "StrongPassword123!",
},
)

assert response.status_code == 302
user = CustomUser.objects.get(email="learner@studybuddy.test")

assert user.username == "learner-2"


@pytest.mark.django_db
def test_signup_form_save_commit_false_builds_unsaved_user():
"""Signup form can build a valid custom user without persisting it."""
form = UserSignUpForm(
data={
"email": "draft@example.com",
"username": "",
"first_name": "Draft",
"last_name": "User",
"password1": "StrongPassword123!",
"password2": "StrongPassword123!",
}
)

assert form.is_valid()

user = form.save(commit=False)

assert user.pk is None
assert user.email == "draft@example.com"
assert user.username == "draft"
assert user.check_password("StrongPassword123!")
assert not CustomUser.objects.filter(email="draft@example.com").exists()


@pytest.mark.django_db
def test_login_with_email_redirects_to_dashboard(client):
"""Users can log in with their email address and password."""
user = CustomUserFactory(email="login@example.com")

response = client.post(
reverse("users:login"),
{
"username": user.email,
"password": "password123",
},
)

assert response.status_code == 302
assert response["Location"] == reverse("dashboard:index")


@pytest.mark.django_db
def test_profile_requires_login(client):
"""Anonymous users are redirected before viewing a profile."""
response = client.get(reverse("users:profile"))

assert response.status_code == 302
assert response["Location"].startswith(f"{reverse('users:login')}?next=")


@pytest.mark.django_db
def test_authenticated_profile_shows_user_email(client):
"""Authenticated users can view their account profile."""
user = CustomUserFactory(email="profile@example.com")
client.force_login(user)

response = client.get(reverse("users:profile"))

assert response.status_code == 200
assert b"profile@example.com" in response.content
22 changes: 12 additions & 10 deletions apps/users/views.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
"""Views for StudyBuddy user accounts."""
"""Views for StudyBuddy account workflows."""

from __future__ import annotations

from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render

from apps.users.forms import CustomUserCreationForm
from apps.users.forms import UserSignUpForm


def signup(request):
"""Register a new StudyBuddy user."""
def signup(request: HttpRequest) -> HttpResponse:
"""Register a new user and send them to the dashboard."""
if request.method == "POST":
form = CustomUserCreationForm(request.POST)
form = UserSignUpForm(request.POST)

if form.is_valid():
user = form.save()
login(request, user)
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect("dashboard:index")
else:
form = CustomUserCreationForm()
form = UserSignUpForm()

return render(request, "registration/signup.html", {"form": form})
return render(request, "users/signup.html", {"form": form})


@login_required
def profile(request):
"""Render the signed-in user's profile page."""
def profile(request: HttpRequest) -> HttpResponse:
"""Render the authenticated user's profile."""
return render(request, "users/profile.html")
Loading
Loading