recipes_api/README5.md
devfzn d20158552c
Creación Image API
tests, CRUD, serializers, views, model & settings
2023-10-11 17:59:44 -03:00

10 KiB

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

...

 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

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.

%%{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

%%{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

  ...
  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

  ...
  services:
    app:
     ...
     volumes:
       - ./app:/app
+      - dev-static-data:/vol/
     ...

  volumes:
    dev-db-data:
+   dev-static-data:
EOF

Actualizar setttings.py

settings.py

-  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

   ...
+  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

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

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

[+] 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

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

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

  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
    @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

SPECTACULAR_SETTINGS = {
    'COMPONENT_SPLIT_REQUEST': True,
}

Incluir imagen en detalle receta

serializers.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

Django Admin

img