diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index dd44f18..f8e673e 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -16,14 +16,20 @@ """ from django.contrib import admin -from django.urls import path from django.conf import settings from django.conf.urls.static import static from django.http import HttpResponse from django.shortcuts import redirect +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), +# UI + path('accounts/', include('accounts.urls')), + + # API + path('api/accounts/', include('accounts.api_urls')), + ] # Add static files diff --git a/accounts/api_urls.py b/accounts/api_urls.py new file mode 100644 index 0000000..8d6dffa --- /dev/null +++ b/accounts/api_urls.py @@ -0,0 +1,26 @@ +""" +Authentication API routes for the Accounts app. + +This module defines the endpoints for user registration, login, +profile retrieval, and logout. These routes are included in the +project’s main urls.py under the prefix "api/accounts/", which means +the final URLs are: + + /api/accounts/auth/register/ → Register a new user + /api/accounts/auth/login/ → Log in an existing user + /api/accounts/auth/profile/ → Retrieve the authenticated user's profile + /api/accounts/auth/logout/ → Log out the current user + +Each path is mapped to a class-based API view defined in accounts/api_views.py. +""" + +from django.urls import path +from .api_views import RegistrationAPI, LoginAPI, ProfileAPI, LogoutAPI + + +urlpatterns = [ + path('auth/register/', RegistrationAPI.as_view(), name='api_register'), + path('auth/login/', LoginAPI.as_view(), name='api_login'), + path('auth/profile/', ProfileAPI.as_view(), name='api_profile'), + path('auth/logout/', LogoutAPI.as_view(), name='api_logout'), +] diff --git a/accounts/api_views.py b/accounts/api_views.py new file mode 100644 index 0000000..6097b31 --- /dev/null +++ b/accounts/api_views.py @@ -0,0 +1,96 @@ +""" +API views for the Accounts app. + +This module defines class-based views for handling user authentication +via RESTful endpoints. It includes registration, login, profile retrieval, +and logout functionality. These views are connected to the routes defined +in accounts/api_urls.py and use serializers from accounts/serializers.py. + +Available API views: + - RegistrationAPI: create a new user and log them in automatically. + - LoginAPI: authenticate user credentials and start a session. + - ProfileAPI: return profile data for the authenticated user. + - LogoutAPI: end the current user session. +""" + +from django.contrib.auth import login as auth_login, logout as auth_logout +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView +from .serializers import RegistrationSerializer, LoginSerializer, ProfileSerializer + +class RegistrationAPI(generics.CreateAPIView): + """ + API endpoint for user registration. + + Handles the creation of a new user account using validated input. + Automatically logs in the newly created user to establish a session. + """ + serializer_class = RegistrationSerializer + permission_classes = [permissions.AllowAny] + + def perform_create(self, serializer): + """ + Save the new user instance and log them in. + + Overrides the default CreateAPIView behavior to attach the user + to the current session immediately after registration. + """ + user = serializer.save() + auth_login(self.request, user) + +class LoginAPI(APIView): + """ + API endpoint for user login. + + Accepts username and password, authenticates the user, + and returns their profile data upon successful login. + """ + permission_classes = [permissions.AllowAny] + + def post(self, request): + """ + Authenticate user credentials and start a session. + + If credentials are valid, the user is logged in and their + profile data is returned in the response. + """ + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + auth_login(request, user) + return Response(ProfileSerializer(user).data) + +class ProfileAPI(generics.RetrieveAPIView): + """ + API endpoint for retrieving the authenticated user's profile. + + Requires the user to be logged in. Returns basic profile information. + """ + serializer_class = ProfileSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + """ + Return the current authenticated user. + + Used by RetrieveAPIView to serialize and return profile data. + """ + return self.request.user + +class LogoutAPI(APIView): + """ + API endpoint for logging out the current user. + + Requires authentication. Ends the session and returns a confirmation message. + """ + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + """ + End the current user session. + + Logs out the user and returns a success response. + """ + auth_logout(request) + return Response({'detail': 'You are out of the system'}, status=status.HTTP_200_OK) diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..945d67c --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,41 @@ +""" +Forms for the Accounts app. + +This module defines form classes used for user registration and login. +They extend Django's built-in authentication forms to include additional +fields or custom behavior where necessary. + +Available forms: + - RegistrationForm: extends UserCreationForm to include an email field. + - LoginForm: extends AuthenticationForm for user login. +""" + +from django import forms +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.contrib.auth import get_user_model + +User = get_user_model() + +class RegistrationForm(UserCreationForm): + """ + Form for user registration. + + Extends Django's built-in UserCreationForm by adding + a required email field. Handles validation and creation + of a new user instance with username, email, and password. + """ + email = forms.EmailField(required=True) + + class Meta: + model = User + fields = ('username', 'email', 'password1', 'password2') + +class LoginForm(AuthenticationForm): + """ + Form for user login. + + Extends Django's built-in AuthenticationForm without + additional fields. Used to authenticate existing users + with their username and password. + """ + pass diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..979d751 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,76 @@ +""" +Serializers for the Accounts app. + +This module defines serializers used for user authentication and profile +management. They handle validation and transformation of input/output data +between Django models and API views. + +Available serializers: + - RegistrationSerializer: validates and creates new user accounts. + - LoginSerializer: authenticates existing users with username/password. + - ProfileSerializer: returns basic profile information for authenticated users. +""" + +from django.contrib.auth import authenticate, get_user_model +from rest_framework import serializers + +User = get_user_model() + +class RegistrationSerializer(serializers.ModelSerializer): + """ + Serializer for user registration. + + Validates the provided username, email, and password. + Creates a new user instance with an encrypted password. + """ + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ('username', 'email', 'password') + + def create(self, validated_data): + """ + Create a new user with the given validated data. + + Uses Django's built-in create_user method to ensure + the password is properly hashed before saving. + """ + return User.objects.create_user( + username=validated_data['username'], + email=validated_data.get('email', ''), + password=validated_data['password'], + ) + +class LoginSerializer(serializers.Serializer): + """ + Serializer for user login. + + Accepts username and password, and authenticates the user + using Django's built-in authentication system. + """ + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + """ + Validate the provided credentials. + + If authentication fails, raise a ValidationError. + On success, attach the authenticated user to attrs. + """ + user = authenticate(username=attrs['username'], password=attrs['password']) + if not user: + raise serializers.ValidationError('Incorrect login details') + attrs['user'] = user + return attrs + +class ProfileSerializer(serializers.ModelSerializer): + """ + Serializer for displaying user profile data. + + Returns basic information about the authenticated user. + """ + class Meta: + model = User + fields = ('id', 'username', 'email') diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html new file mode 100644 index 0000000..ca0bc63 --- /dev/null +++ b/accounts/templates/accounts/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +

