diseño, creación e implementación de user API
serializadores, administración, autenticación y tests
This commit is contained in:
parent
d035ec4cca
commit
f8ac495c2d
545
README.md
545
README.md
@ -1309,3 +1309,548 @@ class UserAdmin(BaseUserAdmin):
|
|||||||
|
|
||||||
- Página para crear usuarios ![img](./imgs_readme/django_admin_12.png)
|
- Página para crear usuarios ![img](./imgs_readme/django_admin_12.png)
|
||||||
- Panel de usuarios del administrador ![img](./imgs_readme/django_admin_13.png)
|
- Panel de usuarios del administrador ![img](./imgs_readme/django_admin_13.png)
|
||||||
|
|
||||||
|
## Documentación de la API
|
||||||
|
|
||||||
|
Es necesario tener acceso a una buena documentación para que los desarrolladores
|
||||||
|
puedan saber como usarla. Se documenta todo lo que sea necesario para usar la API
|
||||||
|
|
||||||
|
- Endopoints disponibles (paths)
|
||||||
|
- Métodos soportados `GET`, `POST`, `PUT`, `PATCH`, `DELETE`...
|
||||||
|
- Formateo de payloads (inputs). Parametros, Post en formato **JSON**
|
||||||
|
- Formateo de respuestas (outputs). Respuesta en formato **JSON**
|
||||||
|
- Proceso Autenticación
|
||||||
|
|
||||||
|
### Opiones de documentación
|
||||||
|
|
||||||
|
- Manual
|
||||||
|
- Documento de texto
|
||||||
|
- Markdown
|
||||||
|
- Automatizada
|
||||||
|
- Usa la metadata del código (comments)
|
||||||
|
- Genera páginas de documentación
|
||||||
|
|
||||||
|
## Autodocs de DRF
|
||||||
|
|
||||||
|
- Documentación Autogenerada (3rd party library)
|
||||||
|
- `drf-spectacular`
|
||||||
|
- Genera el "schema"
|
||||||
|
- Interfaz web navegable
|
||||||
|
- Test requests
|
||||||
|
- Maneja la autenticación
|
||||||
|
|
||||||
|
### Como funciona
|
||||||
|
|
||||||
|
1. Creación del archivo `schema`
|
||||||
|
2. Pasa el schema al GUI
|
||||||
|
|
||||||
|
### Open API Schema
|
||||||
|
|
||||||
|
- Estandar para describir APIs
|
||||||
|
- Popular en la industria
|
||||||
|
- Soportada por la mayoría de herramientas de documentación de API
|
||||||
|
|
||||||
|
### Ejemplo Schema
|
||||||
|
|
||||||
|
fragmento
|
||||||
|
|
||||||
|
```yml
|
||||||
|
/api/recipe/ingredients/:
|
||||||
|
get:
|
||||||
|
oprationId: recipe_ingredients_list
|
||||||
|
description: Manage ingredients in the database.
|
||||||
|
parameteres:
|
||||||
|
- in: query
|
||||||
|
name: assigned_only
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
enum:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
|
description: Filter by item assigned to recipies
|
||||||
|
tags:
|
||||||
|
- recipe
|
||||||
|
security:
|
||||||
|
- tokenAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
content:
|
||||||
|
application/json
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Ingredient'
|
||||||
|
description: ''
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementación DRF
|
||||||
|
|
||||||
|
Se agrega dependencia en `drf-spectacular>=0.16` en `requirements.txt`
|
||||||
|
|
||||||
|
Instalar app, en `settings.py`
|
||||||
|
|
||||||
|
```py
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
...
|
||||||
|
'core',
|
||||||
|
'rest_framework',
|
||||||
|
'drf_spectacular',
|
||||||
|
]
|
||||||
|
|
||||||
|
...
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activar las URLS
|
||||||
|
|
||||||
|
```py
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'),
|
||||||
|
path(
|
||||||
|
'api/docs',
|
||||||
|
SpectacularSwaggerView.as_view(url_name='api-schema'),
|
||||||
|
name='api-docs'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `docker compose run`
|
||||||
|
- `127.0.0.1:8000/api/docs`
|
||||||
|
|
||||||
|
## User API
|
||||||
|
|
||||||
|
### Diseño
|
||||||
|
|
||||||
|
- Registro de usario
|
||||||
|
- Creación de token de autenticación
|
||||||
|
- Consultar y actualizar perfil
|
||||||
|
|
||||||
|
### Endpoins
|
||||||
|
|
||||||
|
| Endpoint | Method | Descripción |
|
||||||
|
| - | - | - |
|
||||||
|
| `user/create` | `POST` | Registrar nuevo usuario |
|
||||||
|
| `user/token` | `POST` | Crea un nuevo token |
|
||||||
|
| `user/me/` | `PUT/PATCH` | Actualizar perfíl |
|
||||||
|
|
||||||
|
### Creación user app
|
||||||
|
|
||||||
|
`docker compose run --rm app sh -c "python manage.py startapp user"`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[+] Creating 1/0
|
||||||
|
✔ Container recipes_api_django-db-1 Created 0.0s
|
||||||
|
[+] Running 1/1
|
||||||
|
✔ Container recipes_api_django-db-1 Started 0.2s
|
||||||
|
```
|
||||||
|
|
||||||
|
Activar `user` app en `settings.py`
|
||||||
|
|
||||||
|
### Test User API
|
||||||
|
|
||||||
|
[`app/user/tests/test_user_api.py`](./app/user/tests/test_user_api.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
CREATE_USER_URL = reverse('user:create')
|
||||||
|
|
||||||
|
def create_user(**params):
|
||||||
|
"""Create and return a new user."""
|
||||||
|
return get_user_model().objects.crate_user(**params)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserApiTest(TestCase):
|
||||||
|
"""Test the public features of the user API."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_create_user_success(self):
|
||||||
|
"""Tests creating a user is successful."""
|
||||||
|
payload = {
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'testpass123',
|
||||||
|
'name': 'TestName',
|
||||||
|
}
|
||||||
|
res = self.client.post(CREATE_USER_URL, payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.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_email_exists_error(self):
|
||||||
|
"""Test error returned if user with email exists."""
|
||||||
|
payload = {
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'testpass123',
|
||||||
|
'name': 'Test Name',
|
||||||
|
}
|
||||||
|
create_user(**payload)
|
||||||
|
res = self.client.post(CREATE_USER_URL, payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_password_too_short_error(self):
|
||||||
|
"""Test an error is returned if password less than 5 chars."""
|
||||||
|
payload = {
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'pw',
|
||||||
|
'name': 'Test Name',
|
||||||
|
}
|
||||||
|
res = self.client.post(CREATE_USER_URL, payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
user_exists = get_user_model().objects.filter(
|
||||||
|
email=payload['email']
|
||||||
|
).exists()
|
||||||
|
self.assertFalse(user_exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creando funcionalidad de User API
|
||||||
|
|
||||||
|
- [serializers.py](./app/user/serializers.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
"""Seralizer for the model object."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_user_model()
|
||||||
|
fields = ['email', 'password', 'name']
|
||||||
|
extra_kwargs = {'password': {'write_only': True, 'min_length': 5}}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Create and return a user with encrypted password"""
|
||||||
|
return get_user_model().objects.create_user(**validated_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [views.py](./app/user/views.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
from rest_framework import generics
|
||||||
|
from user.serializers import UserSerializer
|
||||||
|
|
||||||
|
class CreateUserView(generics.CreateAPIView):
|
||||||
|
"""Create a new user in the system."""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
```
|
||||||
|
|
||||||
|
- [urls.py](./app/user/urls.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django.urls import path
|
||||||
|
from user import views
|
||||||
|
|
||||||
|
app_name='user'
|
||||||
|
urlpatterns = [
|
||||||
|
path('create/', views.CreateUserView.as_view(), name='create')
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [app/urls.py](./app/app/urls.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
...
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
...
|
||||||
|
path('api/user/', include('user.urls')),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
|
||||||
|
| Tipo de autenticación | Descripción |
|
||||||
|
| - | - |
|
||||||
|
| **Básica** | Envía usuario y password en cada request |
|
||||||
|
| **Token** | Usa un token en el encabezado HTTP |
|
||||||
|
| **JSON Web Token (JWT)** | Usa un token de acceso |
|
||||||
|
| **Sesión** | Usa cookies |
|
||||||
|
|
||||||
|
En esta app se utilza **Token** por:
|
||||||
|
|
||||||
|
- Balance entre simplicidad y seguridad
|
||||||
|
- Soporte por defecto por **DRF**
|
||||||
|
- Bién soportada por la mayoria de clientes
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
|
||||||
|
flowchart
|
||||||
|
subgraph " "
|
||||||
|
|
||||||
|
CT["<b>Create token</b>
|
||||||
|
(Post username/password)"]
|
||||||
|
STOC["<b>Store token on client</b>"]
|
||||||
|
ITIH["<b>Include token in HTTP headers</b>"]
|
||||||
|
CT .-> STOC .-> ITIH
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pros del uso de Token
|
||||||
|
|
||||||
|
- Soporte por defecto
|
||||||
|
- Simple de usar
|
||||||
|
- Soportada por todos los clientes
|
||||||
|
- Evita enviar datos de usuario/password en cada request
|
||||||
|
|
||||||
|
#### Cons del uso de Token
|
||||||
|
|
||||||
|
- El token debe ser seguro
|
||||||
|
- Requiere hacer peticiones a la base de datos
|
||||||
|
|
||||||
|
### Login out
|
||||||
|
|
||||||
|
- Sucede en el lado del cliente
|
||||||
|
- Borra el token
|
||||||
|
|
||||||
|
### Test token API
|
||||||
|
|
||||||
|
Agregar tests en
|
||||||
|
[`app/user/tests/test_user_api.py`](./app/user/tests/test_user_api.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
...
|
||||||
|
TOKEN_URL = reverse('user:token')
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
class PublicUserApiTest(TestCase):
|
||||||
|
"""Test the public features of the user API."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def test_create_token_for_user(self):
|
||||||
|
"""Test generate token for valid credentials."""
|
||||||
|
user_details = {
|
||||||
|
'name': 'Test Name',
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'test-user-password123',
|
||||||
|
}
|
||||||
|
create_user(**user_details)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'email': user_details['email'],
|
||||||
|
'password': user_details['password'],
|
||||||
|
}
|
||||||
|
res = self.client.post(TOKEN_URL, payload)
|
||||||
|
|
||||||
|
self.assertIn('token', res.data)
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_create_token_bad_credentials(self):
|
||||||
|
"""Test returns error if credentials invalid."""
|
||||||
|
create_user(email='test@example.com', password='goodpass')
|
||||||
|
|
||||||
|
payload = {'email': 'test@example.com' ,'password': 'badpass'}
|
||||||
|
res = self.client.post(TOKEN_URL, payload)
|
||||||
|
|
||||||
|
self.assertNotIn('token', res.data)
|
||||||
|
self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_token_blank_password(self):
|
||||||
|
"""Test posting a blank password returns an error."""
|
||||||
|
payload = {'email': 'test@example.com' , 'password': ''}
|
||||||
|
res = self.client.post(TOKEN_URL, payload)
|
||||||
|
|
||||||
|
self.assertNotIn('token', res.data)
|
||||||
|
self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementar Token API
|
||||||
|
|
||||||
|
- Añadir app `rest_framework.authtoken` en [settings.py](./app/app/settings.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
...
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework.authtoken', # <---
|
||||||
|
'drf_spectacular',
|
||||||
|
'user',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creación del serlizador para token api
|
||||||
|
|
||||||
|
- [user/serializer.py](./app/user/serializers.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
...
|
||||||
|
|
||||||
|
class AuthTokenSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for the user auth token."""
|
||||||
|
email = serializer.EmailField()
|
||||||
|
password = serializer.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 serializers.ValidationError(msg, code='authorization')
|
||||||
|
|
||||||
|
attrs['user'] = user
|
||||||
|
return attrs
|
||||||
|
```
|
||||||
|
|
||||||
|
- vista [user/views.py](./app/user/views.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
...
|
||||||
|
class CreateTokenView(ObtainAuthToken):
|
||||||
|
"""Create a new auth token for user."""
|
||||||
|
serializer_class = AuthTokenSerializer
|
||||||
|
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
|
||||||
|
```
|
||||||
|
|
||||||
|
- urls [user/urls.py](./app/user/urls.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
urlpatterns = [
|
||||||
|
...
|
||||||
|
path('token/', views.CreateTokenView.as_view(), name='token'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test administrar usuario
|
||||||
|
|
||||||
|
- [test_user_api.py](./app/user/tests/test_user_api.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
...
|
||||||
|
ME_URL = reverse('user:me')
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
class PrivateUserApiTests(TestCase):
|
||||||
|
"""Test API requests that require authentication."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = create_user(
|
||||||
|
email='test@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
name='Test Name',
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_retrive_profile_success(self):
|
||||||
|
"""Test retrieving profile for logged in user."""
|
||||||
|
res = self.client.get(ME_URL)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
res.data, {
|
||||||
|
'name': self.user.name,
|
||||||
|
'email': self.user.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_post_me_not_allowed(self):
|
||||||
|
"""Test POST is not allowed for the 'me' endpoint."""
|
||||||
|
res = self.client.post(ME_URL, {})
|
||||||
|
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
res.status_code,
|
||||||
|
status.HTTP_405_METHOD_NOT_ALLOWED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_user_profile(self):
|
||||||
|
"""Test updating the user profile for the autenticated user."""
|
||||||
|
payload = { 'name': 'Updated Name', 'password': 'newpassword123' }
|
||||||
|
|
||||||
|
res = self.client.patch(ME_URL, payload)
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.name, payload['name'])
|
||||||
|
self.assertTrue(self.user.check_password(payload['password']))
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementación API actualizar usuario
|
||||||
|
|
||||||
|
`me` endpoint
|
||||||
|
|
||||||
|
- creación (sobrescritura) del método update
|
||||||
|
[serializer.py](./app/user/serializers.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
...
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Create and return a user with encrypted password"""
|
||||||
|
return get_user_model().objects.create_user(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Update and return user."""
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
user = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- vistas [views.py](./app/user/views.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
from rest_framework import generics, authentication, permissions
|
||||||
|
...
|
||||||
|
|
||||||
|
class ManageUserView(generics.RetrieveUpdateAPIView):
|
||||||
|
"""Manage the autenticated user."""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
authentication_classes = [authentication.TokenAuthentication]
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Retrieve and return the authenticated user."""
|
||||||
|
return self.request.user
|
||||||
|
```
|
||||||
|
|
||||||
|
- urls [urls.py](./app/user/urls.py)
|
||||||
|
|
||||||
|
```py
|
||||||
|
urlpatterns = [
|
||||||
|
...
|
||||||
|
path('me/', views.ManageUserView.as_view(), name='me'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pruebas en navegador
|
||||||
|
|
||||||
|
Ruta `localhost:8000/api/docs`
|
||||||
|
|
||||||
|
![img](./imgs_readme/api_swagger_00.png)
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Segunda parte -> [Recetas](./README2.md)
|
||||||
|
@ -38,6 +38,10 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'core',
|
'core',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework.authtoken',
|
||||||
|
'drf_spectacular',
|
||||||
|
'user',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -127,3 +131,7 @@ STATIC_URL = 'static/'
|
|||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'core.User'
|
AUTH_USER_MODEL = 'core.User'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
}
|
||||||
|
@ -14,9 +14,16 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'),
|
||||||
|
path(
|
||||||
|
'api/docs',
|
||||||
|
SpectacularSwaggerView.as_view(url_name='api-schema'),
|
||||||
|
name='api-docs'),
|
||||||
|
path('api/user/', include('user.urls')),
|
||||||
]
|
]
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
Test for models.
|
Test for models.
|
||||||
"""
|
"""
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.contrib.auth import get_user, get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
class ModelTests(TestCase):
|
class ModelTests(TestCase):
|
||||||
|
0
app/user/__init__.py
Normal file
0
app/user/__init__.py
Normal file
6
app/user/apps.py
Normal file
6
app/user/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'user'
|
56
app/user/serializers.py
Normal file
56
app/user/serializers.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Seralizers for the user API View
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import get_user_model, authenticate
|
||||||
|
from django.utils.translation import gettext as _, trim_whitespace
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
"""Seralizer for the model object."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_user_model()
|
||||||
|
fields = ['email', 'password', 'name']
|
||||||
|
extra_kwargs = {'password': {'write_only': True, 'min_length': 5}}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Create and return a user with encrypted password"""
|
||||||
|
return get_user_model().objects.create_user(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Update and return user."""
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
user = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class AuthTokenSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for the user auth token."""
|
||||||
|
email = serializers.EmailField()
|
||||||
|
password = serializers.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 serializers.ValidationError(msg, code='authorization')
|
||||||
|
|
||||||
|
attrs['user'] = user
|
||||||
|
return attrs
|
0
app/user/tests/__init__.py
Normal file
0
app/user/tests/__init__.py
Normal file
151
app/user/tests/test_user_api.py
Normal file
151
app/user/tests/test_user_api.py
Normal file
@ -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 import status
|
||||||
|
|
||||||
|
CREATE_USER_URL = reverse('user:create')
|
||||||
|
TOKEN_URL = reverse('user:token')
|
||||||
|
ME_URL = reverse('user:me')
|
||||||
|
|
||||||
|
def create_user(**params):
|
||||||
|
"""Create and return a new user."""
|
||||||
|
return get_user_model().objects.create_user(**params)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserApiTest(TestCase):
|
||||||
|
"""Test the public features of the user API."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_create_user_success(self):
|
||||||
|
"""Tests creating a user is successful."""
|
||||||
|
payload = {
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'testpass123',
|
||||||
|
'name': 'TestName',
|
||||||
|
}
|
||||||
|
res = self.client.post(CREATE_USER_URL, payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.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_email_exists_error(self):
|
||||||
|
"""Test error returned if user with email exists."""
|
||||||
|
payload = {
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'testpass123',
|
||||||
|
'name': 'Test Name',
|
||||||
|
}
|
||||||
|
create_user(**payload)
|
||||||
|
res = self.client.post(CREATE_USER_URL, payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_password_too_short_error(self):
|
||||||
|
"""Test an error is returned if password less than 5 chars."""
|
||||||
|
payload = {
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'pw',
|
||||||
|
'name': 'Test Name',
|
||||||
|
}
|
||||||
|
res = self.client.post(CREATE_USER_URL, payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.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 generate token for valid credentials."""
|
||||||
|
user_details = {
|
||||||
|
'name': 'Test Name',
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'password':'test-user-password123',
|
||||||
|
}
|
||||||
|
create_user(**user_details)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'email': user_details['email'],
|
||||||
|
'password': user_details['password'],
|
||||||
|
}
|
||||||
|
res = self.client.post(TOKEN_URL, payload)
|
||||||
|
|
||||||
|
self.assertIn('token', res.data)
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_create_token_bad_credentials(self):
|
||||||
|
"""Test returns error if credentials invalid."""
|
||||||
|
create_user(email='test@example.com', password='goodpass')
|
||||||
|
|
||||||
|
payload = {'email': 'test@example.com' ,'password': 'badpass'}
|
||||||
|
res = self.client.post(TOKEN_URL, payload)
|
||||||
|
|
||||||
|
self.assertNotIn('token', res.data)
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_token_blank_password(self):
|
||||||
|
"""Test posting a blank password returns an error."""
|
||||||
|
payload = {'email': 'test@example.com' , 'password': ''}
|
||||||
|
res = self.client.post(TOKEN_URL, payload)
|
||||||
|
|
||||||
|
self.assertNotIn('token', res.data)
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_retrive_user_unauthorized(self):
|
||||||
|
"""Test authentication is required for users."""
|
||||||
|
res = self.client.get(ME_URL)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateUserApiTests(TestCase):
|
||||||
|
"""Test API requests that require authentication."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = create_user(
|
||||||
|
email='test@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
name='Test Name',
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_retrive_profile_success(self):
|
||||||
|
"""Test retrieving profile for logged in user."""
|
||||||
|
res = self.client.get(ME_URL)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
res.data, {
|
||||||
|
'name': self.user.name,
|
||||||
|
'email': self.user.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_post_me_not_allowed(self):
|
||||||
|
"""Test POST is not allowed for the 'me' endpoint."""
|
||||||
|
res = self.client.post(ME_URL, {})
|
||||||
|
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
res.status_code,
|
||||||
|
status.HTTP_405_METHOD_NOT_ALLOWED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_user_profile(self):
|
||||||
|
"""Test updating the user profile for the autenticated user."""
|
||||||
|
payload = { 'name': 'Updated Name', 'password': 'newpassword123' }
|
||||||
|
|
||||||
|
res = self.client.patch(ME_URL, payload)
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.name, payload['name'])
|
||||||
|
self.assertTrue(self.user.check_password(payload['password']))
|
||||||
|
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
15
app/user/urls.py
Normal file
15
app/user/urls.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
URL mappings for the user API
|
||||||
|
"""
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from user import views
|
||||||
|
|
||||||
|
|
||||||
|
app_name='user'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('create/', views.CreateUserView.as_view(), name='create'),
|
||||||
|
path('token/', views.CreateTokenView.as_view(), name='token'),
|
||||||
|
path('me/', views.ManageUserView.as_view(), name='me'),
|
||||||
|
]
|
29
app/user/views.py
Normal file
29
app/user/views.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Views for the user API.
|
||||||
|
"""
|
||||||
|
from rest_framework import generics, authentication, permissions
|
||||||
|
from rest_framework.authtoken.views import ObtainAuthToken
|
||||||
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
|
from user.serializers import UserSerializer, AuthTokenSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserView(generics.CreateAPIView):
|
||||||
|
"""Create a new user in the system."""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTokenView(ObtainAuthToken):
|
||||||
|
"""Create a new auth token for user."""
|
||||||
|
serializer_class = AuthTokenSerializer
|
||||||
|
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
|
||||||
|
|
||||||
|
class ManageUserView(generics.RetrieveUpdateAPIView):
|
||||||
|
"""Manage the autenticated user."""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
authentication_classes = [authentication.TokenAuthentication]
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Retrieve and return the authenticated user."""
|
||||||
|
return self.request.user
|
BIN
imgs_readme/api_swagger_00.png
Normal file
BIN
imgs_readme/api_swagger_00.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
@ -1,3 +1,4 @@
|
|||||||
Django==4.2.5
|
Django==4.2.5
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
psycopg2>=2.9.9
|
psycopg2>=2.9.9
|
||||||
|
drf-spectacular>=0.16
|
||||||
|
Loading…
Reference in New Issue
Block a user