From cdf1469329f2275703d3663755d40e4457fd36c1 Mon Sep 17 00:00:00 2001 From: adrian adewunmi Date: Thu, 7 May 2026 13:41:42 +0100 Subject: [PATCH 1/6] feat(roles): add reusable role-aware permission helpers --- apps/roles/permissions.py | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/roles/permissions.py diff --git a/apps/roles/permissions.py b/apps/roles/permissions.py new file mode 100644 index 0000000..bee7d67 --- /dev/null +++ b/apps/roles/permissions.py @@ -0,0 +1,47 @@ +"""Reusable role-aware permission helpers.""" + +from __future__ import annotations + +from collections.abc import Iterable, Callable +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.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 \ No newline at end of file From 97cbf1220f041b2a0da1566fef1862ec30945f26 Mon Sep 17 00:00:00 2001 From: adrian adewunmi Date: Thu, 7 May 2026 13:44:58 +0100 Subject: [PATCH 2/6] feat(docs): add authentication and access control documentation --- docs/authentication.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/authentication.md diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..9fd11b7 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,18 @@ + +## `docs/authentication.md` + +```markdown +# Authentication and Access Control + +Sprint 1 introduces the StudyBuddy identity baseline. + +The goal is to support a real SaaS product shape without overbuilding permissions before product workflows exist. + +## User model + +StudyBuddy uses `apps.users.CustomUser`. + +The model extends Django's `AbstractUser` and changes the login identifier to email. + +```python +USERNAME_FIELD = "email" \ No newline at end of file From d04fa1862864c4b6cb3a43599a8e90d8e6384d84 Mon Sep 17 00:00:00 2001 From: adrian adewunmi Date: Thu, 7 May 2026 13:47:43 +0100 Subject: [PATCH 3/6] feat(permissions): update role checks to use studybuddy_roles relation and enhance tests --- apps/roles/permissions.py | 6 +- apps/roles/tests/test_models.py | 24 ++++++ docs/authentication.md | 136 +++++++++++++++++++++++++++++--- 3 files changed, 154 insertions(+), 12 deletions(-) diff --git a/apps/roles/permissions.py b/apps/roles/permissions.py index bee7d67..87ee2ce 100644 --- a/apps/roles/permissions.py +++ b/apps/roles/permissions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterable, Callable +from collections.abc import Callable, Iterable from functools import wraps from typing import Any @@ -18,7 +18,7 @@ def user_has_role(user: Any, role_slug: str) -> bool: if getattr(user, "is_superuser", False): return True - return user.roles.filter(slug=role_slug).exists() + return user.studybuddy_roles.filter(slug=role_slug).exists() def user_has_any_role(user: Any, role_slugs: Iterable[str]) -> bool: @@ -44,4 +44,4 @@ def wrapped_view( return wrapped_view - return decorator \ No newline at end of file + return decorator diff --git a/apps/roles/tests/test_models.py b/apps/roles/tests/test_models.py index 15cebd0..829e280 100644 --- a/apps/roles/tests/test_models.py +++ b/apps/roles/tests/test_models.py @@ -3,10 +3,13 @@ from __future__ import annotations import pytest +from django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, transaction from apps.roles.factories import RoleFactory from apps.roles.models import Role +from apps.roles.permissions import user_has_any_role, user_has_role +from apps.users.factories import CustomUserFactory @pytest.mark.django_db @@ -42,3 +45,24 @@ 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") + + +@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") diff --git a/docs/authentication.md b/docs/authentication.md index 9fd11b7..13c46da 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,18 +1,136 @@ - -## `docs/authentication.md` - -```markdown # Authentication and Access Control -Sprint 1 introduces the StudyBuddy identity baseline. +Sprint 1 establishes the StudyBuddy identity baseline and the first protected +product surface. -The goal is to support a real SaaS product shape without overbuilding permissions before product workflows exist. +The current goal is a real SaaS-shaped authentication journey without +overbuilding permissions before study-session workflows exist. -## User model +## User Model StudyBuddy uses `apps.users.CustomUser`. -The model extends Django's `AbstractUser` and changes the login identifier to email. +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 -USERNAME_FIELD = "email" \ No newline at end of file +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. From ea64eb07ec74c4ba9c28f858a7dc4387a4face48 Mon Sep 17 00:00:00 2001 From: adrian adewunmi Date: Thu, 7 May 2026 13:53:11 +0100 Subject: [PATCH 4/6] feat(profile): update profile view to include user roles in context and template --- apps/users/tests/test_auth_views.py | 16 ++++++++++++++++ apps/users/views.py | 10 +++++++++- templates/users/profile.html | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/users/tests/test_auth_views.py b/apps/users/tests/test_auth_views.py index 1368def..dd960e6 100644 --- a/apps/users/tests/test_auth_views.py +++ b/apps/users/tests/test_auth_views.py @@ -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 @@ -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 diff --git a/apps/users/views.py b/apps/users/views.py index 078f1a0..7a46d35 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -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, + }, + ) diff --git a/templates/users/profile.html b/templates/users/profile.html index 21a5922..9dea674 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -37,7 +37,7 @@

Account Details

Roles
- {% for role in request.user.studybuddy_roles.all %} + {% for role in roles %} {{ role.display_name }} {% empty %} No roles assigned yet. From bcfb6596794db345e8a4ce4e42da8af36e3624cb Mon Sep 17 00:00:00 2001 From: adrian adewunmi Date: Thu, 7 May 2026 14:05:40 +0100 Subject: [PATCH 5/6] feat(tests): enhance role permission tests with decorators for access control --- apps/roles/tests/test_models.py | 39 ++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/roles/tests/test_models.py b/apps/roles/tests/test_models.py index 829e280..a78fb99 100644 --- a/apps/roles/tests/test_models.py +++ b/apps/roles/tests/test_models.py @@ -4,11 +4,14 @@ 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 user_has_any_role, user_has_role +from apps.roles.permissions import role_required, user_has_any_role, user_has_role from apps.users.factories import CustomUserFactory @@ -57,6 +60,7 @@ def test_user_has_role_uses_studybuddy_roles_relation(): 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 @@ -66,3 +70,36 @@ def test_role_helpers_handle_anonymous_users_and_superusers(): 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() + + @role_required("admin") + def protected_view(request): + return HttpResponse("allowed") + + with pytest.raises(PermissionDenied): + protected_view(request) From bc081953323dd15b9f532bd3a5a859944f63c9af Mon Sep 17 00:00:00 2001 From: adrian adewunmi Date: Thu, 7 May 2026 14:10:50 +0100 Subject: [PATCH 6/6] feat(tests): enhance role_required test to use mock for protected view --- apps/roles/tests/test_models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/roles/tests/test_models.py b/apps/roles/tests/test_models.py index a78fb99..417470a 100644 --- a/apps/roles/tests/test_models.py +++ b/apps/roles/tests/test_models.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import Mock + import pytest from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied @@ -96,10 +98,10 @@ 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() - - @role_required("admin") - def protected_view(request): - return HttpResponse("allowed") + 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()