├── .gitignore ├── LICENSE ├── README.md ├── requirements.txt └── todoapp ├── manage.py ├── todoapp ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── todos ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── permissions.py ├── serializers.py ├── tests.py ├── urls.py └── views.py └── users ├── __init__.py ├── admin.py ├── apps.py ├── migrations └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | db.sqlite3 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | #virtualENV 65 | my_project 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Fatih Erikli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DRF-TDD-Example 2 | 3 | An example Django REST framework project for test driven development. 4 | 5 | ### Test Case Scenarios 6 | * Test to verify registration with invalid password. 7 | * Test to verify registration with already exists username. 8 | * Test to verify registration with valid datas. 9 | * Tested API authentication endpoint validations. 10 | * Tested authenticated user authorization. 11 | * Create a todo with API. 12 | * Update a todo with API. 13 | * Update a todo with API. 14 | * Delete a todo with API. 15 | * Get todo list for a user. 16 | 17 | ### API Endpoints 18 | 19 | #### Users 20 | 21 | * **/api/users/** (User registration endpoint) 22 | * **/api/users/login/** (User login endpoint) 23 | * **/api/users/logout/** (User logout endpoint) 24 | 25 | 26 | #### Todos 27 | 28 | * **/api/todos/** (Todo create and list endpoint) 29 | * **/api/todos/{todo-id}/** (Todo retrieve, update and destroy endpoint) 30 | 31 | ### Install 32 | 33 | pip install -r requirements.txt 34 | 35 | ### Usage 36 | 37 | python manage.py test 38 | 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==4.5.1 2 | Django==2.2.10 3 | django-nose==1.4.6 4 | djangorestframework==3.10.2 5 | nose==1.3.7 6 | -------------------------------------------------------------------------------- /todoapp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "todoapp.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /todoapp/todoapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erdem/DRF-TDD-example/50d014512045f59b99fcb6b1a93506724d771c60/todoapp/todoapp/__init__.py -------------------------------------------------------------------------------- /todoapp/todoapp/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for todoapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'k8b^y952f(10ox6993@h0q3-+&d_r#-g!qu+1zpggva_kux)2x' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'rest_framework', 42 | 'rest_framework.authtoken', 43 | 44 | 'users', 45 | 'todos', 46 | 47 | 'django_nose' 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | 'django.middleware.security.SecurityMiddleware', 52 | 'django.contrib.sessions.middleware.SessionMiddleware', 53 | 'django.middleware.common.CommonMiddleware', 54 | 'django.middleware.csrf.CsrfViewMiddleware', 55 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 56 | 'django.contrib.messages.middleware.MessageMiddleware', 57 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 58 | ] 59 | 60 | ROOT_URLCONF = 'todoapp.urls' 61 | 62 | TEMPLATES = [ 63 | { 64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 65 | 'DIRS': [], 66 | 'APP_DIRS': True, 67 | 'OPTIONS': { 68 | 'context_processors': [ 69 | 'django.template.context_processors.debug', 70 | 'django.template.context_processors.request', 71 | 'django.contrib.auth.context_processors.auth', 72 | 'django.contrib.messages.context_processors.messages', 73 | ], 74 | }, 75 | }, 76 | ] 77 | 78 | WSGI_APPLICATION = 'todoapp.wsgi.application' 79 | 80 | 81 | # Database 82 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 83 | 84 | DATABASES = { 85 | 'default': { 86 | 'ENGINE': 'django.db.backends.sqlite3', 87 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 88 | } 89 | } 90 | 91 | REST_FRAMEWORK = { 92 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 93 | 'rest_framework.authentication.TokenAuthentication', 94 | ), 95 | 'DEFAULT_PERMISSION_CLASSES': ( 96 | 'rest_framework.permissions.IsAuthenticated', 97 | ), 98 | 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 99 | } 100 | 101 | 102 | # Password validation 103 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 104 | 105 | AUTH_PASSWORD_VALIDATORS = [ 106 | { 107 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 108 | }, 109 | { 110 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 111 | }, 112 | { 113 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 114 | }, 115 | { 116 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 117 | }, 118 | ] 119 | 120 | 121 | # Internationalization 122 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 123 | 124 | LANGUAGE_CODE = 'en-us' 125 | 126 | TIME_ZONE = 'UTC' 127 | 128 | USE_I18N = True 129 | 130 | USE_L10N = True 131 | 132 | USE_TZ = True 133 | 134 | 135 | # Static files (CSS, JavaScript, Images) 136 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 137 | 138 | STATIC_URL = '/static/' 139 | 140 | # Use nose to run all tests 141 | TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' 142 | 143 | # Tell nose to measure coverage on the 'foo' and 'bar' apps 144 | NOSE_ARGS = [ 145 | '--with-coverage', 146 | '--cover-package=users,todos', 147 | ] 148 | -------------------------------------------------------------------------------- /todoapp/todoapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from django.contrib import admin 3 | 4 | 5 | api_urls = [ 6 | path('todos/', include('todos.urls')), 7 | path('', include('users.urls')), 8 | ] 9 | 10 | urlpatterns = [ 11 | path('admin/', admin.site.urls), 12 | path('api/', include(api_urls)), 13 | ] 14 | -------------------------------------------------------------------------------- /todoapp/todoapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for todoapp project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "todoapp.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /todoapp/todos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erdem/DRF-TDD-example/50d014512045f59b99fcb6b1a93506724d771c60/todoapp/todos/__init__.py -------------------------------------------------------------------------------- /todoapp/todos/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from todos.models import Todo 3 | 4 | 5 | class TodoAdmin(admin.ModelAdmin): 6 | list_display = ("user", "name", "done", "date_created") 7 | list_filter = ("done", "date_created") 8 | 9 | 10 | admin.site.register(Todo, TodoAdmin) 11 | 12 | -------------------------------------------------------------------------------- /todoapp/todos/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class TodosConfig(AppConfig): 7 | name = 'todos' 8 | verbose_name = 'TODOs sample application' 9 | -------------------------------------------------------------------------------- /todoapp/todos/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2016-03-15 21:16 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Todo', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('name', models.CharField(max_length=255, verbose_name='Name')), 24 | ('done', models.BooleanField(default=False, verbose_name='Done')), 25 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date Created')), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | options={ 29 | 'verbose_name': 'Todo', 30 | 'verbose_name_plural': 'Todos', 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /todoapp/todos/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erdem/DRF-TDD-example/50d014512045f59b99fcb6b1a93506724d771c60/todoapp/todos/migrations/__init__.py -------------------------------------------------------------------------------- /todoapp/todos/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.conf import settings 3 | 4 | from django.db import models 5 | from django.utils.encoding import smart_text as smart_unicode 6 | from django.utils.translation import ugettext_lazy as _ 7 | 8 | 9 | class Todo(models.Model): 10 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 11 | name = models.CharField(_("Name"), max_length=255) 12 | done = models.BooleanField(_("Done"), default=False) 13 | date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) 14 | 15 | class Meta: 16 | verbose_name = _("Todo") 17 | verbose_name_plural = _("Todos") 18 | 19 | def __unicode__(self): 20 | return smart_unicode(self.name) 21 | -------------------------------------------------------------------------------- /todoapp/todos/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | 3 | 4 | class UserIsOwnerTodo(BasePermission): 5 | 6 | def has_object_permission(self, request, view, todo): 7 | return request.user.id == todo.user.id 8 | -------------------------------------------------------------------------------- /todoapp/todos/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import serializers 3 | from todos.models import Todo 4 | 5 | 6 | class TodoUserSerializer(serializers.ModelSerializer): 7 | 8 | class Meta: 9 | model = User 10 | fields = ("id", "username", "email", "date_joined") 11 | 12 | 13 | class TodoSerializer(serializers.ModelSerializer): 14 | user = TodoUserSerializer(read_only=True) 15 | 16 | class Meta: 17 | model = Todo 18 | fields = ("user", "name", "done", "date_created") 19 | -------------------------------------------------------------------------------- /todoapp/todos/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.contrib.auth.models import User 3 | from django.urls import reverse 4 | 5 | from rest_framework.authtoken.models import Token 6 | from rest_framework.test import APITestCase 7 | from todos.models import Todo 8 | from todos.serializers import TodoSerializer 9 | 10 | 11 | class TodoListCreateAPIViewTestCase(APITestCase): 12 | url = reverse("todos:list") 13 | 14 | def setUp(self): 15 | self.username = "john" 16 | self.email = "john@snow.com" 17 | self.password = "you_know_nothing" 18 | self.user = User.objects.create_user(self.username, self.email, self.password) 19 | self.token = Token.objects.create(user=self.user) 20 | self.api_authentication() 21 | 22 | def api_authentication(self): 23 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 24 | 25 | def test_create_todo(self): 26 | response = self.client.post(self.url, {"name": "Clean the room!"}) 27 | self.assertEqual(201, response.status_code) 28 | 29 | def test_user_todos(self): 30 | """ 31 | Test to verify user todos list 32 | """ 33 | Todo.objects.create(user=self.user, name="Clean the car!") 34 | response = self.client.get(self.url) 35 | self.assertTrue(len(json.loads(response.content)) == Todo.objects.count()) 36 | 37 | 38 | class TodoDetailAPIViewTestCase(APITestCase): 39 | 40 | def setUp(self): 41 | self.username = "john" 42 | self.email = "john@snow.com" 43 | self.password = "you_know_nothing" 44 | self.user = User.objects.create_user(self.username, self.email, self.password) 45 | self.todo = Todo.objects.create(user=self.user, name="Call Mom!") 46 | self.url = reverse("todos:detail", kwargs={"pk": self.todo.pk}) 47 | self.token = Token.objects.create(user=self.user) 48 | self.api_authentication() 49 | 50 | def api_authentication(self): 51 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 52 | 53 | def test_todo_object_bundle(self): 54 | """ 55 | Test to verify todo object bundle 56 | """ 57 | response = self.client.get(self.url) 58 | self.assertEqual(200, response.status_code) 59 | 60 | todo_serializer_data = TodoSerializer(instance=self.todo).data 61 | response_data = json.loads(response.content) 62 | self.assertEqual(todo_serializer_data, response_data) 63 | 64 | def test_todo_object_update_authorization(self): 65 | """ 66 | Test to verify that put call with different user token 67 | """ 68 | new_user = User.objects.create_user("newuser", "new@user.com", "newpass") 69 | new_token = Token.objects.create(user=new_user) 70 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + new_token.key) 71 | 72 | # HTTP PUT 73 | response = self.client.put(self.url, {"name", "Hacked by new user"}) 74 | self.assertEqual(403, response.status_code) 75 | 76 | # HTTP PATCH 77 | response = self.client.patch(self.url, {"name", "Hacked by new user"}) 78 | self.assertEqual(403, response.status_code) 79 | 80 | def test_todo_object_update(self): 81 | response = self.client.put(self.url, {"name": "Call Dad!"}) 82 | response_data = json.loads(response.content) 83 | todo = Todo.objects.get(id=self.todo.id) 84 | self.assertEqual(response_data.get("name"), todo.name) 85 | 86 | def test_todo_object_partial_update(self): 87 | response = self.client.patch(self.url, {"done": True}) 88 | response_data = json.loads(response.content) 89 | todo = Todo.objects.get(id=self.todo.id) 90 | self.assertEqual(response_data.get("done"), todo.done) 91 | 92 | def test_todo_object_delete_authorization(self): 93 | """ 94 | Test to verify that put call with different user token 95 | """ 96 | new_user = User.objects.create_user("newuser", "new@user.com", "newpass") 97 | new_token = Token.objects.create(user=new_user) 98 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + new_token.key) 99 | response = self.client.delete(self.url) 100 | self.assertEqual(403, response.status_code) 101 | 102 | def test_todo_object_delete(self): 103 | response = self.client.delete(self.url) 104 | self.assertEqual(204, response.status_code) 105 | -------------------------------------------------------------------------------- /todoapp/todos/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from todos.views import TodoListCreateAPIView, TodoDetailAPIView 3 | 4 | app_name = 'todos' 5 | 6 | urlpatterns = [ 7 | path('', TodoListCreateAPIView.as_view(), name="list"), 8 | path('/', TodoDetailAPIView.as_view(), name="detail"), 9 | ] 10 | -------------------------------------------------------------------------------- /todoapp/todos/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView 2 | from rest_framework.permissions import IsAuthenticated 3 | 4 | from todos.models import Todo 5 | from todos.permissions import UserIsOwnerTodo 6 | from todos.serializers import TodoSerializer 7 | 8 | 9 | class TodoListCreateAPIView(ListCreateAPIView): 10 | serializer_class = TodoSerializer 11 | 12 | def get_queryset(self): 13 | return Todo.objects.filter(user=self.request.user) 14 | 15 | def perform_create(self, serializer): 16 | serializer.save(user=self.request.user) 17 | 18 | 19 | class TodoDetailAPIView(RetrieveUpdateDestroyAPIView): 20 | serializer_class = TodoSerializer 21 | queryset = Todo.objects.all() 22 | permission_classes = (IsAuthenticated, UserIsOwnerTodo) 23 | 24 | 25 | -------------------------------------------------------------------------------- /todoapp/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erdem/DRF-TDD-example/50d014512045f59b99fcb6b1a93506724d771c60/todoapp/users/__init__.py -------------------------------------------------------------------------------- /todoapp/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /todoapp/users/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class UsersConfig(AppConfig): 7 | name = 'users' 8 | verbose_name = 'Basic user system for the TODOs application' 9 | -------------------------------------------------------------------------------- /todoapp/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erdem/DRF-TDD-example/50d014512045f59b99fcb6b1a93506724d771c60/todoapp/users/migrations/__init__.py -------------------------------------------------------------------------------- /todoapp/users/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | 5 | # Create your models here. 6 | -------------------------------------------------------------------------------- /todoapp/users/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate 2 | from django.contrib.auth.models import User 3 | from django.contrib.auth.hashers import make_password 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | from rest_framework import serializers 7 | from rest_framework.authtoken.models import Token 8 | 9 | 10 | class UserRegistrationSerializer(serializers.ModelSerializer): 11 | password = serializers.CharField(write_only=True) 12 | confirm_password = serializers.CharField(write_only=True) 13 | 14 | class Meta: 15 | model = User 16 | fields = ("id", "username", "email", "password", "confirm_password", "date_joined") 17 | 18 | def validate(self, attrs): 19 | if attrs.get('password') != attrs.get('confirm_password'): 20 | raise serializers.ValidationError("Those passwords don't match.") 21 | del attrs['confirm_password'] 22 | attrs['password'] = make_password(attrs['password']) 23 | return attrs 24 | 25 | 26 | class UserLoginSerializer(serializers.Serializer): 27 | username = serializers.CharField(required=True) 28 | password = serializers.CharField(required=True) 29 | 30 | default_error_messages = { 31 | 'inactive_account': _('User account is disabled.'), 32 | 'invalid_credentials': _('Unable to login with provided credentials.') 33 | } 34 | 35 | def __init__(self, *args, **kwargs): 36 | super(UserLoginSerializer, self).__init__(*args, **kwargs) 37 | self.user = None 38 | 39 | def validate(self, attrs): 40 | self.user = authenticate(username=attrs.get("username"), password=attrs.get('password')) 41 | if self.user: 42 | if not self.user.is_active: 43 | raise serializers.ValidationError(self.error_messages['inactive_account']) 44 | return attrs 45 | else: 46 | raise serializers.ValidationError(self.error_messages['invalid_credentials']) 47 | 48 | 49 | class TokenSerializer(serializers.ModelSerializer): 50 | auth_token = serializers.CharField(source='key') 51 | 52 | class Meta: 53 | model = Token 54 | fields = ("auth_token", "created") 55 | -------------------------------------------------------------------------------- /todoapp/users/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.contrib.auth.models import User 4 | from django.urls import reverse 5 | 6 | from rest_framework.authtoken.models import Token 7 | from rest_framework.test import APITestCase 8 | 9 | 10 | class UserRegistrationAPIViewTestCase(APITestCase): 11 | url = reverse("users:list") 12 | 13 | def test_invalid_password(self): 14 | """ 15 | Test to verify that a post call with invalid passwords 16 | """ 17 | user_data = { 18 | "username": "testuser", 19 | "email": "test@testuser.com", 20 | "password": "password", 21 | "confirm_password": "INVALID_PASSWORD" 22 | } 23 | response = self.client.post(self.url, user_data) 24 | self.assertEqual(400, response.status_code) 25 | 26 | def test_user_registration(self): 27 | """ 28 | Test to verify that a post call with user valid data 29 | """ 30 | user_data = { 31 | "username": "testuser", 32 | "email": "test@testuser.com", 33 | "password": "123123", 34 | "confirm_password": "123123" 35 | } 36 | response = self.client.post(self.url, user_data) 37 | self.assertEqual(201, response.status_code) 38 | self.assertTrue("token" in json.loads(response.content)) 39 | 40 | def test_unique_username_validation(self): 41 | """ 42 | Test to verify that a post call with already exists username 43 | """ 44 | user_data_1 = { 45 | "username": "testuser", 46 | "email": "test@testuser.com", 47 | "password": "123123", 48 | "confirm_password": "123123" 49 | } 50 | response = self.client.post(self.url, user_data_1) 51 | self.assertEqual(201, response.status_code) 52 | 53 | user_data_2 = { 54 | "username": "testuser", 55 | "email": "test2@testuser.com", 56 | "password": "123123", 57 | "confirm_password": "123123" 58 | } 59 | response = self.client.post(self.url, user_data_2) 60 | self.assertEqual(400, response.status_code) 61 | 62 | 63 | class UserLoginAPIViewTestCase(APITestCase): 64 | url = reverse("users:login") 65 | 66 | def setUp(self): 67 | self.username = "john" 68 | self.email = "john@snow.com" 69 | self.password = "you_know_nothing" 70 | self.user = User.objects.create_user(self.username, self.email, self.password) 71 | 72 | def test_authentication_without_password(self): 73 | response = self.client.post(self.url, {"username": "snowman"}) 74 | self.assertEqual(400, response.status_code) 75 | 76 | def test_authentication_with_wrong_password(self): 77 | response = self.client.post(self.url, {"username": self.username, "password": "I_know"}) 78 | self.assertEqual(400, response.status_code) 79 | 80 | def test_authentication_with_valid_data(self): 81 | response = self.client.post(self.url, {"username": self.username, "password": self.password}) 82 | self.assertEqual(200, response.status_code) 83 | self.assertTrue("auth_token" in json.loads(response.content)) 84 | 85 | 86 | class UserTokenAPIViewTestCase(APITestCase): 87 | def url(self, key): 88 | return reverse("users:token", kwargs={"key": key}) 89 | 90 | def setUp(self): 91 | self.username = "john" 92 | self.email = "john@snow.com" 93 | self.password = "you_know_nothing" 94 | self.user = User.objects.create_user(self.username, self.email, self.password) 95 | self.token = Token.objects.create(user=self.user) 96 | self.api_authentication() 97 | 98 | self.user_2 = User.objects.create_user("mary", "mary@earth.com", "super_secret") 99 | self.token_2 = Token.objects.create(user=self.user_2) 100 | 101 | def tearDown(self): 102 | self.user.delete() 103 | self.token.delete() 104 | self.user_2.delete() 105 | self.token_2.delete() 106 | 107 | def api_authentication(self): 108 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 109 | 110 | def test_delete_by_key(self): 111 | response = self.client.delete(self.url(self.token.key)) 112 | self.assertEqual(204, response.status_code) 113 | self.assertFalse(Token.objects.filter(key=self.token.key).exists()) 114 | 115 | def test_delete_current(self): 116 | response = self.client.delete(self.url('current')) 117 | self.assertEqual(204, response.status_code) 118 | self.assertFalse(Token.objects.filter(key=self.token.key).exists()) 119 | 120 | def test_delete_unauthorized(self): 121 | response = self.client.delete(self.url(self.token_2.key)) 122 | self.assertEqual(404, response.status_code) 123 | self.assertTrue(Token.objects.filter(key=self.token_2.key).exists()) 124 | 125 | def test_get(self): 126 | # Test that unauthorized access returns 404 127 | response = self.client.get(self.url(self.token_2.key)) 128 | self.assertEqual(404, response.status_code) 129 | 130 | for key in [self.token.key, 'current']: 131 | response = self.client.get(self.url(key)) 132 | self.assertEqual(200, response.status_code) 133 | self.assertEqual(self.token.key, response.data['auth_token']) 134 | self.assertIn('created', response.data) 135 | -------------------------------------------------------------------------------- /todoapp/users/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from users.views import UserRegistrationAPIView, UserLoginAPIView, UserTokenAPIView 3 | 4 | app_name = 'users' 5 | 6 | urlpatterns = [ 7 | path('users/', UserRegistrationAPIView.as_view(), name="list"), 8 | path('users/login/', UserLoginAPIView.as_view(), name="login"), 9 | path('tokens//', UserTokenAPIView.as_view(), name="token"), 10 | ] 11 | -------------------------------------------------------------------------------- /todoapp/users/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.authtoken.models import Token 3 | from rest_framework.generics import CreateAPIView, GenericAPIView 4 | from rest_framework.response import Response 5 | from rest_framework.generics import RetrieveDestroyAPIView 6 | from users.serializers import UserRegistrationSerializer, UserLoginSerializer, TokenSerializer 7 | 8 | 9 | class UserRegistrationAPIView(CreateAPIView): 10 | authentication_classes = () 11 | permission_classes = () 12 | serializer_class = UserRegistrationSerializer 13 | 14 | def create(self, request, *args, **kwargs): 15 | serializer = self.get_serializer(data=request.data) 16 | serializer.is_valid(raise_exception=True) 17 | self.perform_create(serializer) 18 | 19 | user = serializer.instance 20 | token, created = Token.objects.get_or_create(user=user) 21 | data = serializer.data 22 | data["token"] = token.key 23 | 24 | headers = self.get_success_headers(serializer.data) 25 | return Response(data, status=status.HTTP_201_CREATED, headers=headers) 26 | 27 | 28 | class UserLoginAPIView(GenericAPIView): 29 | authentication_classes = () 30 | permission_classes = () 31 | serializer_class = UserLoginSerializer 32 | 33 | def post(self, request, *args, **kwargs): 34 | serializer = self.get_serializer(data=request.data) 35 | if serializer.is_valid(): 36 | user = serializer.user 37 | token, _ = Token.objects.get_or_create(user=user) 38 | return Response( 39 | data=TokenSerializer(token).data, 40 | status=status.HTTP_200_OK, 41 | ) 42 | else: 43 | return Response( 44 | data=serializer.errors, 45 | status=status.HTTP_400_BAD_REQUEST, 46 | ) 47 | 48 | 49 | class UserTokenAPIView(RetrieveDestroyAPIView): 50 | lookup_field = "key" 51 | serializer_class = TokenSerializer 52 | queryset = Token.objects.all() 53 | 54 | def filter_queryset(self, queryset): 55 | return queryset.filter(user=self.request.user) 56 | 57 | def retrieve(self, request, key, *args, **kwargs): 58 | if key == "current": 59 | instance = Token.objects.get(key=request.auth.key) 60 | serializer = self.get_serializer(instance) 61 | return Response(serializer.data) 62 | return super(UserTokenAPIView, self).retrieve(request, key, *args, **kwargs) 63 | 64 | def destroy(self, request, key, *args, **kwargs): 65 | if key == "current": 66 | Token.objects.get(key=request.auth.key).delete() 67 | return Response(status=status.HTTP_204_NO_CONTENT) 68 | return super(UserTokenAPIView, self).destroy(request, key, *args, **kwargs) 69 | --------------------------------------------------------------------------------