diff --git a/app/app/settings.py b/app/app/settings.py index 9e33ac6..11495a8 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -42,6 +42,7 @@ 'rest_framework.authtoken', 'drf_spectacular', 'user', + 'recipe', ] MIDDLEWARE = [ diff --git a/app/app/urls.py b/app/app/urls.py index 0890765..9d3a468 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -22,5 +22,8 @@ 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')) + path('api/user/', include('user.urls')), + path('api/recipe/', include('recipe.urls')), ] + +# Here this includes all the urls which we have defined in the recipe urls and other files \ No newline at end of file diff --git a/app/core/admin.py b/app/core/admin.py index ddd09cc..e8d06e0 100644 --- a/app/core/admin.py +++ b/app/core/admin.py @@ -23,4 +23,5 @@ class UserAdmin(BaseUserAdmin): ) -admin.site.register(models.User, UserAdmin) \ No newline at end of file +admin.site.register(models.User, UserAdmin) +admin.site.register(models.Recipe) \ No newline at end of file diff --git a/app/core/migrations/0002_recipe.py b/app/core/migrations/0002_recipe.py new file mode 100644 index 0000000..29a2c55 --- /dev/null +++ b/app/core/migrations/0002_recipe.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.25 on 2024-08-24 15:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Recipe', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('time_minutes', models.IntegerField()), + ('price', models.DecimalField(decimal_places=2, max_digits=5)), + ('link', models.CharField(blank=True, max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/app/core/models.py b/app/core/models.py index 7fae641..4bc76a4 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -4,7 +4,7 @@ """ Database model """ - +from django.conf import settings from django.db import models from django.contrib.auth.models import ( AbstractBaseUser, @@ -40,4 +40,17 @@ class User(AbstractBaseUser, PermissionsMixin): # assigning the UserManager to objects objects = UserManager() - USERNAME_FIELD = 'email' \ No newline at end of file + USERNAME_FIELD = 'email' + +class Recipe(models.Model): + """Recipe model""" + # Here we use settings.AUTH_USER_MODEL to reference the user model in case we change the user model in the future this is a best practice + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + time_minutes = models.IntegerField() + price = models.DecimalField(max_digits=5, decimal_places=2) + link = models.CharField(max_length=255, blank=True) + + def __str__(self): + return self.title \ No newline at end of file diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 392f072..2237b7e 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -1,10 +1,13 @@ """ Test for models """ +from decimal import Decimal from django.test import TestCase from django.contrib.auth import get_user_model +from core import models + class ModelTests(TestCase): """Test for models""" @@ -42,4 +45,20 @@ def test_create_new_superuser(self): """Test creating a new superuser""" user = get_user_model().objects.create_superuser('test@example.com', 'test123') self.assertTrue(user.is_superuser) - self.assertTrue(user.is_staff) \ No newline at end of file + self.assertTrue(user.is_staff) + + def test_create_recipe(self): + """Test creating a new recipe""" + user = get_user_model().objects.create_user( + 'test@example.com', + 'test123') + recipe = models.Recipe.objects.create( + user=user, + title='Sample Recipe', + time_minutes=5, + price=Decimal( + '5.00'), + description='Sample Description' + ) + self.assertEqual(str(recipe), recipe.title) + \ No newline at end of file diff --git a/app/recipe/__init__.py b/app/recipe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/recipe/apps.py b/app/recipe/apps.py new file mode 100644 index 0000000..9133199 --- /dev/null +++ b/app/recipe/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RecipeConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'recipe' diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py new file mode 100644 index 0000000..6059e2b --- /dev/null +++ b/app/recipe/serializers.py @@ -0,0 +1,22 @@ +""" +Serialiers for recipe APIs +""" + +from rest_framework import serializers +from core.models import Recipe + +class RecipeSerializer(serializers.ModelSerializer): + """Serializer for recipe objects""" + + # Here it tells django we want to use the model Recipe and the fields we want to use + class Meta: + model = Recipe + fields = ('id', 'title', 'time_minutes', 'price', 'link') + read_only_fields = ('id',) + +class RecipeDetailSerializer(RecipeSerializer): + """Serializer for recipe detail objects""" + # Here we are extending the RecipeSerializer and adding the extra fields + class Meta(RecipeSerializer.Meta): + fields = RecipeSerializer.Meta.fields + ('ingredients', 'tags') + read_only_fields = ('id',) diff --git a/app/recipe/tests/__init__.py b/app/recipe/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py new file mode 100644 index 0000000..2392221 --- /dev/null +++ b/app/recipe/tests/test_recipe_api.py @@ -0,0 +1,95 @@ +""" +Tests for recipe APIs. +""" + +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APIClient + +from core.models import Recipe + +from recipe.serializers import RecipeSerializer, RecipeDetailSerializer + +RECIPE_URL = reverse('recipe:recipe-list') + +def detail_url(recipe_id): + """Return recipe detail URL""" + return reverse('recipe:recipe-detail', args=[recipe_id]) + +def create_recipe(user, **params): + """Create and return a sample recipe""" + defaults = { + 'title': 'Sample Recipe', + 'time_minutes': 10, + 'price': Decimal('5.00'), + 'description': 'Sample description', + 'link': 'https://sample.com/recipe' + } + defaults.update(params) + + return Recipe.objects.create(user=user, **defaults) + + +class PublicRecipeAPITests(TestCase): + """Test unauthenticated recipe API access""" + + def setUp(self): + self.client = APIClient() + + def test_auth_required(self): + """Test that authentication is required""" + res = self.client.get(reverse('recipe:recipe-list')) + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PrivateRecipeApiTests(TestCase): + """Test authenticated recipe API access""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'user@example.com', + 'testpass') + self.client.force_authenticate(self.user) + + def test_retrieve_recipes(self): + """Test retrieving a list of recipes""" + create_recipe(user=self.user) + create_recipe(user=self.user) + + res = self.client.get(RECIPE_URL) + + recipes = Recipe.objects.all().order_by('-id') + serializer = RecipeSerializer(recipes, many=True) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, serializer.data) + + def test_recipes_limited_to_user(self): + """Test retrieving recipes for user""" + other_user = get_user_model().objects.create_user( + 'other@example.com', + 'password123') + create_recipe(user=other_user) + recipe = create_recipe(user=self.user) + + res = self.client.get(RECIPE_URL) + + recipes = Recipe.objects.filter(user=self.user) + serializer = RecipeSerializer(recipes, many=True) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, serializer.data) + + def test_get_recipe_detail(self): + """Test viewing a recipe detail""" + recipe = create_recipe(user=self.user) + + url = detail_url(recipe.id) + res = self.client.get(url) + + serializer = RecipeDetailSerializer(recipe) + self.assertEqual(res.data, serializer.data) \ No newline at end of file diff --git a/app/recipe/urls.py b/app/recipe/urls.py new file mode 100644 index 0000000..2d08ba2 --- /dev/null +++ b/app/recipe/urls.py @@ -0,0 +1,16 @@ +""" +URL mapping for the recipe app is defined in app/recipe/urls.py. The URL mapping is then imported into the main project urls.py file. +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from recipe import views + +router = DefaultRouter() +router.register('recipes', views.RecipeViewSet) + +app_name = 'recipe' + +urlpatterns = [ + path('', include(router.urls)) +] \ No newline at end of file diff --git a/app/recipe/views.py b/app/recipe/views.py new file mode 100644 index 0000000..8af951e --- /dev/null +++ b/app/recipe/views.py @@ -0,0 +1,29 @@ +""" +Views for the recipe APIs +""" + +from rest_framework import viewsets +from rest_framework.authentication import TokenAuthentication +from rest_framework.permissions import IsAuthenticated + + +from core.models import Recipe +from recipe import serializers + +class RecipeViewSet(viewsets.ModelViewSet): + """Manage recipes in the database""" + serializer_class = serializers.RecipeDetailSerializer + queryset = Recipe.objects.all() + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Retrieve the recipes for the authenticated user""" + return self.queryset.filter(user=self.request.user).order_by('-id') + + def get_serializer_class(self): + """Return appropriate serializer class""" + if self.action == 'list': + return serializers.RecipeSerializer + + return self.serializer_class \ No newline at end of file