From 0ef6d8ac703ead0086651836fe9108aa23b487a8 Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:43:16 +0200 Subject: [PATCH 1/7] feat: added docstring and str representation --- borrowing/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/borrowing/models.py b/borrowing/models.py index 9b0c2d1..4376a2d 100644 --- a/borrowing/models.py +++ b/borrowing/models.py @@ -5,8 +5,15 @@ class Borrowing(models.Model): + """ + Borrowing model with attributes: + borrow_date, expected_return_date, actual_return_date, book, user + """ borrow_date = models.DateField(auto_now_add=True) expected_return_date = models.DateField() actual_return_date = models.DateField(null=True, blank=True) book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="borrowings") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="borrowings") + + def __str__(self): + return str(self.borrow_date) From a8aa1ca1b84673ca37eccf8d10ba43955de3d6ac Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:43:41 +0200 Subject: [PATCH 2/7] feat: added serializers for borrowings --- borrowing/serializers.py | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 borrowing/serializers.py diff --git a/borrowing/serializers.py b/borrowing/serializers.py new file mode 100644 index 0000000..dc78b62 --- /dev/null +++ b/borrowing/serializers.py @@ -0,0 +1,96 @@ +from rest_framework import serializers + +from book.models import Book +from book.serializers import BookSerializer +from borrowing.models import Borrowing + + +class BorrowingSerializer(serializers.ModelSerializer): + """ + Borrowing Serializer with validation for only one active borrowing. + """ + book = serializers.SlugRelatedField( + queryset=Book.objects.all(), + slug_field="title" + ) + + class Meta: + model = Borrowing + fields = [ + "id", + "expected_return_date", + "book" + ] + + def validate(self, attrs): + user = self.context["request"].user + active_borrowings = Borrowing.objects.filter( + user=user, + actual_return_date__isnull=True + ) + + if active_borrowings.exists(): + raise serializers.ValidationError( + "You already have an active borrowing. " + "Please return the current book before borrowing another." + ) + return attrs + + +class BorrowingReturnBookSerializer(serializers.ModelSerializer): + """ + Borrowing Serializer for returning a borrowing book. + """ + return_book = serializers.BooleanField() + + class Meta: + model = Borrowing + fields = ["return_book"] + + +class BorrowingListSerializer(serializers.ModelSerializer): + """ + Borrowing Serializer for borrowing list. + """ + book = serializers.SlugRelatedField( + many=False, read_only=True, slug_field="title" + ) + + class Meta: + model = Borrowing + fields = [ + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + ] + + def to_representation(self, instance): + representation = super().to_representation(instance) + if not instance.actual_return_date: + representation.pop("actual_return_date", None) + return representation + + +class BorrowingDetailSerializer(serializers.ModelSerializer): + """ + Borrowing Serializer for detail of a borrowing book. + """ + book = BookSerializer(many=False, read_only=True) + + class Meta: + model = Borrowing + fields = [ + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + ] + + def to_representation(self, instance): + representation = super().to_representation(instance) + if not instance.actual_return_date: + representation.pop("actual_return_date", None) + return representation From f0133f51a649e0aee3cd1ab4a337e02c4b4c5b51 Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:44:13 +0200 Subject: [PATCH 3/7] feat: added api urls for borrowings --- borrowing/urls.py | 11 +++++++++++ core/urls.py | 1 + 2 files changed, 12 insertions(+) create mode 100644 borrowing/urls.py diff --git a/borrowing/urls.py b/borrowing/urls.py new file mode 100644 index 0000000..81f1d95 --- /dev/null +++ b/borrowing/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework import routers + +from borrowing.views import BorrowingViewSet + +router = routers.DefaultRouter() +router.register("", BorrowingViewSet, basename="borrowings") + +urlpatterns = [path("", include(router.urls))] + +app_name = "borrowing" diff --git a/core/urls.py b/core/urls.py index 0cdc875..698ef12 100644 --- a/core/urls.py +++ b/core/urls.py @@ -26,6 +26,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/user/", include("user.urls", namespace="user")), + path("api/borrowings/", include("borrowing.urls", namespace="borrowings")), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), From 2523e375bd0f41597275a2d6c645b94397bcd25a Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:45:02 +0200 Subject: [PATCH 4/7] feat: added viewset for borrowings api --- borrowing/views.py | 84 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/borrowing/views.py b/borrowing/views.py index 60f00ef..956dc1f 100644 --- a/borrowing/views.py +++ b/borrowing/views.py @@ -1 +1,83 @@ -# Create your views here. +from datetime import datetime + +from django.http import HttpResponseRedirect +from rest_framework import viewsets, mixins, status +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from django_filters.rest_framework import DjangoFilterBackend + +from borrowing.filters import CustomFilter +from borrowing.models import Borrowing +from borrowing.serializers import ( + BorrowingSerializer, + BorrowingDetailSerializer, + BorrowingListSerializer, + BorrowingReturnBookSerializer +) + + +class BorrowingViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + """ + Viewset for borrowing related objects. + Provides actions: list, create, retrieve. + """ + queryset = Borrowing.objects.all() + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_class = CustomFilter + + def get_queryset(self): + if self.request.user.is_staff: + return Borrowing.objects.all().order_by("actual_return_date") + return Borrowing.objects.filter(user=self.request.user).order_by("actual_return_date") + + def get_serializer_class(self): + if self.action == "list": + return BorrowingListSerializer + elif self.action == "retrieve": + return BorrowingDetailSerializer + elif self.action == "return_book": + return BorrowingReturnBookSerializer + + return BorrowingSerializer + + def perform_create(self, serializer): + book = serializer.validated_data["book"] + + if book.inventory <= 0: + raise ValidationError("No copies available in inventory.") + + book.inventory -= 1 + book.save() + + serializer.save(user=self.request.user) + + @action( + methods=["POST"], + detail=True, + url_path="return", + permission_classes=[IsAuthenticated], + ) + def return_book(self, request, pk=None): + """ + Additional post action to return a book. + """ + borrowing = self.get_object() + + if borrowing.actual_return_date: + raise ValidationError("This borrowing has already been returned.") + + borrowing.actual_return_date = datetime.now() + borrowing.save() + + book = borrowing.book + book.inventory += 1 + book.save() + + return HttpResponseRedirect("/api/borrowings/", status=status.HTTP_302_FOUND) From 60315c31bcf03a19bd91dc997e21e189603cf174 Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:45:27 +0200 Subject: [PATCH 5/7] feat: added custom filters for borrowings --- borrowing/filters.py | 30 ++++++++++++++++++++++++++++++ core/settings.py | 1 + poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 borrowing/filters.py diff --git a/borrowing/filters.py b/borrowing/filters.py new file mode 100644 index 0000000..1aac12d --- /dev/null +++ b/borrowing/filters.py @@ -0,0 +1,30 @@ +from django_filters import rest_framework as filters + +from borrowing.models import Borrowing + + +class CustomFilter(filters.FilterSet): + """ + FilterSet for CustomFilter + """ + is_active = filters.BooleanFilter(method="filter_is_active", label="Active") + user_id = filters.NumberFilter(method="filter_user_id") + + class Meta: + model = Borrowing + fields = ["is_active", "user_id"] + + def filter_is_active(self, queryset, name, value): + if value: + return queryset.filter(actual_return_date__isnull=True) + else: + return queryset.filter(actual_return_date__isnull=False) + + def filter_user_id(self, queryset, name, value): + request = self.request + if request.user.is_staff: + if value: + return queryset.filter(user_id=value) + return queryset + else: + return queryset.filter(user=request.user) \ No newline at end of file diff --git a/core/settings.py b/core/settings.py index c00f80b..ed70445 100644 --- a/core/settings.py +++ b/core/settings.py @@ -47,6 +47,7 @@ "rest_framework", "rest_framework_simplejwt", "drf_spectacular", + "django_filters", # custom apps "book", diff --git a/poetry.lock b/poetry.lock index fdcb5e8..2cd8265 100644 --- a/poetry.lock +++ b/poetry.lock @@ -53,6 +53,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-filter" +version = "24.3" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64"}, + {file = "django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"}, +] + +[package.dependencies] +Django = ">=4.2" + [[package]] name = "djangorestframework" version = "3.15.2" @@ -523,4 +537,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12.8" -content-hash = "1fe43f197dc5ae81ca3cd4fb9d073d0f954973d365b9f2398ae81022ae0cb158" +content-hash = "42f1e8dbb5c88e2669cf5a00e2600f4cb38bc311523375a99ed13773e41a0bff" diff --git a/pyproject.toml b/pyproject.toml index 420512d..f26fa77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ djangorestframework = "^3.15.2" drf-spectacular = "^0.28.0" psycopg2-binary = "^2.9.10" djangorestframework-simplejwt = "^5.3.1" +django-filter = "^24.3" [build-system] requires = ["poetry-core"] From 3372f5a7b74fc099d0570c15399e7f7089568b0f Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:45:51 +0200 Subject: [PATCH 6/7] feat: added tests for borrowings --- borrowing/tests.py | 1 - borrowing/tests/__init__.py | 0 borrowing/tests/test_borrowing_api.py | 106 +++++++++++++++ borrowing/tests/test_borrowing_models.py | 57 ++++++++ borrowing/tests/test_borrowing_serializers.py | 123 ++++++++++++++++++ 5 files changed, 286 insertions(+), 1 deletion(-) delete mode 100644 borrowing/tests.py create mode 100644 borrowing/tests/__init__.py create mode 100644 borrowing/tests/test_borrowing_api.py create mode 100644 borrowing/tests/test_borrowing_models.py create mode 100644 borrowing/tests/test_borrowing_serializers.py diff --git a/borrowing/tests.py b/borrowing/tests.py deleted file mode 100644 index a39b155..0000000 --- a/borrowing/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/borrowing/tests/__init__.py b/borrowing/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowing/tests/test_borrowing_api.py b/borrowing/tests/test_borrowing_api.py new file mode 100644 index 0000000..508acd8 --- /dev/null +++ b/borrowing/tests/test_borrowing_api.py @@ -0,0 +1,106 @@ +from rest_framework.test import APITestCase +from rest_framework import status +from django.urls import reverse +from datetime import timedelta +from django.utils.timezone import now +from django.contrib.auth import get_user_model + +from book.models import Book +from borrowing.models import Borrowing + +User = get_user_model() + +class BorrowingViewSetTest(APITestCase): + def setUp(self): + self.user = User.objects.create_user(email="test@user.com", password="password123") + self.staff_user = User.objects.create_user(email="test@admin.com", password="adminpassword", is_staff=True) + self.book = Book.objects.create(title="Test Book", author="Author", inventory=5, daily_fee=1) + self.client.force_authenticate(user=self.user) + + def test_create_borrowing(self): + """Test creating a borrowing record.""" + data = { + "expected_return_date": (now().date() + timedelta(days=7)), + "book": self.book.title, + } + url = reverse("borrowings:borrowings-list") + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Borrowing.objects.count(), 1) + self.assertEqual(Borrowing.objects.first().user, self.user) + + def test_create_borrowing_no_inventory(self): + """Test creating a borrowing record when no copies are available.""" + self.book.inventory = 0 + self.book.save() + + data = { + "expected_return_date": (now().date() + timedelta(days=7)), + "book": self.book.title, + } + url = reverse("borrowings:borrowings-list") + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_list_borrowings_for_user(self): + """Test retrieving the list of borrowings for the authenticated user.""" + borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + url = reverse("borrowings:borrowings-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["id"], borrowing.id) + + def test_list_borrowings_for_staff(self): + """Test retrieving the list of borrowings for an admin user.""" + self.client.force_authenticate(user=self.staff_user) + Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + url = reverse("borrowings:borrowings-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_return_book(self): + """Test returning a borrowed book.""" + self.book.inventory = 5 + self.book.save() + + borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + response = self.client.post(f"/api/borrowings/{borrowing.id}/return/") + borrowing.refresh_from_db() + self.book.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIsNotNone(borrowing.actual_return_date) + self.assertEqual(self.book.inventory, 6) + + def test_return_book_already_returned(self): + """Test trying to return a book that has already been returned.""" + borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + actual_return_date=now(), + ) + response = self.client.post(f"/api/borrowings/{borrowing.id}/return/") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data[0], "This borrowing has already been returned.") + + def test_permission_denied_for_non_authenticated_user(self): + """Test that non-authenticated users are denied access.""" + self.client.logout() + url = reverse("borrowings:borrowings-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/borrowing/tests/test_borrowing_models.py b/borrowing/tests/test_borrowing_models.py new file mode 100644 index 0000000..f12b816 --- /dev/null +++ b/borrowing/tests/test_borrowing_models.py @@ -0,0 +1,57 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils.timezone import now +from datetime import timedelta +from borrowing.models import Borrowing, Book + +User = get_user_model() + +class BorrowingModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="test@user.com", + password="password123" + ) + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + inventory=1, + daily_fee=1 + ) + self.expected_return_date = now().date() + timedelta(days=7) + self.borrowing = Borrowing.objects.create( + expected_return_date=self.expected_return_date, + book=self.book, + user=self.user, + ) + + def test_borrowing_creation(self): + """Test if Borrowing object is created properly.""" + self.assertEqual(Borrowing.objects.count(), 1) + borrowing = Borrowing.objects.first() + self.assertEqual(borrowing.user, self.user) + self.assertEqual(borrowing.book, self.book) + self.assertEqual(borrowing.expected_return_date, self.expected_return_date) + self.assertIsNone(borrowing.actual_return_date) + self.assertEqual(borrowing.borrow_date, now().date()) + + def test_str_method(self): + """Test the __str__ method of Borrowing.""" + borrowing = Borrowing.objects.first() + self.assertEqual(str(borrowing), str(borrowing.borrow_date)) + + def test_book_relation(self): + """Test the ForeignKey relation with Book.""" + self.assertEqual(self.book.borrowings.count(), 1) + self.assertIn(self.borrowing, self.book.borrowings.all()) + + def test_user_relation(self): + """Test the ForeignKey relation with User.""" + self.assertEqual(self.user.borrowings.count(), 1) + self.assertIn(self.borrowing, self.user.borrowings.all()) + + def test_actual_return_date_blank(self): + """Test that actual_return_date can be blank.""" + self.borrowing.actual_return_date = now().date() + self.borrowing.save() + self.assertEqual(self.borrowing.actual_return_date, now().date()) diff --git a/borrowing/tests/test_borrowing_serializers.py b/borrowing/tests/test_borrowing_serializers.py new file mode 100644 index 0000000..f81e450 --- /dev/null +++ b/borrowing/tests/test_borrowing_serializers.py @@ -0,0 +1,123 @@ +from unittest.mock import MagicMock + +from rest_framework.test import APITestCase +from rest_framework.exceptions import ValidationError +from datetime import timedelta +from django.utils.timezone import now +from django.contrib.auth import get_user_model + +from book.models import Book +from borrowing.models import Borrowing +from borrowing.serializers import ( + BorrowingSerializer, + BorrowingReturnBookSerializer, + BorrowingListSerializer, + BorrowingDetailSerializer, +) + +User = get_user_model() + + +class BorrowingSerializerTest(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="test@user.com", + password="password123" + ) + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + inventory=1, + daily_fee=1.0 + ) + self.context = {"request": self.client} + self.client.force_authenticate(user=self.user) + + def test_borrowing_serializer_validation_no_active_borrowings(self): + """Ensure validation passes when there are no active borrowings.""" + data = { + "expected_return_date": (now().date() + timedelta(days=7)), + "book": self.book.title, + } + + mock_request = MagicMock() + mock_request.user = self.user + + serializer = BorrowingSerializer(data=data, context={"request": mock_request}) + self.assertTrue(serializer.is_valid()) + + def test_borrowing_serializer_validation_with_active_borrowings(self): + """Ensure validation fails if the user has active borrowings.""" + Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + data = { + "expected_return_date": (now().date() + timedelta(days=14)), + "book": self.book.title, + } + + mock_request = MagicMock() + mock_request.user = self.user + + serializer = BorrowingSerializer(data=data, context={"request": mock_request}) + + with self.assertRaises(ValidationError): + serializer.is_valid(raise_exception=True) + + def test_borrowing_return_book_serializer(self): + """Test BorrowingReturnBookSerializer handles return_book field correctly.""" + data = {"return_book": True} + serializer = BorrowingReturnBookSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["return_book"], True) + + def test_borrowing_list_serializer(self): + """Test BorrowingListSerializer representation.""" + borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + serializer = BorrowingListSerializer(borrowing) + expected_data = { + "id": borrowing.id, + "borrow_date": str(borrowing.borrow_date), + "expected_return_date": str(borrowing.expected_return_date), + "book": self.book.title, + } + self.assertEqual(serializer.data, expected_data) + + def test_borrowing_list_serializer_excludes_actual_return_date(self): + """Test BorrowingListSerializer excludes actual_return_date when not set.""" + borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + serializer = BorrowingListSerializer(borrowing) + self.assertNotIn("actual_return_date", serializer.data) + + def test_borrowing_detail_serializer(self): + """Test BorrowingDetailSerializer with BookSerializer representation.""" + borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.book, + user=self.user, + ) + serializer = BorrowingDetailSerializer(borrowing) + expected_data = { + "id": borrowing.id, + "borrow_date": str(borrowing.borrow_date), + "expected_return_date": str(borrowing.expected_return_date), + "book": { + "id": self.book.id, + "title": self.book.title, + "author": self.book.author, + "cover": "SOFT", + "inventory": self.book.inventory, + "daily_fee": "1.00", + }, + } + self.assertEqual(serializer.data, expected_data) From 789e9f3b36fc06d26008872ddf31e6c37c0cda05 Mon Sep 17 00:00:00 2001 From: Vladyslav Bondarenko <128479458+VladyslavBon@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:54:14 +0200 Subject: [PATCH 7/7] feat: update test for detail borrowing serializer --- borrowing/tests/test_borrowing_serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/borrowing/tests/test_borrowing_serializers.py b/borrowing/tests/test_borrowing_serializers.py index f81e450..b37d915 100644 --- a/borrowing/tests/test_borrowing_serializers.py +++ b/borrowing/tests/test_borrowing_serializers.py @@ -118,6 +118,7 @@ def test_borrowing_detail_serializer(self): "cover": "SOFT", "inventory": self.book.inventory, "daily_fee": "1.00", + "unreturned_borrowings_count": 1 }, } self.assertEqual(serializer.data, expected_data)