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
13 changes: 11 additions & 2 deletions backend/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import os
from pathlib import Path
from dotenv import load_dotenv
from datetime import timedelta

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
Expand All @@ -39,6 +40,7 @@
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"users",
"inventory",
Expand Down Expand Up @@ -140,7 +142,14 @@
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication", # Or JWT
"rest_framework_simplejwt.authentication.JWTAuthentication", # Or JWT
],
}

SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1), # They can renew for 24 hours
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"AUTH_HEADER_TYPES": ("Bearer",), # Prefix
}
2 changes: 1 addition & 1 deletion backend/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
urlpatterns = [
path("admin/", admin.site.urls),
# API Routes - Uncomment when ready to use
# path('api/', include('users.urls')),
path("api/", include("users.urls")),
# path('api/inventory/', include('inventory.urls')),
# path('api/', include('requests.urls')),
]
Expand Down
Binary file modified backend/requirements.txt
Binary file not shown.
36 changes: 36 additions & 0 deletions backend/users/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from rest_framework import permissions


class IsAdmin(permissions.BasePermission):
"""
Allows access only to admims
"""

def has_permission(self, request, view):
# Logged in and ADMIN
return bool(request.user and request.user.is_authenticated and request.user.role == "ADMIN")


class IsVolunteer(permissions.BasePermission):
"""
Allows access to volunteers and admins
"""

def has_permission(self, request, view):
# Logged in and VOLUNTEER or ADMIN
return bool(request.user and request.user.is_authenticated and request.user.role in ["VOLUNTEER", "ADMIN"])


class IsActiveAndNotExpired(permissions.BasePermission):
"""
Global check: Is the user active? Has their access expired?
"""

def has_permission(self, request, view):
# 1. Standard authen check
if not request.user or not request.user.is_authenticated:
return False

# Since already cross checking server for user info, might as well add this check for security/instant bans
# This checks: is_active=True AND access_expires_at > Now
return request.user.has_active_access()
63 changes: 37 additions & 26 deletions backend/users/serializers.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
from rest_framework import serializers

# from .models import User
from .models import User

# Create your serializers here.

# Example: User Serializer
# Uncomment and modify as needed
#
# class UserSerializer(serializers.ModelSerializer):
# """
# Serializer for the User model.
# Controls what fields are exposed in the API.
# """
# class Meta:
# model = User
# fields = ['id', 'username', 'email', 'first_name', 'last_name', 'date_joined']
# read_only_fields = ['id', 'date_joined']
#
#
# class UserCreateSerializer(serializers.ModelSerializer):
# """
# Serializer for creating new users with password handling.
# """
# password = serializers.CharField(write_only=True, min_length=8, style={'input_type': 'password'})
#
# class Meta:
# model = User
# fields = ['username', 'email', 'password', 'first_name', 'last_name']
#
# def create(self, validated_data):
# """Create user with encrypted password."""
# return User.objects.create_user(**validated_data)


class UserSerializer(serializers.ModelSerializer):
"""
Serializer for the User model.
Controls what fields are exposed in the API.
"""

class Meta:
model = User
# Changed 'date_joined' to 'created_at'
# Also added 'role' so frontend can see if ADMIN or VOLUNTEER
fields = ["id", "name", "email", "created_at", "role"]
read_only_fields = ["id", "created_at", "role"]


class UserRegistrationSerializer(serializers.ModelSerializer):
"""
Used ONLY for registering new users
"""

password = serializers.CharField(write_only=True, style={"input_type": "password"})

class Meta:
model = User
fields = ["email", "name", "password"]

def create(self, validated_data):
# Need to overwrite create to handle password hashing
user = User.objects.create_user(
email=validated_data["email"],
name=validated_data["name"],
password=validated_data["password"],
role="VOLUNTEER", # Default role for new signups, should this be configurable
)
return user
42 changes: 41 additions & 1 deletion backend/users/tests.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,53 @@
import pytest
from django.urls import reverse
from rest_framework import status
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.mark.django_db
def test_create_user():
"""Test creating a user"""
"""Test creating basic user"""
user = User.objects.create_user(email="test@example.com", name="Test User", password="testpass123")
assert user.email == "test@example.com"
assert user.name == "Test User"
assert user.check_password("testpass123")


