From 982d197e99db3abd4cd93f9e7d4e2227d8222050 Mon Sep 17 00:00:00 2001 From: Adrian333Dev Date: Wed, 30 Aug 2023 10:15:42 +0400 Subject: [PATCH] user api implemented --- Makefile | 127 +++++++++++++++++++-------- app/config/settings.py | 2 + app/config/urls.py | 3 +- app/user/__init__.py | 0 app/user/apps.py | 6 ++ app/user/serializers.py | 62 +++++++++++++ app/user/tests/__init__.py | 0 app/user/tests/test_user_api.py | 151 ++++++++++++++++++++++++++++++++ app/user/urls.py | 15 ++++ app/user/views.py | 36 ++++++++ 10 files changed, 364 insertions(+), 38 deletions(-) create mode 100644 app/user/__init__.py create mode 100644 app/user/apps.py create mode 100644 app/user/serializers.py create mode 100644 app/user/tests/__init__.py create mode 100644 app/user/tests/test_user_api.py create mode 100644 app/user/urls.py create mode 100644 app/user/views.py diff --git a/Makefile b/Makefile index 78623c2..0030c24 100644 --- a/Makefile +++ b/Makefile @@ -1,65 +1,118 @@ # Makefile -# Docker Compose commands +# Variables DC=docker-compose -DC_BUILD=$(DC) build -DC_UP=$(DC) up -DC_DOWN=$(DC) down DC_RUN=$(DC) run --rm app sh -c -FLAKE8=$(DC_RUN) "flake8" -STARTPROJECT=$(DC_RUN) django-admin startproject -TEST="python manage.py test" +# Commands +.PHONY: help create-and-run-migrations migrate createmigration run-server superuser shell docker-build docker-up docker-down update test create-project create-app default: help -dcup: - $(DC_UP) -up: dcup +create-and-run-migrations: + @echo "Creating and running migrations..." + $(DC_RUN) "python manage.py makemigrations && python manage.py migrate" +crm: create-and-run-migrations -dcdown: - $(DC_DOWN) -down: dcdown +migrate: + @echo "Running migrations..." + $(DC_RUN) "python manage.py migrate" +m: migrate + +createmigration: + @echo "Creating migrations..." + $(DC_RUN) "python manage.py makemigrations" +cm: createmigration + +run-server: + @echo "Running server..." + $(DC_RUN) "python manage.py runserver" +r: run-server + +superuser: + @echo "Creating superuser..." + $(DC_RUN) "python manage.py createsuperuser" +su: superuser + +shell: + @echo "Running shell..." + $(DC_RUN) "python manage.py shell" +sh: shell + +docker-build: + @echo "Building docker image..." + $(DC) build +build: docker-build + +docker-up: + @echo "Running docker containers..." + $(DC) up +up: docker-up -dcbuild: - $(DC_BUILD) -build: dcbuild +docker-down: + @echo "Stopping docker containers..." + $(DC) down +down: docker-down + +update: makemigrations docker-build test: + @echo "Running tests..." $(DC_RUN) "python manage.py test && flake8" t: test - -createproject: +create-project: ifeq ($(filter-out $@,$(MAKECMDGOALS)),) - @echo "Usage: make createproject project_name [optional: .]" + @echo "Please provide a project name" else - $(DC_RUN) "django-admin startproject $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))" + @echo "Creating project..." + django-admin startproject $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) . endif -cp: createproject +cp: create-project -createapp: +create-app: ifeq ($(filter-out $@,$(MAKECMDGOALS)),) - @echo "Usage: make createapp app_name" + @echo "Please provide an app name" else + @echo "Creating app..." $(DC_RUN) "python manage.py startapp $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))" endif -ca: createapp +ca: create-app -createsuperuser: - $(DC_RUN) "python manage.py createsuperuser" -su: createsuperuser - -migrations: - $(DC_RUN) "python manage.py makemigrations" -ms: migrations - -migrate: - $(DC_RUN) "python manage.py wait_for_db && python manage.py migrate" -m: migrate +# Help help: - @echo "Usage: make [target]" + @echo "Usage: make [command]" + @echo "" + @echo "Commands:" + @echo " crm, create-and-run-migrations Create and run migrations" + @echo " m, migrate Run migrations" + @echo " cm, createmigration Create migrations" + @echo " r, run-server Run server" + @echo " su, superuser Create superuser" + @echo " sh, shell Run shell" + @echo " u, update Make migrations and and rebuild docker image" + @echo " build Build docker image" + @echo " up Run docker containers" + @echo " down Stop docker containers" + @echo " t, test Run tests" + @echo " cp, create-project Create project" + @echo " ca, create-app Create app" + @echo " help Show this help message and exit" @echo "" - @echo "Targets:" \ No newline at end of file + @echo "Examples:" + @echo " make crm" + @echo " make m" + @echo " make cm" + @echo " make r" + @echo " make su" + @echo " make sh" + @echo " make u" + @echo " make build" + @echo " make up" + @echo " make down" + @echo " make t" + @echo " make cp " + @echo " make ca " + @echo " make help" \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py index 728c4e2..47e3ec2 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -40,9 +40,11 @@ "django.contrib.staticfiles", # Third party apps "rest_framework", + "rest_framework.authtoken", "drf_spectacular", # Local apps "core", + "user", ] MIDDLEWARE = [ diff --git a/app/config/urls.py b/app/config/urls.py index 341e89b..94c2463 100644 --- a/app/config/urls.py +++ b/app/config/urls.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.urls import path +from django.urls import path, include from drf_spectacular.views import ( SpectacularAPIView, SpectacularSwaggerView, @@ -13,4 +13,5 @@ SpectacularSwaggerView.as_view(url_name="api-schema"), name="api-docs", ), + path("api/user/", include("user.urls")), ] diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/apps.py b/app/user/apps.py new file mode 100644 index 0000000..36cce4c --- /dev/null +++ b/app/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/app/user/serializers.py b/app/user/serializers.py new file mode 100644 index 0000000..9b0b4e2 --- /dev/null +++ b/app/user/serializers.py @@ -0,0 +1,62 @@ +""" +Serializers for the user API View. +""" +from django.contrib.auth import get_user_model, authenticate +from django.utils.translation import gettext as _ + +from rest_framework.serializers import ( + ModelSerializer, + Serializer, + CharField, + EmailField, + ValidationError, +) + + +class UserSerializer(ModelSerializer): + """ + Serializer for the User model. + """ + + class Meta: + model = get_user_model() + fields = ["username", "email", "password"] + extra_kwargs = {"password": {"write_only": True, "min_length": 8}} + + def create(self, validated_data): + """Create a new user with encrypted password and return it.""" + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + """Update a user, setting the password correctly and return it.""" + password = validated_data.pop("password", None) + user = super().update(instance, validated_data) + + if password: + user.set_password(password) + user.save() + + return user + + +class AuthTokenSerializer(Serializer): + """Serializer for the user authentication object.""" + + email = EmailField() + password = CharField(style={"input_type": "password"}, trim_whitespace=False) + + def validate(self, attrs): + """Validate and authenticate the user.""" + email = attrs.get("email") + password = attrs.get("password") + + user = authenticate( + request=self.context.get("request"), username=email, password=password + ) + + if not user: + msg = _("Unable to authenticate with provided credentials.") + raise ValidationError(msg, code="authentication") + + attrs["user"] = user + return attrs diff --git a/app/user/tests/__init__.py b/app/user/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/tests/test_user_api.py b/app/user/tests/test_user_api.py new file mode 100644 index 0000000..3c7394f --- /dev/null +++ b/app/user/tests/test_user_api.py @@ -0,0 +1,151 @@ +""" +Tests for the user API. +""" +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse + +from rest_framework.test import APIClient +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_405_METHOD_NOT_ALLOWED, +) + +from core.constants.mock_data import john_doe, mock_user + + +CREATE_USER_URL = reverse("user:create") +TOKEN_URL = reverse("user:token") +ME_URL = reverse("user:me") + + +def create_user(**params): + """Helper function to create a new user.""" + return get_user_model().objects.create_user(**params) + + +class PublicUserApiTests(TestCase): + """Test the users API (public).""" + + def setUp(self): + self.client = APIClient() + + def test_create_valid_user_success(self): + """Test creating user with valid payload is successful.""" + payload = john_doe.copy() + + res = self.client.post(CREATE_USER_URL, payload) + self.assertEqual(res.status_code, HTTP_201_CREATED) + + user = get_user_model().objects.get(email=payload["email"]) + self.assertTrue(user.check_password(payload["password"])) + self.assertNotIn("password", res.data) + + def test_user_with_existing_email(self): + """Test creating user with existing email fails.""" + payload = john_doe.copy() + payload["username"] = "unique_username123" + + create_user(**payload) + res = self.client.post(CREATE_USER_URL, payload) + self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) + + def test_user_with_existing_username(self): + """Test creating user with existing username fails.""" + payload = john_doe.copy() + payload["email"] = "unique_email123@exmaple.com" + + create_user(**payload) + res = self.client.post(CREATE_USER_URL, payload) + self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) + + def test_user_with_too_short_password(self): + """Test creating user with too short password fails.""" + payload = mock_user("Bobby", "Davis") + payload["password"] = "pw" + + res = self.client.post(CREATE_USER_URL, payload) + self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) + + user_exists = get_user_model().objects.filter(email=payload["email"]).exists() + self.assertFalse(user_exists) + + def test_create_token_for_user(self): + """Test that a token is created for the user.""" + user = mock_user("Henry", "Ford") + create_user(**user) + payload = {"email": user["email"], "password": user["password"]} + res = self.client.post(TOKEN_URL, payload) + + self.assertIn("token", res.data) + self.assertEqual(res.status_code, HTTP_200_OK) + + def test_create_token_invalid_credentials(self): + """Test that token is not created if invalid credentials are given.""" + user = mock_user("Thomas", "Edison") + create_user(**user) + payload = {"email": user["email"], "password": "wrong_password"} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn("token", res.data) + self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) + + def test_create_token_no_user(self): + """Test that token is not created if user doesn't exist.""" + payload = {"email": "non_existing_user@example.com", "password": "pass12345"} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn("token", res.data) + self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) + + def test_create_token_blank_password(self): + """Test that token is not created if password is blank.""" + payload = {"email": john_doe["email"], "password": ""} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn("token", res.data) + self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) + + def test_retrieve_user_unauthorized(self): + """Test that authentication is required for users.""" + res = self.client.get(ME_URL) + + self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED) + + +class PrivateUserApiTests(TestCase): + """Test API requests that require authentication.""" + + def setUp(self): + self.user = create_user(**john_doe) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_retrieve_profile_success(self): + """Test retrieving profile for logged in user.""" + res = self.client.get(ME_URL) + + self.assertEqual(res.status_code, HTTP_200_OK) + self.assertEqual( + res.data, {"username": self.user.username, "email": self.user.email} + ) + + def test_post_me_not_allowed(self): + """Test that POST is not allowed on the me URL.""" + res = self.client.post(ME_URL, {}) + + self.assertEqual(res.status_code, HTTP_405_METHOD_NOT_ALLOWED) + + def test_update_user_profile(self): + """Test updating the user profile for authenticated user.""" + payload = {"username": "new_username", "password": "new_password123"} + + res = self.client.patch(ME_URL, payload) + + self.user.refresh_from_db() + self.assertEqual(self.user.username, payload["username"]) + self.assertTrue(self.user.check_password(payload["password"])) + self.assertEqual(res.status_code, HTTP_200_OK) diff --git a/app/user/urls.py b/app/user/urls.py new file mode 100644 index 0000000..db19b29 --- /dev/null +++ b/app/user/urls.py @@ -0,0 +1,15 @@ +""" +URL mappings for the user API. +""" +from django.urls import path + +from user.views import CreateUserView, CreateTokenView, ManageUserView + + +app_name = "user" + +urlpatterns = [ + path("create/", CreateUserView.as_view(), name="create"), + path("token/", CreateTokenView.as_view(), name="token"), + path("me/", ManageUserView.as_view(), name="me"), +] diff --git a/app/user/views.py b/app/user/views.py new file mode 100644 index 0000000..5f0a5bb --- /dev/null +++ b/app/user/views.py @@ -0,0 +1,36 @@ +""" +Views for the user API. +""" +from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView +from rest_framework.authtoken.views import ObtainAuthToken +from rest_framework.authentication import TokenAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.settings import api_settings + +# Create your views here. +from user.serializers import UserSerializer, AuthTokenSerializer + + +class CreateUserView(CreateAPIView): + """Create a new user in the system.""" + + serializer_class = UserSerializer + + +class CreateTokenView(ObtainAuthToken): + """Create a new auth token for the user.""" + + serializer_class = AuthTokenSerializer + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + + +class ManageUserView(RetrieveUpdateAPIView): + """Manage the authenticated user.""" + + serializer_class = UserSerializer + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + def get_object(self): + """Retrieve and return the authenticated user.""" + return self.request.user