diff --git a/Dockerfile b/Dockerfile index 6a4b877..d999594 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,10 @@ EXPOSE 8000 ARG DEV=false -# apt install -y python3-dev libpq-dev python3-psycopg2 && \ 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 && \ @@ -25,7 +25,11 @@ RUN python -m venv /py && \ 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 ENV PATH="/py/bin:$PATH" diff --git a/README.md b/README.md index 05ef62a..75c2cd5 100644 --- a/README.md +++ b/README.md @@ -1856,3 +1856,5 @@ Ruta `localhost:8000/api/docs` - 2da parte -> [Recetas](./README2.md) - 3ra parte -> [Tags](./README3.md) - 4ta parte -> [Ingredientes](./README4.md) +- 5ta parte -> [Imagenes](./README5.md) +- 6ta parte -> [Fitrado](./README6.md) diff --git a/README2.md b/README2.md index ad1c4b8..450faa1 100644 --- a/README2.md +++ b/README2.md @@ -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) - 4ta parte -> [Ingredientes](./README4.md) +- 5ta parte -> [Imagenes](./README5.md) +- 6ta parte -> [filtrado](./README6.md) diff --git a/README3.md b/README3.md index 7fce063..ad2336b 100644 --- a/README3.md +++ b/README3.md @@ -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) - 4ta parte -> [Ingredientes](./README4.md) +- 5ta parte -> [Imagenes](./README5.md) +- 6ta parte -> [Filtrado](./README6.md) diff --git a/README4.md b/README4.md index 25a824a..7deeda7 100644 --- a/README4.md +++ b/README4.md @@ -509,6 +509,8 @@ herencia ---- -- 1ra parte -> [Recepi API](./README.md) +- 1ra parte -> [API Recetas](./README.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) diff --git a/README5.md b/README5.md new file mode 100644 index 0000000..b50896a --- /dev/null +++ b/README5.md @@ -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//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. + + + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%% +flowchart +subgraph "Django Development" +direction RL + +subgraph "HTTP" +direction LR +GT1["GET /static/static/admin/style.css"] +GT2["GET /static/static/restframework/icon.ico"] +GT3["GET /static/media/recipe/123.jpg"] +end + +subgraph "Django" +DJ{"Django Dev Server"} +end + +subgraph "Files" +direction LR +FL1(" [admin]/static/style.css") +FL2(" [restframework]/static/icon.ico") +FL3(" /vol/static/media/123.jpg") +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["GET /static/static/admin/style.css"] +GT2["GET /static/static/restframework/icon.ico"] +GT3["GET /static/media/recipe/123.jpg"] +end + +subgraph "Nginx" +NG{"Reverse Proxy Server"} +end + +subgraph "Files" +direction LR +FL1(" [admin]/static/style.css") +FL2(" [restframework]/static/icon.ico") +FL3(" /vol/static/media/123.jpg") +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) diff --git a/README6.md b/README6.md new file mode 100644 index 0000000..e69de29 diff --git a/app/app/settings.py b/app/app/settings.py index b00e4f9..f6007ed 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -124,7 +124,11 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # 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 # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field @@ -136,3 +140,7 @@ AUTH_USER_MODEL = 'core.User' REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } + +SPECTACULAR_SETTINGS = { + 'COMPONENT_SPLIT_REQUEST': True, +} diff --git a/app/app/urls.py b/app/app/urls.py index 777efcb..9842684 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -14,9 +14,14 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 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.urls import include, path +from django.conf.urls.static import static +from django.conf import settings urlpatterns = [ path('admin/', admin.site.urls), @@ -28,3 +33,9 @@ urlpatterns = [ path('api/user/', include('user.urls')), path('api/recipe/', include('recipe.urls')), ] + +if settings.DEBUG: + urlpatterns += static( + settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT, + ) diff --git a/app/core/migrations/0005_recipe_image.py b/app/core/migrations/0005_recipe_image.py new file mode 100644 index 0000000..eeb7b88 --- /dev/null +++ b/app/core/migrations/0005_recipe_image.py @@ -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), + ), + ] diff --git a/app/core/models.py b/app/core/models.py index ce588a7..b514e43 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -1,6 +1,9 @@ """ Databse models. """ +import uuid +import os + from django.conf import settings from django.db import models 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): """Manager for users.""" @@ -58,6 +69,7 @@ class Recipe(models.Model): link = models.CharField(max_length=255, blank=True) tags = models.ManyToManyField('Tag') ingredients = models.ManyToManyField('Ingredient') + image = models.ImageField(null=True, upload_to=recipe_image_file_path) def __str__(self): return self.title diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 6e87110..32da4fa 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -1,6 +1,7 @@ """ Test for models. """ +from unittest.mock import patch from decimal import Decimal from django.test import TestCase @@ -80,3 +81,12 @@ class ModelTests(TestCase): ) 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') diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index 273b906..f0f6db0 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -91,4 +91,14 @@ class RecipeDetailSerializer(RecipeSerializer): """Serializer for recipe detail view.""" 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'}} diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index 107be86..e7c2fab 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -1,6 +1,10 @@ """ Test for recipe APIs. """ +import tempfile +import os + +from PIL import Image from decimal import Decimal 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]) +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): """Create and return a sample recipe.""" defaults = { @@ -384,3 +393,42 @@ class PrivateRecipeApiTests(TestCase): self.assertEqual(res.status_code, status.HTTP_200_OK) 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) diff --git a/app/recipe/views.py b/app/recipe/views.py index a7bc61b..f5e7ff4 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -4,7 +4,10 @@ Views for the recipe APIs. from rest_framework import ( viewsets, mixins, + status, ) +from rest_framework.decorators import action +from rest_framework.response import Response from rest_framework.authentication import TokenAuthentication from rest_framework.permissions import IsAuthenticated @@ -31,12 +34,27 @@ class RecipeViewSet(viewsets.ModelViewSet): """Return the serializer class for request.""" if self.action == 'list': return serializers.RecipeSerializer + elif self.action == 'upload_image': + return serializers.RecipeImageSerializer + return self.serializer_class def perform_create(self, serializer): """Create a new recipe.""" 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, mixins.UpdateModelMixin, diff --git a/docker-compose.yml b/docker-compose.yml index 8a89582..147a139 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - "8000:8000" volumes: - ./app:/app + - dev-static-data:/vol/ command: > sh -c "python manage.py wait_for_db && python manage.py migrate && @@ -33,4 +34,5 @@ services: volumes: dev-db-data: + dev-static-data: diff --git a/imgs_readme/api_swagger_04.png b/imgs_readme/api_swagger_04.png new file mode 100644 index 0000000..1939bfb Binary files /dev/null and b/imgs_readme/api_swagger_04.png differ diff --git a/imgs_readme/django_admin_16.png b/imgs_readme/django_admin_16.png new file mode 100644 index 0000000..d5a2b82 Binary files /dev/null and b/imgs_readme/django_admin_16.png differ diff --git a/requirements.txt b/requirements.txt index c3ba626..0f2e2bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Django==4.2.5 djangorestframework==3.14.0 psycopg2>=2.9.9 drf-spectacular>=0.16 +Pillow>=10