@pytest.mark.django_db
def test_login_success(client):
"""Test JWT login success"""
User.objects.create_user(email="login@example.com", name="Login User", password="password123")

url = "/api/auth/login/"
data = {"email": "login@example.com", "password": "password123"}

response = client.post(url, data)

assert response.status_code == status.HTTP_200_OK
assert "access" in response.data
assert "refresh" in response.data


@pytest.mark.django_db
def test_logout_blacklists_token(client):
"""Test JWT Blacklist on logout"""
user = User.objects.create_user(email="logout@example.com", name="Logout User", password="password123")

# Login to get tokens
login_url = "/api/auth/login/"
login_res = client.post(
login_url, {"email": "logout@example.com", "password": "password123"}, content_type="application/json"
)

access = login_res.data["access"]
refresh = login_res.data["refresh"]

# Call Logout with the Authorization header manually added to avoid conftest
logout_url = "/api/auth/logout/"
response = client.post(
logout_url, {"refresh": refresh}, content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {access}"
)

assert response.status_code == status.HTTP_205_RESET_CONTENT
41 changes: 16 additions & 25 deletions backend/users/urls.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,18 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView, # The built-in "Login" view
TokenRefreshView, # The built-in "Refresh Session" view
)
from users.views import LogoutView, RegisterView, UserProfileView

# from .views import UserViewSet

# Create your URL patterns here.

# Example: Using DRF Router for ViewSets
# Uncomment and modify as needed
#
# router = DefaultRouter()
# router.register(r'users', UserViewSet, basename='user')
#
# urlpatterns = [
# path('', include(router.urls)),
# ]

# This will create the following endpoints:
# GET /api/users/ - List all users
# POST /api/users/ - Create a new user
# GET /api/users/{id}/ - Retrieve a specific user
# PUT /api/users/{id}/ - Update a user
# PATCH /api/users/{id}/ - Partial update
# DELETE /api/users/{id}/ - Delete a user
# GET /api/users/me/ - Custom action (if defined in viewset)

urlpatterns = []
urlpatterns = [
# The 'api/' part is already handled in core/urls.py
# Auth Routes
path("auth/register/", RegisterView.as_view(), name="auth_register"),
path("auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("auth/logout/", LogoutView.as_view(), name="auth_logout"),
# User Routes
path("users/me/", UserProfileView.as_view(), name="user_profile"),
]
73 changes: 40 additions & 33 deletions backend/users/views.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,42 @@
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework import generics, status, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from .serializers import UserRegistrationSerializer, UserSerializer

# from .models import User
# from .serializers import UserSerializer, UserCreateSerializer

# Create your views here.

# Example: User ViewSet
# Uncomment and modify as needed
#
# class UserViewSet(viewsets.ModelViewSet):
# """
# ViewSet for managing users.
# Provides CRUD operations: list, create, retrieve, update, delete
# """
# queryset = User.objects.all()
# serializer_class = UserSerializer
# permission_classes = [permissions.IsAuthenticatedOrReadOnly]
#
# def get_serializer_class(self):
# """Return appropriate serializer based on action."""
# if self.action == 'create':
# return UserCreateSerializer
# return UserSerializer
#
# @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
# def me(self, request):
# """
# Custom endpoint: GET /api/users/me/
# Returns the current authenticated user.
# """
# serializer = self.get_serializer(request.user)
# return Response(serializer.data)

# Register new account
class RegisterView(generics.CreateAPIView):
"""
Endpoint: POST /api/auth/register/
"""

serializer_class = UserRegistrationSerializer
permission_classes = [permissions.AllowAny] # Open to public


# User Profile to check own data
class UserProfileView(APIView):
"""
Endpoint: GET /api/users/me/
"""

permission_classes = [permissions.IsAuthenticated]

def get(self, request):
serializer = UserSerializer(request.user)
return Response(serializer.data)


# Logout (Needs both refresh and access tokens)
class LogoutView(APIView):
permission_classes = (permissions.IsAuthenticated,)

def post(self, request):
try:
refresh_token = request.data["refresh"]
token = RefreshToken(refresh_token)
token.blacklist()
return Response(status=status.HTTP_205_RESET_CONTENT)
except Exception:
return Response(status=status.HTTP_400_BAD_REQUEST)
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added requirements.txt
Binary file not shown.