Skip to content

Commit c7039ef

Browse files
authored
Merge pull request #29 from AAdewunmi/feat/add-role-permission-helpers
Feat/add role permission helpers
2 parents 135d38d + bc08195 commit c7039ef

6 files changed

Lines changed: 272 additions & 2 deletions

File tree

apps/roles/permissions.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Reusable role-aware permission helpers."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable, Iterable
6+
from functools import wraps
7+
from typing import Any
8+
9+
from django.core.exceptions import PermissionDenied
10+
from django.http import HttpRequest, HttpResponse
11+
12+
13+
def user_has_role(user: Any, role_slug: str) -> bool:
14+
"""Return whether a user has the requested role slug."""
15+
if not getattr(user, "is_authenticated", False):
16+
return False
17+
18+
if getattr(user, "is_superuser", False):
19+
return True
20+
21+
return user.studybuddy_roles.filter(slug=role_slug).exists()
22+
23+
24+
def user_has_any_role(user: Any, role_slugs: Iterable[str]) -> bool:
25+
"""Return whether a user has at least one of the requested role slugs."""
26+
return any(user_has_role(user, role_slug) for role_slug in role_slugs)
27+
28+
29+
def role_required(role_slug: str) -> Callable:
30+
"""Decorate a view so only users with a role can access it."""
31+
32+
def decorator(view_func: Callable) -> Callable:
33+
"""Wrap a Django view with a role check."""
34+
35+
@wraps(view_func)
36+
def wrapped_view(
37+
request: HttpRequest, *args: Any, **kwargs: Any
38+
) -> HttpResponse:
39+
"""Run the role check before calling the wrapped view."""
40+
if not user_has_role(request.user, role_slug):
41+
raise PermissionDenied
42+
43+
return view_func(request, *args, **kwargs)
44+
45+
return wrapped_view
46+
47+
return decorator

apps/roles/tests/test_models.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
from __future__ import annotations
44

5+
from unittest.mock import Mock
6+
57
import pytest
8+
from django.contrib.auth.models import AnonymousUser
9+
from django.core.exceptions import PermissionDenied
610
from django.db import IntegrityError, transaction
11+
from django.http import HttpResponse
12+
from django.test import RequestFactory
713

814
from apps.roles.factories import RoleFactory
915
from apps.roles.models import Role
16+
from apps.roles.permissions import role_required, user_has_any_role, user_has_role
17+
from apps.users.factories import CustomUserFactory
1018

1119

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

4351
assert role.slug == slug
4452
assert role.display_name == display_name
53+
54+
55+
@pytest.mark.django_db
56+
def test_user_has_role_uses_studybuddy_roles_relation():
57+
"""Role helpers use the current user-side StudyBuddy role relation."""
58+
user = CustomUserFactory()
59+
role = RoleFactory(slug="learner", display_name="Learner")
60+
user.studybuddy_roles.add(role)
61+
62+
assert user_has_role(user, "learner")
63+
assert user_has_any_role(user, ["admin", "learner"])
64+
assert not user_has_role(user, "admin")
65+
assert not user_has_any_role(user, ["admin", "tutor"])
66+
67+
68+
@pytest.mark.django_db
69+
def test_role_helpers_handle_anonymous_users_and_superusers():
70+
"""Anonymous users fail role checks while superusers pass them."""
71+
superuser = CustomUserFactory(is_staff=True, is_superuser=True)
72+
73+
assert not user_has_role(AnonymousUser(), "admin")
74+
assert user_has_role(superuser, "admin")
75+
76+
77+
@pytest.mark.django_db
78+
def test_role_required_allows_users_with_required_role():
79+
"""The role decorator lets users with the required role reach the view."""
80+
user = CustomUserFactory()
81+
role = RoleFactory(slug="learner", display_name="Learner")
82+
user.studybuddy_roles.add(role)
83+
request = RequestFactory().get("/protected/")
84+
request.user = user
85+
86+
@role_required("learner")
87+
def protected_view(request):
88+
return HttpResponse("allowed")
89+
90+
response = protected_view(request)
91+
92+
assert response.status_code == 200
93+
assert response.content == b"allowed"
94+
95+
96+
@pytest.mark.django_db
97+
def test_role_required_denies_users_without_required_role():
98+
"""The role decorator rejects users who do not have the required role."""
99+
request = RequestFactory().get("/protected/")
100+
request.user = CustomUserFactory()
101+
view_func = Mock(return_value=HttpResponse("allowed"))
102+
protected_view = role_required("admin")(view_func)
103+
104+
with pytest.raises(PermissionDenied):
105+
protected_view(request)
106+
107+
view_func.assert_not_called()

apps/users/tests/test_auth_views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.contrib.auth import get_user_model
77
from django.urls import reverse
88

9+
from apps.roles.factories import RoleFactory
910
from apps.users.factories import CustomUserFactory
1011
from apps.users.forms import UserSignUpForm
1112

@@ -259,3 +260,18 @@ def test_authenticated_profile_shows_user_email(client):
259260

260261
assert response.status_code == 200
261262
assert b"profile@example.com" in response.content
263+
264+
265+
@pytest.mark.django_db
266+
def test_authenticated_profile_receives_roles_from_view_context(client):
267+
"""Profile role display is fed by view context, not template permissions."""
268+
user = CustomUserFactory(email="role.profile@example.com")
269+
role = RoleFactory(slug="learner", display_name="Learner")
270+
user.studybuddy_roles.add(role)
271+
client.force_login(user)
272+
273+
response = client.get(reverse("users:profile"))
274+
275+
assert response.status_code == 200
276+
assert list(response.context["roles"]) == [role]
277+
assert b"Learner" in response.content

