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
47 changes: 47 additions & 0 deletions apps/roles/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Reusable role-aware permission helpers."""

from __future__ import annotations

from collections.abc import Callable, Iterable
from functools import wraps
from typing import Any

from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse


def user_has_role(user: Any, role_slug: str) -> bool:
"""Return whether a user has the requested role slug."""
if not getattr(user, "is_authenticated", False):
return False

if getattr(user, "is_superuser", False):
return True

return user.studybuddy_roles.filter(slug=role_slug).exists()


def user_has_any_role(user: Any, role_slugs: Iterable[str]) -> bool:
"""Return whether a user has at least one of the requested role slugs."""
return any(user_has_role(user, role_slug) for role_slug in role_slugs)


def role_required(role_slug: str) -> Callable:
"""Decorate a view so only users with a role can access it."""

def decorator(view_func: Callable) -> Callable:
"""Wrap a Django view with a role check."""

@wraps(view_func)
def wrapped_view(
request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponse:
"""Run the role check before calling the wrapped view."""
if not user_has_role(request.user, role_slug):
raise PermissionDenied

return view_func(request, *args, **kwargs)

return wrapped_view

return decorator
63 changes: 63 additions & 0 deletions apps/roles/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

from __future__ import annotations

from unittest.mock import Mock

import pytest
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
from django.http import HttpResponse
from django.test import RequestFactory

from apps.roles.factories import RoleFactory
from apps.roles.models import Role
from apps.roles.permissions import role_required, user_has_any_role, user_has_role
from apps.users.factories import CustomUserFactory


@pytest.mark.django_db
Expand Down Expand Up @@ -42,3 +50,58 @@ def test_role_factory_common_role_traits(trait, slug, display_name):

assert role.slug == slug
assert role.display_name == display_name


@pytest.mark.django_db
def test_user_has_role_uses_studybuddy_roles_relation():
"""Role helpers use the current user-side StudyBuddy role relation."""
user = CustomUserFactory()
role = RoleFactory(slug="learner", display_name="Learner")
user.studybuddy_roles.add(role)

assert user_has_role(user, "learner")
assert user_has_any_role(user, ["admin", "learner"])
assert not user_has_role(user, "admin")
assert not user_has_any_role(user, ["admin", "tutor"])


@pytest.mark.django_db
def test_role_helpers_handle_anonymous_users_and_superusers():
"""Anonymous users fail role checks while superusers pass them."""
superuser = CustomUserFactory(is_staff=True, is_superuser=True)

assert not user_has_role(AnonymousUser(), "admin")
assert user_has_role(superuser, "admin")


@pytest.mark.django_db
def test_role_required_allows_users_with_required_role():
"""The role decorator lets users with the required role reach the view."""
user = CustomUserFactory()
role = RoleFactory(slug="learner", display_name="Learner")
user.studybuddy_roles.add(role)
request = RequestFactory().get("/protected/")
request.user = user

@role_required("learner")
def protected_view(request):
return HttpResponse("allowed")

response = protected_view(request)

assert response.status_code == 200
assert response.content == b"allowed"


@pytest.mark.django_db
def test_role_required_denies_users_without_required_role():
"""The role decorator rejects users who do not have the required role."""
request = RequestFactory().get("/protected/")
request.user = CustomUserFactory()
view_func = Mock(return_value=HttpResponse("allowed"))
protected_view = role_required("admin")(view_func)

with pytest.raises(PermissionDenied):
protected_view(request)

view_func.assert_not_called()
16 changes: 16 additions & 0 deletions apps/users/tests/test_auth_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.contrib.auth import get_user_model
from django.urls import reverse

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

Expand Down Expand Up @@ -259,3 +260,18 @@ def test_authenticated_profile_shows_user_email(client):

assert response.status_code == 200
assert b"profile@example.com" in response.content


@pytest.mark.django_db
def test_authenticated_profile_receives_roles_from_view_context(client):
"""Profile role display is fed by view context, not template permissions."""
user = CustomUserFactory(email="role.profile@example.com")
role = RoleFactory(slug="learner", display_name="Learner")
user.studybuddy_roles.add(role)
client.force_login(user)

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

assert response.status_code == 200
assert list(response.context["roles"]) == [role]
assert b"Learner" in response.content
10 changes: 9 additions & 1 deletion apps/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@ def signup(request: HttpRequest) -> HttpResponse:
@login_required
def profile(request: HttpRequest) -> HttpResponse:
"""Render the authenticated user's profile."""
return render(request, "users/profile.html")
roles = request.user.studybuddy_roles.order_by("display_name")

