Creación Image API

tests, CRUD, serializers, views, model & settings
This commit is contained in:
devfzn 2023-10-11 17:59:44 -03:00
parent 1b40f37bda
commit d20158552c
Signed by: devfzn
GPG Key ID: E070ECF4A754FDB1
19 changed files with 608 additions and 10 deletions

View File

@ -11,10 +11,10 @@ EXPOSE 8000
ARG DEV=false ARG DEV=false
# apt install -y python3-dev libpq-dev python3-psycopg2 && \
RUN python -m venv /py && \ RUN python -m venv /py && \
apt update && \ apt update && \
apt install -y postgresql-client python3-dev libpq-dev gcc python3-psycopg2 && \ apt install -y postgresql-client libjpeg-dev python3-dev libpq-dev \
gcc python3-psycopg2 zlib1g zlib1g-dev && \
apt clean && \ apt clean && \
/py/bin/pip install --upgrade pip && \ /py/bin/pip install --upgrade pip && \
/py/bin/pip install -r /tmp/requirements.txt && \ /py/bin/pip install -r /tmp/requirements.txt && \
@ -25,7 +25,11 @@ RUN python -m venv /py && \
adduser \ adduser \
--disabled-password \ --disabled-password \
--no-create-home \ --no-create-home \
django-user django-user && \
mkdir -p /vol/web/media && \
mkdir -p /vol/web/static && \
chown -R django-user:django-user /vol && \
chmod -R 755 /vol
ENV PATH="/py/bin:$PATH" ENV PATH="/py/bin:$PATH"

View File

@ -1856,3 +1856,5 @@ Ruta `localhost:8000/api/docs`
- 2da parte -> [Recetas](./README2.md) - 2da parte -> [Recetas](./README2.md)
- 3ra parte -> [Tags](./README3.md) - 3ra parte -> [Tags](./README3.md)
- 4ta parte -> [Ingredientes](./README4.md) - 4ta parte -> [Ingredientes](./README4.md)
- 5ta parte -> [Imagenes](./README5.md)
- 6ta parte -> [Fitrado](./README6.md)

View File

@ -539,6 +539,8 @@ URL `localhost:8000/api/docs/`
---- ----
- 1ra parte -> [Recepi API](./README.md) - 1ra parte -> [API Recetas](./README.md)
- 3ra parte -> [Tags](./README3.md) - 3ra parte -> [Tags](./README3.md)
- 4ta parte -> [Ingredientes](./README4.md) - 4ta parte -> [Ingredientes](./README4.md)
- 5ta parte -> [Imagenes](./README5.md)
- 6ta parte -> [filtrado](./README6.md)

View File

@ -517,6 +517,8 @@ URL `localhost:8000/api/docs`
---- ----
- 1ra parte -> [Recepi API](./README.md) - 1ra parte -> [API Recetas](./README.md)
- 2da parte -> [Recetas](./README2.md) - 2da parte -> [Recetas](./README2.md)
- 4ta parte -> [Ingredientes](./README4.md) - 4ta parte -> [Ingredientes](./README4.md)
- 5ta parte -> [Imagenes](./README5.md)
- 6ta parte -> [Filtrado](./README6.md)

View File

@ -509,6 +509,8 @@ herencia
---- ----
- 1ra parte -> [Recepi API](./README.md) - 1ra parte -> [API Recetas](./README.md)
- 2da parte -> [Recetas](./README2.md) - 2da parte -> [Recetas](./README2.md)
- 3ra parte -> [Tags](./README2.md) - 3ra parte -> [Tags](./README3.md)
- 5ta parte -> [Imagenes](./README5.md)
- 6ta parte -> [Filtrado](./README6.md)

447
README5.md Normal file
View File