Login

+
+ {% csrf_token %} + {{ form.as_p }} + +
+

+ Don't have an account? Registration +

+{% endblock %} diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html new file mode 100644 index 0000000..68191ef --- /dev/null +++ b/accounts/templates/accounts/profile.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Profile{% endblock %} + +{% block content %} +

Profile

+

User name: {{ user.username }}

+

Email: {{ user.email }}

+
+ {% csrf_token %} + +
+{% endblock %} diff --git a/accounts/templates/accounts/registration.html b/accounts/templates/accounts/registration.html new file mode 100644 index 0000000..52bbee1 --- /dev/null +++ b/accounts/templates/accounts/registration.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Register{% endblock %} + +{% block content %} +

Registration

+
+ {% csrf_token %} + {{ form.as_p }} + +
+Already exist? Login +{% endblock %} diff --git a/accounts/templates/base.html b/accounts/templates/base.html new file mode 100644 index 0000000..61e5dd0 --- /dev/null +++ b/accounts/templates/base.html @@ -0,0 +1,27 @@ + + + + + {% block title %}Fools Arena{% endblock %} + + +
+

Fools Arena

+ +
+ +
+ {% block content %} + + {% endblock %} +
+ + + + diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py new file mode 100644 index 0000000..c08c576 --- /dev/null +++ b/accounts/tests/test_auth.py @@ -0,0 +1,199 @@ +"""Authentication tests for both UI and REST API endpoints. + +This module contains test cases for verifying authentication flows in +a Django application that provides both template-based (UI) and +REST API endpoints. Tests cover registration, login, logout, and +profile access behaviors. +""" + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse + +User = get_user_model() + + +@pytest.mark.django_db +class TestTemplateAuth: + """Test suite for UI-based authentication using Django templates.""" + + def test_register_valid(self, client): + """Test successful registration through UI. + + Sends valid registration data through a POST request to the 'register' view. + Ensures that the user is created and redirected to the profile page. + + Args: + client (django.test.Client): Django test client fixture. + """ + resp = client.post(reverse('register'), { + 'username': 'maksim', + 'email': 'm@example.com', + 'password1': 'StrongPass123', + 'password2': 'StrongPass123', + }) + assert resp.status_code == 302 + assert resp.url == reverse('profile') + assert User.objects.filter(username='maksim').exists() + + def test_register_invalid_password_mismatch(self, client): + """Test registration with mismatched passwords. + + Ensures that invalid password confirmation prevents user creation + and that the registration form is re-rendered with status 200. + """ + resp = client.post(reverse('register'), { + 'username': 'bad', + 'email': 'b@example.com', + 'password1': 'StrongPass123', + 'password2': 'StrongPass124', + }) + assert resp.status_code == 200 + assert not User.objects.filter(username='bad').exists() + + def test_login_valid(self, client, user_factory): + """Test successful login through UI. + + Verifies that valid credentials redirect the user to the profile page. + """ + user = user_factory(password="test123") + resp = client.post(reverse('login'), { + 'username': user.username, + 'password': 'test123', + }) + assert resp.status_code == 302 + assert resp.url == reverse('profile') + + def test_login_invalid(self, client): + """Test login with invalid credentials. + + Ensures the response remains on the login page (status 200) and does not redirect. + """ + resp = client.post(reverse('login'), { + 'username': 'nope', + 'password': 'wrong' + }) + assert resp.status_code == 200 + + def test_profile_requires_authentication(self, client): + """Test profile page access without authentication. + + Ensures that unauthenticated users are redirected to the login page. + """ + resp = client.get(reverse('profile')) + assert resp.status_code == 302 + assert reverse('login') in resp.url + + def test_logout(self, client, user_factory): + """Test logout functionality through UI. + + Verifies that an authenticated user is logged out and redirected to the login page. + """ + user = user_factory(password="test123") + client.post(reverse('login'), {'username': user.username, 'password': 'test123'}) + resp = client.post(reverse('logout')) + assert resp.status_code == 302 + assert resp.url == reverse('login') + + +@pytest.mark.django_db +class TestAPIAuth: + """Test suite for REST API authentication endpoints.""" + + def test_api_register_valid(self, api_client): + """Test successful API registration. + + Sends valid user data to the registration endpoint and verifies + that a new user is created with HTTP 201 Created. + """ + register_url = reverse("api_register") + resp = api_client.post(register_url, { + 'username': 'maksim_api', + 'email': 'mapi@example.com', + 'password': 'StrongPass123', + }, format='json') + assert resp.status_code == 201 + assert User.objects.filter(username='maksim_api').exists() + + def test_api_register_invalid(self, api_client): + """Test API registration with invalid data. + + Ensures that malformed input returns HTTP 400 Bad Request. + """ + register_url = reverse("api_register") + resp = api_client.post(register_url, { + 'username': '', + 'email': 'bad', + 'password': 'short', + }, format='json') + assert resp.status_code == 400 + + def test_api_login_valid(self, api_client, user_factory): + """Test successful API login. + + Sends valid credentials to the login endpoint and verifies that + profile data is returned in the response. + """ + user = user_factory(password="test123") + url = reverse("api_login") + resp = api_client.post(url, { + 'username': user.username, + 'password': 'test123' + }, format='json') + assert resp.status_code == 200 + assert resp.data['username'] == user.username + + def test_api_login_invalid(self, api_client): + """Test API login with invalid credentials. + + Ensures that incorrect credentials return HTTP 400 Bad Request. + """ + url = reverse("api_login") + resp = api_client.post(url, { + 'username': 'nope', + 'password': 'wrong' + }, format='json') + assert resp.status_code == 400 + + def test_api_profile_authenticated(self, api_client, user_factory): + """Test authenticated API profile access. + + After logging in, verifies that the authenticated user can retrieve + their own profile data. + """ + login_url = reverse("api_login") + user = user_factory(password="test123") + api_client.post(login_url, { + 'username': user.username, + 'password': 'test123' + }, format='json') + profile_url = reverse("api_profile") + resp = api_client.get(profile_url) + assert resp.status_code == 200 + assert resp.data['username'] == user.username + + def test_api_profile_unauthenticated(self, api_client): + """Test unauthenticated API profile access. + + Ensures that accessing the profile endpoint without authentication + returns HTTP 403 Forbidden. + """ + profile_url = reverse("api_profile") + resp = api_client.get(profile_url) + assert resp.status_code == 403 + + def test_api_logout(self, api_client, user_factory): + """Test API logout. + + Verifies that an authenticated user can log out successfully, + receiving HTTP 200 OK in response. + """ + login_url = reverse("api_login") + user = user_factory(password="test123") + api_client.post(login_url, { + 'username': user.username, + 'password': 'test123' + }, format='json') + logout_url = reverse("api_logout") + resp = api_client.post(logout_url) + assert resp.status_code == 200 diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..0444d0f --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,25 @@ +""" +Authentication template routes for the Accounts app. + +This module defines the endpoints for user registration, login, +profile display, and logout. These routes are included in the +project’s main urls.py under the prefix "accounts/", which means +the final URLs are: + + /accounts/register/ → Render the registration form and create a new user + /accounts/login/ → Render the login form and authenticate a user + /accounts/profile/ → Display the authenticated user's profile page + /accounts/logout/ → Log out the current user and redirect accordingly + +Each path is mapped to a function-based view defined in accounts/views.py. +""" + +from django.urls import path +from .views import register_view, login_view, profile_view, logout_view + +urlpatterns = [ + path('register/', register_view, name='register'), + path('login/', login_view, name='login'), + path('profile/', profile_view, name='profile'), + path('logout/', logout_view, name='logout'), +] diff --git a/accounts/views.py b/accounts/views.py index 91ea44a..55ce079 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,84 @@ -from django.shortcuts import render +""" +Views for the Accounts app. -# Create your views here. +This module defines function-based views for handling user authentication +through HTML templates. It includes registration, login, profile display, +and logout functionality. These views are connected to the routes defined +in accounts/urls.py and render templates located in accounts/templates/accounts/. + +Available views: + - register_view: render and process the registration form. + - login_view: render and process the login form. + - profile_view: display the authenticated user's profile page. + - logout_view: log out the current user and redirect accordingly. +""" + +from django.contrib.auth import login as auth_login, logout as auth_logout +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from django.views.decorators.csrf import csrf_protect + +from .forms import RegistrationForm, LoginForm + +@csrf_protect +def register_view(request): + """ + Render and process the registration form. + + If the request method is POST and the form is valid, a new user + is created and automatically logged in. On success, the user is + redirected to the profile page. Otherwise, the registration form + is re-rendered with validation errors. + """ + if request.method == 'POST': + form = RegistrationForm(request.POST) + if form.is_valid(): + user = form.save() + auth_login(request, user) + return redirect('profile') + else: + form = RegistrationForm() + return render(request, 'accounts/registration.html', {'form': form}) + +@csrf_protect +def login_view(request): + """ + Render and process the login form. + + If the request method is POST and the form is valid, the user + is authenticated and logged in. On success, the user is redirected + to the profile page. Otherwise, the login form is re-rendered with + validation errors. + """ + if request.method == 'POST': + form = LoginForm(request, data=request.POST) + if form.is_valid(): + auth_login(request, form.get_user()) + return redirect('profile') + else: + form = LoginForm() + return render(request, 'accounts/login.html', {'form': form}) + +@login_required +def profile_view(request): + """ + Display the authenticated user's profile page. + + Requires the user to be logged in. If the user is not authenticated, + they will be redirected to the login page. + """ + return render(request, 'accounts/profile.html') + +@csrf_protect +def logout_view(request): + """ + Log out the current user. + + If the request method is POST, the user is logged out and redirected + to the login page. For non-POST requests, the user is redirected + back to the profile page. + """ + if request.method == 'POST': + auth_logout(request) + return redirect('login') + return redirect('profile') diff --git a/conftest.py b/conftest.py index 23d949f..1a1b3b4 100644 --- a/conftest.py +++ b/conftest.py @@ -29,6 +29,7 @@ def test_basic_game_has_trump(basic_game, basic_cards): django.setup() from django.contrib.auth import get_user_model +from rest_framework.test import APIClient from game.models import ( CardSuit, CardRank, Card, Lobby, LobbySettings, Game, GamePlayer, SpecialCard, SpecialRuleSet @@ -357,3 +358,8 @@ def basic_rule_set(db): description="Simple special cards for new players", min_players=2 ) + +@pytest.fixture +def api_client(): + """DRF APIClient for API-tests.""" + return APIClient()