return render(
request,
"users/profile.html",
{
"roles": roles,
},
)
136 changes: 136 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Authentication and Access Control

Sprint 1 establishes the StudyBuddy identity baseline and the first protected
product surface.

The current goal is a real SaaS-shaped authentication journey without
overbuilding permissions before study-session workflows exist.

## User Model

StudyBuddy uses `apps.users.CustomUser`.

The model extends Django's `AbstractUser`, keeps Django's standard permission
fields, and changes the login identifier to email:

```python
USERNAME_FIELD = "email"
```

Email addresses are unique and required. Usernames still exist for Django
compatibility, but account creation can generate one from the email local part
when the signup form leaves `username` blank.

The active auth setting is:

```python
AUTH_USER_MODEL = "users.CustomUser"
```

## Authentication Routes

User-facing authentication routes live under `/accounts/`:

- `users:signup` -> `/accounts/signup/`
- `users:login` -> `/accounts/login/`
- `users:logout` -> `/accounts/logout/`
- `users:profile` -> `/accounts/profile/`

Signup and login redirect authenticated users to the dashboard:

```python
LOGIN_URL = "users:login"
LOGIN_REDIRECT_URL = "dashboard:index"
LOGOUT_REDIRECT_URL = "home"
```

The protected dashboard route is:

- `dashboard:index` -> `/dashboard/`

## Signup Flow

`apps.users.forms.CustomUserCreationForm` validates email uniqueness, optional
username uniqueness, and password confirmation through Django's user creation
form behavior.

A successful signup:

- creates a valid `CustomUser`;
- authenticates the new user;
- redirects to `dashboard:index`.

Duplicate email addresses are rejected at the form layer before creating a user.

## Login Flow

The login page uses Django's `LoginView` with `users/login.html`.

Users log in with their email address and password because the custom user model
uses email as `USERNAME_FIELD`.

Authenticated users who visit login or signup are redirected to the dashboard.

## Profile Flow

`users:profile` is login-protected and renders `templates/users/profile.html`.

The profile displays the current user's display name, email, username, and
assigned StudyBuddy roles.

## Roles

StudyBuddy roles are modeled by `apps.roles.Role`.

Roles have:

- `slug`;
- `display_name`;
- optional `description`;
- timestamps;
- a many-to-many relation to the custom user model.

The user-side relation is:

```python
user.studybuddy_roles
```

Use this relation in templates, views, factories, tests, and permission helpers.
Do not use `user.roles`; that is not the current related name.

## Permission Helpers

`apps.roles.permissions` provides lightweight role-aware helpers:

- `user_has_role(user, role_slug)`;
- `user_has_any_role(user, role_slugs)`;
- `role_required(role_slug)`.

Anonymous users fail role checks. Superusers pass role checks. Regular users
pass only when `user.studybuddy_roles` contains the requested slug.

## Dashboard Access

The dashboard is the Sprint 1 post-login product destination.

It is intentionally a protected shell until Sprint 2 adds study-session data.
It displays placeholder study metrics, an empty-session state, account access,
and role-aware messaging backed by `user.studybuddy_roles`.

## Validation

The authentication journey is covered by HTTP-level tests, not just isolated
model creation. Tests verify:

- signup page render;
- signup user creation;
- duplicate email rejection;
- signup redirect-follow behavior;
- login with email and password;
- login redirect-follow behavior;
- authenticated redirects away from login/signup;
- profile protection;
- authenticated profile rendering;
- logout redirect behavior;
- dashboard access for authenticated users.
2 changes: 1 addition & 1 deletion templates/users/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ <h2 class="card-title-ui">Account Details</h2>
<dt>Roles</dt>
<dd>
<span class="profile-pill-list">
{% for role in request.user.studybuddy_roles.all %}
{% for role in roles %}
<span class="mock-pill">{{ role.display_name }}</span>
{% empty %}
<span class="text-muted-ui">No roles assigned yet.</span>
Expand Down
Loading