apps/users/views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,12 @@ def signup(request: HttpRequest) -> HttpResponse:
3131
@login_required
3232
def profile(request: HttpRequest) -> HttpResponse:
3333
"""Render the authenticated user's profile."""
34-
return render(request, "users/profile.html")
34+
roles = request.user.studybuddy_roles.order_by("display_name")
35+
36+
return render(
37+
request,
38+
"users/profile.html",
39+
{
40+
"roles": roles,
41+
},
42+
)

docs/authentication.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Authentication and Access Control
2+
3+
Sprint 1 establishes the StudyBuddy identity baseline and the first protected
4+
product surface.
5+
6+
The current goal is a real SaaS-shaped authentication journey without
7+
overbuilding permissions before study-session workflows exist.
8+
9+
## User Model
10+
11+
StudyBuddy uses `apps.users.CustomUser`.
12+
13+
The model extends Django's `AbstractUser`, keeps Django's standard permission
14+
fields, and changes the login identifier to email:
15+
16+
```python
17+
USERNAME_FIELD = "email"
18+
```
19+
20+
Email addresses are unique and required. Usernames still exist for Django
21+
compatibility, but account creation can generate one from the email local part
22+
when the signup form leaves `username` blank.
23+
24+
The active auth setting is:
25+
26+
```python
27+
AUTH_USER_MODEL = "users.CustomUser"
28+
```
29+
30+
## Authentication Routes
31+
32+
User-facing authentication routes live under `/accounts/`:
33+
34+
- `users:signup` -> `/accounts/signup/`
35+
- `users:login` -> `/accounts/login/`
36+
- `users:logout` -> `/accounts/logout/`
37+
- `users:profile` -> `/accounts/profile/`
38+
39+
Signup and login redirect authenticated users to the dashboard:
40+
41+
```python
42+
LOGIN_URL = "users:login"
43+
LOGIN_REDIRECT_URL = "dashboard:index"
44+
LOGOUT_REDIRECT_URL = "home"
45+
```
46+
47+
The protected dashboard route is:
48+
49+
- `dashboard:index` -> `/dashboard/`
50+
51+
## Signup Flow
52+
53+
`apps.users.forms.CustomUserCreationForm` validates email uniqueness, optional
54+
username uniqueness, and password confirmation through Django's user creation
55+
form behavior.
56+
57+
A successful signup:
58+
59+
- creates a valid `CustomUser`;
60+
- authenticates the new user;
61+
- redirects to `dashboard:index`.
62+
63+
Duplicate email addresses are rejected at the form layer before creating a user.
64+
65+
## Login Flow
66+
67+
The login page uses Django's `LoginView` with `users/login.html`.
68+
69+
Users log in with their email address and password because the custom user model
70+
uses email as `USERNAME_FIELD`.
71+
72+
Authenticated users who visit login or signup are redirected to the dashboard.
73+
74+
## Profile Flow
75+
76+
`users:profile` is login-protected and renders `templates/users/profile.html`.
77+
78+
The profile displays the current user's display name, email, username, and
79+
assigned StudyBuddy roles.
80+
81+
## Roles
82+
83+
StudyBuddy roles are modeled by `apps.roles.Role`.
84+
85+
Roles have:
86+
87+
- `slug`;
88+
- `display_name`;
89+
- optional `description`;
90+
- timestamps;
91+
- a many-to-many relation to the custom user model.
92+
93+
The user-side relation is:
94+
95+
```python
96+
user.studybuddy_roles
97+
```
98+
99+
Use this relation in templates, views, factories, tests, and permission helpers.
100+
Do not use `user.roles`; that is not the current related name.
101+
102+
## Permission Helpers
103+
104+
`apps.roles.permissions` provides lightweight role-aware helpers:
105+
106+
- `user_has_role(user, role_slug)`;
107+
- `user_has_any_role(user, role_slugs)`;
108+
- `role_required(role_slug)`.
109+
110+
Anonymous users fail role checks. Superusers pass role checks. Regular users
111+
pass only when `user.studybuddy_roles` contains the requested slug.
112+
113+
## Dashboard Access
114+
115+
The dashboard is the Sprint 1 post-login product destination.
116+
117+
It is intentionally a protected shell until Sprint 2 adds study-session data.
118+
It displays placeholder study metrics, an empty-session state, account access,
119+
and role-aware messaging backed by `user.studybuddy_roles`.
120+
121+
## Validation
122+
123+
The authentication journey is covered by HTTP-level tests, not just isolated
124+
model creation. Tests verify:
125+
126+
- signup page render;
127+
- signup user creation;
128+
- duplicate email rejection;
129+
- signup redirect-follow behavior;
130+
- login with email and password;
131+
- login redirect-follow behavior;
132+
- authenticated redirects away from login/signup;
133+
- profile protection;
134+
- authenticated profile rendering;
135+
- logout redirect behavior;
136+
- dashboard access for authenticated users.

templates/users/profile.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ <h2 class="card-title-ui">Account Details</h2>
3737
<dt>Roles</dt>
3838
<dd>
3939
<span class="profile-pill-list">
40-
{% for role in request.user.studybuddy_roles.all %}
40+
{% for role in roles %}
4141
<span class="mock-pill">{{ role.display_name }}</span>
4242
{% empty %}
4343
<span class="text-muted-ui">No roles assigned yet.</span>

0 commit comments

Comments
 (0)