@ -0,0 +1,447 @@
# Images API
- Manejar archivos estaticos/media
- Agregar dependencias para manejar imagenes
- Actualización del modelo receta con el campo imagen
- Agregar endpoint para subir imagenes para recetas
### Endpoint
- `/api/recipes/<id>/upload-image/`
| Método HTTP | Función |
| - | - |
| `POST` | Subir imagen |
### Dependencias adicionales
- Pillow (Python Imaging Library)
- zlib1g, zlib1g-dev
- libjpeg-dev
[Dockerfile](./Dockerfile)
```diff
...
RUN python -m venv /py && \
apt update && \
- apt install -y postgresql-client python3-dev libpq-dev gcc python3-psycopg2 && \
+ apt install -y postgresql-client libjpeg-dev python3-dev libpq-dev \
+ gcc python3-psycopg2 zlib1g zlib1g-dev && \
apt clean && \
/py/bin/pip install --upgrade pip && \
/py/bin/pip install -r /tmp/requirements.txt && \
...
```
[`requirements.txt`](./requirements.txt)
```txt
Django==4.2.5
djangorestframework==3.14.0
psycopg2>=2.9.9
drf-spectacular>=0.16
Pillow>=10
```
`docker compose build`
### Media and Static
Imagenes, CSS, JS, Iconos, etc.
- Media: Cargadas en tiempo de ejecución (ej. user uploads)
- Static: Generadas al construir la app. (on build)
## Configuración archivos estaticos
- `STATIC_URL` Base static URL ej. `/static/static/`
- `MEDIA_URL` Base media URL ej. `/static/media/`
- `MEDIA_ROOT` Path to media files on filesystem ej. `/vol/web/media`
- `STATC_ROOT` Path to static files on filesystem ej. `/vol/web/static`
### Docker volumes
Son los volumenes que almacenan los datos persistentes
- `/vol/web` Almanecenamiento de subdirectorios `static` y `media`
## Mapping
### Development
Al desarrollar se utiliza el servidor de desarrollo de Django.
<style>div.mermaid{text-align: center;}</style>
```mermaid
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
subgraph "Django Development"
direction RL
subgraph "HTTP"
direction LR
GT1["<b>GET</b> <code>/static/static/admin/style.css</code>"]
GT2["<b>GET</b> <code>/static/static/restframework/icon.ico</code>"]
GT3["<b>GET</b> <code>/static/media/recipe/123.jpg</code>"]
end
subgraph "Django"
DJ{"Django Dev Server"}
end
subgraph "Files"
direction LR
FL1(" <code><b>[admin]</b>/static/style.css</code>")
FL2(" <code><b>[restframework]</b>/static/icon.ico</code>")
FL3(" <code>/vol/static/media/123.jpg</code>")
end
Django --> GT1
Django --> GT3
Django --> GT2
FL1 --- Django
FL2 --- Django
FL3 --- Django
end
```
### Deployment
```mermaid
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
subgraph "Nginx Development"
direction RL
subgraph "HTTP"
direction LR
GT1["<b>GET</b> <code>/static/static/admin/style.css</code>"]
GT2["<b>GET</b> <code>/static/static/restframework/icon.ico</code>"]
GT3["<b>GET</b> <code>/static/media/recipe/123.jpg</code>"]
end
subgraph "Nginx"
NG{"Reverse Proxy Server"}
end
subgraph "Files"
direction LR
FL1(" <code><b>[admin]</b>/static/style.css</code>")
FL2(" <code><b>[restframework]</b>/static/icon.ico</code>")
FL3(" <code>/vol/static/media/123.jpg</code>")
end
Nginx --> GT1
Nginx --> GT3
Nginx --> GT2
FL1 --- Nginx
FL2 --- Nginx
FL3 --- Nginx
end
```
- ej. `GET` `/static/media/file.jpeg` <- `/vol/web/media/file.jpeg`
- ej. `GET` `/static/static/admin/style.css` <- `/vol/web/static/admin/style.css`
### Collect Static
Comando de django que recolecta los archivos estaticos
`python manage.py collectstatic` coloca todos los archivos estaticos en el
directorio `STATIC_ROOT` especificado en `settings.py`
## Configuracion para archivos estaticos
Subdirectorio para manejar archivos
[`Dockerfile`](./Dockerfile)
```diff
...
adduser \
--disabled-password \
--no-create-home \
- django-user
+ django-user && \
+ mkdir -p /vol/web/media && \
+ mkdir -p /vol/web/static && \
+ chown -R django-user:django-user /vol && \
+ chmod -R 755 /vol
...
```
[`docker-compose`](./docker-compose.yml)
```diff
...
services:
app:
...
volumes:
- ./app:/app
+ - dev-static-data:/vol/
...
volumes:
dev-db-data:
+ dev-static-data:
EOF
```
### Actualizar `setttings.py`
[`settings.py`](./app/app/settings.py)
```diff
- STATIC_URL = 'static/'
+ STATIC_URL = '/static/static/'
+ MEDIA_URL = '/static/media/'
+
+ MEDIA_ROOT = '/vol/web/media'
+ STATIC_ROOT = '/vol/web/static'
```
### Actualizar `app/urls.py`
[`app/urls.py`](./app/app/urls.py)
```diff
...
+ from django.conf.urls.static import static
+ from django.conf import settings
...
+ if settings.DEBUG:
+ urlpatterns += static(
+ settings.MEDIA_URL,
+ document_root=settings.MEDIA_ROOT,
+ )
EOF
```
## Test agregar campo imagen en el modelo de receta
[`test_models.py`](./app/core/tests/test_models.py)
```py
from unittest.mock import patch
...
...
@patch('core.models.uuid.uuid4')
def test_recipe_file_name_uuid(self, mock_uuid):
"""Test generating image path."""
uuid = 'test-uuid'
mock_uuid.return_value = uuid
file_path = models.recipe_image_file_path(None, 'example.jpg')
self.assertEqual(file_path, f'uploads/recipe/{uuid}.jpg')
```
## Implementación imagen en el modelo
[`models.py`](./app/core/models.py)
```py
import uuid
import os
...
def recipe_image_file_path(instance, filename):
"""Generate file path for new recipe image."""
ext = os.path.splitext(filename)[1]
filename = f'{uuid.uuid4()}{ext}'
return os.path.join('uploads', 'recipe', filename)
...
class Recipe(models.Model):
...
image = models.ImageField(null=True, upload_to=recipe_image_file_path)
def __str__(self):
...
```
### Crear migraciones
Crear migraciones para el nuevo campo en el modelo receta
`docker compose run --rm app sh -c python manage.py makemigrations`
```sh
[+] Creating 1/0
✔ Container recipes_api_django-db-1 Running 0.0s
Migrations for 'core':
core/migrations/0005_recipe_image.py
- Add field image to recipe
```
## Implementación funcionalidad de la API para subir imagenes
### Test cargar/subir imagen
[`test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py)
```py
import tempfile
import os
from PIL import Image
...
def image_upload_url(recipe_id):
"""Create and return an image upload URL."""
return reverse('recipe:recipe-detail', args=[recipe_id])
...
class ImageUploadTest(TestCase):
"""Tests for the image upload API."""
def setUp(self):
self.client = APIClient()
self.user = get_user_model().objects.create_user(
'user@example.com',
'password123',
)
self.client.force_authenticate(self.user)
self.recipe = create_recipe(user=self.user)
def tearDown(self):
self.recipe.image.delete()
def test_upload_image(self):
"""Test uploading an image to a recipe."""
url = image_upload_url(self.recipe.id)
with tempfile.NamedTemporaryFile(suffix='.jpg') as image_file:
img = Image.new('RGB', (10, 10))
img.save(image_file, format='JPEG')
image_file.seek(0)
payload = {'image': image_file}
res = self.client.post(url, payload, format='multipart')
self.recipe.refresh_from_db()
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertIn('image', res.data)
self.assertTrue(os.path.exists(self.recipe.image.path))
def test_upload_image_bad_request(self):
"""Test uploading invalid image."""
url = image_upload_url(self.recipe.id)
payload = {'image': 'notanimage'}
res = self.client.post(url, payload, format='multipart')
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
```
## Implementación subir imagen
Implementación de la funcionalidad para subir imagenes a travez de
*recipe endpoint*
### Serializador imagen receta
[`serializers.py`](./app/recipe/serializers.py)
```py
class RecipeImageSerializer(serializers.ModelSerializer):
"""Serializer for uploading images to recipes."""
class Meta:
model = Recipe
fields = ['id', 'image']
read_only_fields = ['id']
extra_kwargs = {'image': {'required': 'True'}}
```
### Vista imagen receta
[`recipe/views.py`](./app/recipe/views.py)
```diff
from rest_framework import (
viewsets,
mixins,
+ status,
)
+ from rest_framework.decorators import action
+ from rest_framework.response import Response
...
class RecipeViewSet(viewsets.ModelViewSet):
...
def get_serializer_class(self):
"""Return the serializer class for request."""
if self.action == 'list':
return serializers.RecipeSerializer
+ elif self.action == 'upload_image':
+ return serializers.RecipeImageSerializer
```
```py
@action(methods=['POST'], detail=True, url_path='upload-image')
def upload_image(self, request, pk=None):
"""Upload an image to recipe."""
recipe = self.get_object()
serializer = self.get_serializer(recipe, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
```
Para subir imagenes a travez de la interfaz web establecer la sgte.
configuración en [`settings.py`](./app/app/settings.py)
```py
SPECTACULAR_SETTINGS = {
'COMPONENT_SPLIT_REQUEST': True,
}
```
### Incluir imagen en detalle receta
[`serializers.py`](./app/recipe/serializers.py)
```py
class RecipeDetailSerializer(RecipeSerializer):
"""Serializer for recipe detail view."""
class Meta(RecipeSerializer.Meta):
fields = RecipeSerializer.Meta.fields + ['description', 'image']
```
## Prueba en el navegador
Levantar aplicación `docker compose up` y visitar `locahost:8000/api/docs`
### Swagger
![img](./imgs_readme/api_swagger_04.png)
### Django Admin
![img](./imgs_readme/django_admin_16.png)
----
- 1ra parte -> [API Recetas](./README.md)
- 2da parte -> [Recetas](./README2.md)
- 3ra parte -> [Tags](./README3.md)
- 4ta parte -> [Ingredientes](./README4.md)
- 6ta parte -> [Filtrado](./README6.md)

0
README6.md Normal file
View File

View File

@ -124,7 +124,11 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = '/static/static/'
MEDIA_URL = '/static/media/'
MEDIA_ROOT = '/vol/web/media'
STATIC_ROOT = '/vol/web/static'
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
@ -136,3 +140,7 @@ AUTH_USER_MODEL = 'core.User'
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
} }
SPECTACULAR_SETTINGS = {
'COMPONENT_SPLIT_REQUEST': True,
}

View File

@ -14,9 +14,14 @@ 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 drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
)
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.conf.urls.static import static
from django.conf import settings
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
@ -28,3 +33,9 @@ urlpatterns = [
path('api/user/', include('user.urls')), path('api/user/', include('user.urls')),
path('api/recipe/', include('recipe.urls')), path('api/recipe/', include('recipe.urls')),
] ]
if settings.DEBUG:
urlpatterns += static(
settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT,
)

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.5 on 2023-10-11 19:31
import core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_ingredient_recipe_ingredients'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='image',
field=models.ImageField(null=True, upload_to=core.models.recipe_image_file_path),
),
]

View File

@ -1,6 +1,9 @@
""" """
Databse models. Databse models.
""" """
import uuid
import os
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.contrib.auth.models import ( from django.contrib.auth.models import (
@ -10,6 +13,14 @@ from django.contrib.auth.models import (
) )
def recipe_image_file_path(instance, filename):
"""Generate file path for new recipe image."""
ext = os.path.splitext(filename)[1]
filename = f'{uuid.uuid4()}{ext}'
return os.path.join('uploads', 'recipe', filename)
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
"""Manager for users.""" """Manager for users."""
@ -58,6 +69,7 @@ class Recipe(models.Model):
link = models.CharField(max_length=255, blank=True) link = models.CharField(max_length=255, blank=True)
tags = models.ManyToManyField('Tag') tags = models.ManyToManyField('Tag')
ingredients = models.ManyToManyField('Ingredient') ingredients = models.ManyToManyField('Ingredient')
image = models.ImageField(null=True, upload_to=recipe_image_file_path)
def __str__(self): def __str__(self):
return self.title return self.title

View File

@ -1,6 +1,7 @@
""" """
Test for models. Test for models.
""" """
from unittest.mock import patch
from decimal import Decimal from decimal import Decimal
from django.test import TestCase from django.test import TestCase
@ -80,3 +81,12 @@ class ModelTests(TestCase):
) )
self.assertEqual(str(ingredient), ingredient.name) self.assertEqual(str(ingredient), ingredient.name)
@patch('core.models.uuid.uuid4')
def test_recipe_file_name_uuid(self, mock_uuid):
"""Test generating image path."""
uuid = 'test-uuid'
mock_uuid.return_value = uuid
file_path = models.recipe_image_file_path(None, 'example.jpg')
self.assertEqual(file_path, f'uploads/recipe/{uuid}.jpg')

View File

@ -91,4 +91,14 @@ class RecipeDetailSerializer(RecipeSerializer):
"""Serializer for recipe detail view.""" """Serializer for recipe detail view."""
class Meta(RecipeSerializer.Meta): class Meta(RecipeSerializer.Meta):
fields = RecipeSerializer.Meta.fields + ['description'] fields = RecipeSerializer.Meta.fields + ['description', 'image']
class RecipeImageSerializer(serializers.ModelSerializer):
"""Serializer for uploading images to recipes."""
class Meta:
model = Recipe
fields = ['id', 'image']
read_only_fields = ['id']
extra_kwargs = {'image': {'required': 'True'}}

View File

@ -1,6 +1,10 @@
""" """
Test for recipe APIs. Test for recipe APIs.
""" """
import tempfile
import os
from PIL import Image
from decimal import Decimal from decimal import Decimal
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -30,6 +34,11 @@ def detail_url(recipe_id):
return reverse('recipe:recipe-detail', args=[recipe_id]) return reverse('recipe:recipe-detail', args=[recipe_id])
def image_upload_url(recipe_id):
"""Create and return an image upload URL."""
return reverse('recipe:recipe-upload-image', args=[recipe_id])
def create_recipe(user, **params): def create_recipe(user, **params):
"""Create and return a sample recipe.""" """Create and return a sample recipe."""
defaults = { defaults = {
@ -384,3 +393,42 @@ class PrivateRecipeApiTests(TestCase):
self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(recipe.ingredients.count(), 0) self.assertEqual(recipe.ingredients.count(), 0)
class ImageUploadTest(TestCase):
"""Tests for the image upload API."""
def setUp(self):
self.client = APIClient()
self.user = get_user_model().objects.create_user(
'user@example.com',
'password123',
)
self.client.force_authenticate(self.user)
self.recipe = create_recipe(user=self.user)
def tearDown(self):
self.recipe.image.delete()
def test_upload_image(self):
"""Test uploading an image to a recipe."""
url = image_upload_url(self.recipe.id)
with tempfile.NamedTemporaryFile(suffix='.jpg') as image_file:
img = Image.new('RGB', (10, 10))
img.save(image_file, format='JPEG')
image_file.seek(0)
payload = {'image': image_file}
res = self.client.post(url, payload, format='multipart')
self.recipe.refresh_from_db()
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertIn('image', res.data)
self.assertTrue(os.path.exists(self.recipe.image.path))
def test_upload_image_bad_request(self):
"""Test uploading invalid image."""
url = image_upload_url(self.recipe.id)
payload = {'image': 'notanimage'}
res = self.client.post(url, payload, format='multipart')
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)

View File

@ -4,7 +4,10 @@ Views for the recipe APIs.
from rest_framework import ( from rest_framework import (
viewsets, viewsets,
mixins, mixins,
status,
) )
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -31,12 +34,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
"""Return the serializer class for request.""" """Return the serializer class for request."""
if self.action == 'list': if self.action == 'list':
return serializers.RecipeSerializer return serializers.RecipeSerializer
elif self.action == 'upload_image':
return serializers.RecipeImageSerializer
return self.serializer_class return self.serializer_class
def perform_create(self, serializer): def perform_create(self, serializer):
"""Create a new recipe.""" """Create a new recipe."""
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
@action(methods=['POST'], detail=True, url_path='upload-image')
def upload_image(self, request, pk=None):
"""Upload an image to recipe."""
recipe = self.get_object()
serializer = self.get_serializer(recipe, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class BaseRecipeAtrrViewSet(mixins.DestroyModelMixin, class BaseRecipeAtrrViewSet(mixins.DestroyModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,

View File

@ -9,6 +9,7 @@ services:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./app:/app - ./app:/app
- dev-static-data:/vol/
command: > command: >
sh -c "python manage.py wait_for_db && sh -c "python manage.py wait_for_db &&
python manage.py migrate && python manage.py migrate &&
@ -33,4 +34,5 @@ services:
volumes: volumes:
dev-db-data: dev-db-data:
dev-static-data:

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -2,3 +2,4 @@ Django==4.2.5
djangorestframework==3.14.0 djangorestframework==3.14.0
psycopg2>=2.9.9 psycopg2>=2.9.9
drf-spectacular>=0.16 drf-spectacular>=0.16
Pillow>=10