From 54956636384169f1f1268124c0f15366989df826 Mon Sep 17 00:00:00 2001 From: dmytro Date: Fri, 3 Jan 2025 14:21:02 +0200 Subject: [PATCH 1/4] feat: add payment model --- .DS_Store | Bin 6148 -> 6148 bytes .github/workflows/test.yml | 3 +++ borrowing/urls.py | 3 +-- core/settings.py | 4 ++-- payment/models.py | 34 +++++++++++++++++++++++++++++++++- payment/serializers.py | 1 + payment/urls.py | 0 payment/views.py | 1 - 8 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 payment/serializers.py create mode 100644 payment/urls.py diff --git a/.DS_Store b/.DS_Store index 75620164d6ab6f1314683e2820c7ac6b737cacd4..49e8e4a3c54f57728dbf8b584685e91b0373ccb8 100644 GIT binary patch delta 93 zcmZoMXffEJ&cZlxvIdJG&{ tb%BbSSWVa%92poGyC&~pHD|my`3tK7NTnK^Hc({{n>ypf%~fn&f&e@a8~6YK delta 93 zcmZoMXffEJ&cf(8S%bxpasA{d7IQ|~$&*;r8M`O%VliT3{5km@i!o#RWF=NZprQ~~ tU7(^SRueWxSq27%d6W0Bnlm;|{=#YiQmMwK4OAJ#rq1ZNxr(hz5CGL>8YTb$ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d345bd1..5ab7f20 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,9 @@ jobs: - name: Run Ruff run: poetry run ruff check --output-format=github . + - name: make + run: poetry run ruff check --output-format=github . + - name: Run Tests env: SECRET_KEY: "j+(#!sag4%^ay+oanu&t-&3x@2$!+s%x4u!4%pser4o9)2!ua1" diff --git a/borrowing/urls.py b/borrowing/urls.py index 81f1d95..6d73855 100644 --- a/borrowing/urls.py +++ b/borrowing/urls.py @@ -1,4 +1,3 @@ -from django.urls import path, include from rest_framework import routers from borrowing.views import BorrowingViewSet @@ -6,6 +5,6 @@ router = routers.DefaultRouter() router.register("", BorrowingViewSet, basename="borrowings") -urlpatterns = [path("", include(router.urls))] +urlpatterns = router.urls app_name = "borrowing" diff --git a/core/settings.py b/core/settings.py index ed70445..4ed8773 100644 --- a/core/settings.py +++ b/core/settings.py @@ -118,8 +118,8 @@ } SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(hours=3), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ACCESS_TOKEN_LIFETIME": timedelta(days=10), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), "ROTATE_REFRESH_TOKENS": False, "AUTH_HEADER_NAME": "HTTP_AUTHORIZE", } diff --git a/payment/models.py b/payment/models.py index 35e0d64..69c5bae 100644 --- a/payment/models.py +++ b/payment/models.py @@ -1,2 +1,34 @@ +from django.db import models -# Create your models here. +from borrowing.models import Borrowing + + +class Payment(models.Model): + class Status(models.TextChoices): + PENDING = ("PENDING",) + PAID = ("PAID",) + + class Type(models.TextChoices): + PAYMENT = ("PAYMENT",) + FINE = ("FINE",) + + status = models.CharField( + max_length=7, + choices=Status, + default=Status.PENDING + ) + type = models.CharField( + max_length=7, + choices=Type, + default=Type.PAYMENT + ) + borrowing = models.ForeignKey(Borrowing, on_delete=models.CASCADE) + session_url = models.URLField(max_length=500, blank=True, null=True) + session_id = models.CharField(max_length=255, blank=True, null=True) + money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + ordering = ["-borrowing__borrow_date"] + + def __str__(self): + return f"{self.user.email} - {self.money_to_pay} USD - {self.status}" diff --git a/payment/serializers.py b/payment/serializers.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/payment/serializers.py @@ -0,0 +1 @@ + diff --git a/payment/urls.py b/payment/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/views.py b/payment/views.py index b8e4ee0..8b13789 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,2 +1 @@ -# Create your views here. From 1d494e9ab36c69c1794d6f6ba7dd79c9c4e305f0 Mon Sep 17 00:00:00 2001 From: dmytro Date: Fri, 3 Jan 2025 14:21:25 +0200 Subject: [PATCH 2/4] feat: add payment serializers --- payment/serializers.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/payment/serializers.py b/payment/serializers.py index 8b13789..88224db 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -1 +1,24 @@ +from rest_framework import serializers +from payment.models import Payment +from borrowing.serializers import BorrowingListSerializer + + +class PaymentSerializer(serializers.ModelSerializer): + + class Meta: + model = Payment + fields = ( + "id", + "status", + "type", + "borrowing", + "session_url", + "session_id", + "money_to_pay" + ) + read_only_fields = ("id",) + + +class PaymentListSerializer(PaymentSerializer): + borrowing = BorrowingListSerializer From 89065afd04c2b0755de0ad7b0af2013a605fcba5 Mon Sep 17 00:00:00 2001 From: dmytro Date: Fri, 3 Jan 2025 14:50:07 +0200 Subject: [PATCH 3/4] feat: add payment list retrieve viewset --- core/urls.py | 3 ++- payment/admin.py | 5 ++++- payment/models.py | 2 +- payment/serializers.py | 11 +++++++++-- payment/urls.py | 11 +++++++++++ payment/views.py | 27 +++++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/core/urls.py b/core/urls.py index d3e2588..6c3c024 100644 --- a/core/urls.py +++ b/core/urls.py @@ -26,7 +26,8 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/user/", include("user.urls", namespace="user")), - path("api/borrowings/", include("borrowing.urls", namespace="borrowings")), + path("api/borrowings/", include("borrowing.urls", namespace="borrowing")), + path("api/payments/", include("payment.urls", namespace="payment")), 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"), diff --git a/payment/admin.py b/payment/admin.py index b97a94f..2d3cff2 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -1,2 +1,5 @@ +from django.contrib import admin -# Register your models here. +from payment.models import Payment + +admin.site.register(Payment) diff --git a/payment/models.py b/payment/models.py index 69c5bae..22e8f73 100644 --- a/payment/models.py +++ b/payment/models.py @@ -31,4 +31,4 @@ class Meta: ordering = ["-borrowing__borrow_date"] def __str__(self): - return f"{self.user.email} - {self.money_to_pay} USD - {self.status}" + return f"{self.borrowing.user.email} - {self.money_to_pay} USD - {self.status}" diff --git a/payment/serializers.py b/payment/serializers.py index 88224db..e8792ca 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -1,7 +1,10 @@ from rest_framework import serializers from payment.models import Payment -from borrowing.serializers import BorrowingListSerializer +from borrowing.serializers import ( + BorrowingListSerializer, + BorrowingDetailSerializer +) class PaymentSerializer(serializers.ModelSerializer): @@ -21,4 +24,8 @@ class Meta: class PaymentListSerializer(PaymentSerializer): - borrowing = BorrowingListSerializer + borrowing = BorrowingListSerializer() + + +class PaymentDetailSerializer(PaymentListSerializer): + borrowing = BorrowingDetailSerializer() diff --git a/payment/urls.py b/payment/urls.py index e69de29..1736b82 100644 --- a/payment/urls.py +++ b/payment/urls.py @@ -0,0 +1,11 @@ +from rest_framework import routers + +from payment.views import PaymentListCreateView + + +router = routers.DefaultRouter() +router.register("", PaymentListCreateView, basename="payments") + +urlpatterns = router.urls + +app_name = "payment" diff --git a/payment/views.py b/payment/views.py index 8b13789..f910eda 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1 +1,28 @@ +from rest_framework import viewsets, mixins +from payment.models import Payment +from payment.serializers import ( + PaymentSerializer, + PaymentListSerializer, + PaymentDetailSerializer +) + + +class PaymentListCreateView( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + + def get_queryset(self): + queryset = Payment.objects.select_related("borrowing") + if self.request.user.is_staff: + return queryset + return queryset.filter(borrowing__user__id=self.request.user.id) + + def get_serializer_class(self): + if self.action == "list": + return PaymentListSerializer + if self.action == "retrieve": + return PaymentDetailSerializer + return PaymentSerializer From 2e7611193327c2e68eec2f1c2a7bb8fd87ad957c Mon Sep 17 00:00:00 2001 From: dmytro Date: Fri, 3 Jan 2025 16:54:25 +0200 Subject: [PATCH 4/4] feat: added tests --- borrowing/tests/test_borrowing_api.py | 10 +- borrowing/views.py | 6 +- core/settings.py | 5 + core/urls.py | 3 +- payment/apps.py | 16 ++- payment/migrations/0001_initial.py | 67 ++++++++++ payment/models.py | 50 ++++++-- payment/serializers.py | 48 ++++++- payment/tests.py | 2 - payment/tests/__init__.py | 0 payment/tests/test_payment_api.py | 177 ++++++++++++++++++++++++++ payment/views.py | 51 +++++++- poetry.lock | 17 ++- pyproject.toml | 1 + 14 files changed, 428 insertions(+), 25 deletions(-) create mode 100644 payment/migrations/0001_initial.py delete mode 100644 payment/tests.py create mode 100644 payment/tests/__init__.py create mode 100644 payment/tests/test_payment_api.py diff --git a/borrowing/tests/test_borrowing_api.py b/borrowing/tests/test_borrowing_api.py index 508acd8..e23417b 100644 --- a/borrowing/tests/test_borrowing_api.py +++ b/borrowing/tests/test_borrowing_api.py @@ -23,7 +23,7 @@ def test_create_borrowing(self): "expected_return_date": (now().date() + timedelta(days=7)), "book": self.book.title, } - url = reverse("borrowings:borrowings-list") + url = reverse("borrowing: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) @@ -38,7 +38,7 @@ def test_create_borrowing_no_inventory(self): "expected_return_date": (now().date() + timedelta(days=7)), "book": self.book.title, } - url = reverse("borrowings:borrowings-list") + url = reverse("borrowing:borrowings-list") response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -50,7 +50,7 @@ def test_list_borrowings_for_user(self): book=self.book, user=self.user, ) - url = reverse("borrowings:borrowings-list") + url = reverse("borrowing:borrowings-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) @@ -64,7 +64,7 @@ def test_list_borrowings_for_staff(self): book=self.book, user=self.user, ) - url = reverse("borrowings:borrowings-list") + url = reverse("borrowing:borrowings-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) @@ -101,6 +101,6 @@ def test_return_book_already_returned(self): 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") + url = reverse("borrowing:borrowings-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/borrowing/views.py b/borrowing/views.py index 956dc1f..93391a4 100644 --- a/borrowing/views.py +++ b/borrowing/views.py @@ -27,15 +27,15 @@ class BorrowingViewSet( 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): + queryset = Borrowing.objects.select_related("book", "user") 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") + return queryset.order_by("actual_return_date") + return queryset.filter(user=self.request.user).order_by("actual_return_date") def get_serializer_class(self): if self.action == "list": diff --git a/core/settings.py b/core/settings.py index 4ed8773..0ab2b57 100644 --- a/core/settings.py +++ b/core/settings.py @@ -48,6 +48,7 @@ "rest_framework_simplejwt", "drf_spectacular", "django_filters", + "debug_toolbar", # custom apps "book", @@ -59,6 +60,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -149,6 +151,9 @@ }, ] +INTERNAL_IPS = [ + "127.0.0.1", +] # Internationalization # https://docs.djangoproject.com/en/5.1/topics/i18n/ diff --git a/core/urls.py b/core/urls.py index 6c3c024..aa42d86 100644 --- a/core/urls.py +++ b/core/urls.py @@ -22,6 +22,7 @@ SpectacularRedocView, SpectacularSwaggerView ) +from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ path("admin/", admin.site.urls), @@ -32,4 +33,4 @@ 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"), path("api/books/", include("book.urls", namespace="book")), -] +] + debug_toolbar_urls() diff --git a/payment/apps.py b/payment/apps.py index b4a45c3..6300a78 100644 --- a/payment/apps.py +++ b/payment/apps.py @@ -2,5 +2,17 @@ class PaymentConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'payment' + """ + Configuration class for the Payment app. + + This class sets the default auto field type for models in the Payment app + and specifies the app's name for Django to recognize it. + + Attributes: + default_auto_field (str): Specifies the type of primary key to use by default + for models that do not explicitly define one. + name (str): The name of the app, used by Django to locate and reference it. + """ + + default_auto_field = "django.db.models.BigAutoField" + name = "payment" diff --git a/payment/migrations/0001_initial.py b/payment/migrations/0001_initial.py new file mode 100644 index 0000000..9782134 --- /dev/null +++ b/payment/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 5.1.4 on 2025-01-03 12:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("borrowing", "0002_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Payment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[("PENDING", "Pending"), ("PAID", "Paid")], + default="PENDING", + max_length=7, + ), + ), + ( + "type", + models.CharField( + choices=[("PAYMENT", "Payment"), ("FINE", "Fine")], + default="PAYMENT", + max_length=7, + ), + ), + ( + "session_url", + models.URLField(blank=True, max_length=500, null=True), + ), + ( + "session_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "money_to_pay", + models.DecimalField(decimal_places=2, max_digits=10), + ), + ( + "borrowing", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="borrowing.borrowing", + ), + ), + ], + options={ + "ordering": ["-borrowing__borrow_date"], + }, + ), + ] diff --git a/payment/models.py b/payment/models.py index 22e8f73..55fff2a 100644 --- a/payment/models.py +++ b/payment/models.py @@ -4,24 +4,58 @@ class Payment(models.Model): + """ + Represents a payment or fine associated with a borrowing transaction. + + This model tracks the status and type of payments, linking them to specific + borrowings. Payments can be marked as pending or paid, and can either represent + a standard payment or a fine. + + Attributes: + status (str): The current status of the payment (PENDING or PAID). + type (str): The type of payment (PAYMENT or FINE). + borrowing (Borrowing): The borrowing record this payment is associated with. + session_url (str, optional): URL for the payment session (e.g., Stripe session). + session_id (str, optional): Identifier for the payment session. + money_to_pay (Decimal): The amount to be paid or fined, with up to 10 digits + and 2 decimal places. + + Meta: + ordering (list): Orders the payments by the borrowing date in descending order. + + Methods: + __str__(): Returns a human-readable string representation of the payment, + showing the user's email, amount, and payment status. + """ + class Status(models.TextChoices): + """ + Enum-like class for defining possible statuses of a payment. + + Attributes: + PENDING (str): Payment is pending. + PAID (str): Payment is completed. + """ + PENDING = ("PENDING",) PAID = ("PAID",) class Type(models.TextChoices): + """ + Enum-like class for defining possible types of payments. + + Attributes: + PAYMENT (str): Standard payment for borrowing. + FINE (str): Fine for late return or damage. + """ + PAYMENT = ("PAYMENT",) FINE = ("FINE",) status = models.CharField( - max_length=7, - choices=Status, - default=Status.PENDING - ) - type = models.CharField( - max_length=7, - choices=Type, - default=Type.PAYMENT + max_length=7, choices=Status, default=Status.PENDING ) + type = models.CharField(max_length=7, choices=Type, default=Type.PAYMENT) borrowing = models.ForeignKey(Borrowing, on_delete=models.CASCADE) session_url = models.URLField(max_length=500, blank=True, null=True) session_id = models.CharField(max_length=255, blank=True, null=True) diff --git a/payment/serializers.py b/payment/serializers.py index e8792ca..4cf2fef 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -3,11 +3,31 @@ from payment.models import Payment from borrowing.serializers import ( BorrowingListSerializer, - BorrowingDetailSerializer + BorrowingDetailSerializer, ) class PaymentSerializer(serializers.ModelSerializer): + """ + Serializer for the Payment model. + + This serializer handles the basic serialization and deserialization + of Payment instances, allowing for easy conversion between complex + Payment model instances and native Python datatypes (e.g., JSON). + + Meta: + model (Payment): The model that this serializer is based on. + fields (tuple): The fields to include in the serialized output. + - id (int): Unique identifier for the payment. + - status (str): Current status of the payment (PENDING or PAID). + - type (str): The type of payment (PAYMENT or FINE). + - borrowing (int): The ID of the related borrowing record. + - session_url (str): URL to the payment session. + - session_id (str): Payment session identifier. + - money_to_pay (Decimal): Amount to be paid. + read_only_fields (tuple): Fields that cannot be modified through this serializer. + - id: The primary key is read-only and automatically generated. + """ class Meta: model = Payment @@ -18,14 +38,38 @@ class Meta: "borrowing", "session_url", "session_id", - "money_to_pay" + "money_to_pay", ) read_only_fields = ("id",) class PaymentListSerializer(PaymentSerializer): + """ + Serializer for listing Payment records. + + This serializer extends PaymentSerializer by replacing the borrowing field + with a nested BorrowingListSerializer to provide additional details about + the borrowing record in list views. + + Attributes: + borrowing (BorrowingListSerializer): Provides summarized borrowing data + instead of just the ID. + """ + borrowing = BorrowingListSerializer() class PaymentDetailSerializer(PaymentListSerializer): + """ + Serializer for detailed view of a Payment record. + + This serializer extends PaymentListSerializer by using BorrowingDetailSerializer + for the borrowing field, providing a more in-depth view of the related borrowing + record. + + Attributes: + borrowing (BorrowingDetailSerializer): Provides detailed borrowing data + for a specific payment. + """ + borrowing = BorrowingDetailSerializer() diff --git a/payment/tests.py b/payment/tests.py deleted file mode 100644 index 4929020..0000000 --- a/payment/tests.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Create your tests here. diff --git a/payment/tests/__init__.py b/payment/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/tests/test_payment_api.py b/payment/tests/test_payment_api.py new file mode 100644 index 0000000..1070d84 --- /dev/null +++ b/payment/tests/test_payment_api.py @@ -0,0 +1,177 @@ +from datetime import timedelta + +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.utils.timezone import now +from rest_framework.test import APITestCase +from rest_framework import status + +from book.models import Book +from borrowing.models import Borrowing +from payment.models import Payment +from payment.serializers import PaymentListSerializer, PaymentDetailSerializer + + +PAYMENT_URL = reverse("payment:payments-list") + + +def get_retrieve_payment_url(payment_id: int): + """ + Generate URL for retrieving a specific payment by ID. + + Args: + payment_id (int): ID of the payment to retrieve. + + Returns: + str: URL to access the payment detail endpoint. + """ + return reverse("payment:payments-detail", args=(payment_id,)) + + +class UnauthenticatedPaymentApiTest(APITestCase): + """ + Test suite for unauthenticated access to the payment API. + """ + + def test_auth_required(self): + """ + Ensure authentication is required to access the payment API. + """ + response = self.client.get(PAYMENT_URL) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class AuthenticatedPaymentApiTest(APITestCase): + """ + Test suite for authenticated user interactions with the payment API. + """ + + def setUp(self): + """ + Set up initial data for tests, including users, books, + borrowings, and payments. + """ + self.user = get_user_model().objects.create_user( + email="test@test.com", password="test1234" + ) + self.second_user = get_user_model().objects.create_user( + email="secon@mail.com", password="second1234" + ) + self.first_book = Book.objects.create( + title="first_test_book", + author="first_test_author", + cover="SOFT", + inventory=7, + daily_fee=3, + ) + self.second_book = Book.objects.create( + title="second_test_book", + author="second_test_author", + cover="HARD", + inventory=7, + daily_fee=3, + ) + self.borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.first_book, + user=self.user, + ) + self.second_borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=10), + book=self.second_book, + user=self.second_user, + ) + self.first_payment = Payment.objects.create( + borrowing=self.borrowing, money_to_pay=10 + ) + self.second_payment = Payment.objects.create( + borrowing=self.second_borrowing, money_to_pay=15 + ) + self.client.force_authenticate(self.user) + + def test_list_payment(self): + """ + Test that authenticated users can retrieve a list of their own payments. + """ + response = self.client.get(PAYMENT_URL) + payments = Payment.objects.filter(borrowing__user=self.user) + serializer = PaymentListSerializer(payments, many=True) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, serializer.data) + + def test_retrieve_payment(self): + """ + Test that authenticated users can retrieve the details of their specific payments. + """ + url = get_retrieve_payment_url(self.first_payment.id) + response = self.client.get(url) + serializer = PaymentDetailSerializer(self.first_payment) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, serializer.data) + + def test_retrieve_not_your_payment_rejected(self): + """ + Test that users cannot retrieve payments that do not belong to them. + """ + url = get_retrieve_payment_url(self.second_payment.id) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class AdminPaymentApiTest(APITestCase): + """ + Test suite for admin interactions with the payment API. + """ + + def setUp(self): + """ + Set up data for admin tests, including admin and regular users, + books, borrowings, and payments. + """ + self.admin = get_user_model().objects.create_superuser( + email="admin@test.com", password="test1234" + ) + self.user = get_user_model().objects.create_user( + email="test@test.com", password="test1234" + ) + self.first_book = Book.objects.create( + title="first_test_book", + author="first_test_author", + cover="SOFT", + inventory=7, + daily_fee=3, + ) + self.second_book = Book.objects.create( + title="second_test_book", + author="second_test_author", + cover="HARD", + inventory=7, + daily_fee=3, + ) + self.borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=7), + book=self.first_book, + user=self.admin, + ) + self.second_borrowing = Borrowing.objects.create( + expected_return_date=now().date() + timedelta(days=10), + book=self.second_book, + user=self.user, + ) + self.first_payment = Payment.objects.create( + borrowing=self.borrowing, money_to_pay=10 + ) + self.second_payment = Payment.objects.create( + borrowing=self.second_borrowing, money_to_pay=15 + ) + self.client.force_authenticate(self.admin) + + def test_all_payments_allowed(self): + """ + Test that admin users can retrieve all payments in the system. + """ + payments = Payment.objects.all() + response = self.client.get(PAYMENT_URL) + serializer = PaymentListSerializer(payments, many=True) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, serializer.data) diff --git a/payment/views.py b/payment/views.py index f910eda..c18235b 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,10 +1,11 @@ from rest_framework import viewsets, mixins +from rest_framework.permissions import IsAuthenticated from payment.models import Payment from payment.serializers import ( PaymentSerializer, PaymentListSerializer, - PaymentDetailSerializer + PaymentDetailSerializer, ) @@ -13,14 +14,62 @@ class PaymentListCreateView( mixins.RetrieveModelMixin, viewsets.GenericViewSet, ): + """ + API view for listing and retrieving Payment records. + + This view provides endpoints for listing all payments and retrieving + individual payment records. It supports nested borrowing data in the + response, with different serializers based on the type of action performed + (list or retrieve). + + Permissions: + - Requires authentication for all actions. + - Admin users can view all payments. + - Regular users can only view their own payments. + + Attributes: + permission_classes (list): Restricts access to authenticated users only. + + Methods: + get_queryset(): + Returns the queryset of Payment objects. + Admin users receive all payments, while regular users receive + only payments associated with their borrowing records. + + get_serializer_class(): + Returns the appropriate serializer class based on the action being + performed. + - list: Uses PaymentListSerializer to provide summarized borrowing data. + - retrieve: Uses PaymentDetailSerializer for detailed borrowing data. + - default: Falls back to PaymentSerializer for basic CRUD operations. + """ + + permission_classes = [IsAuthenticated] def get_queryset(self): + """ + Retrieve the appropriate queryset for the user. + + Returns: + QuerySet: A queryset of Payment objects. Admin users receive all + payments, while regular users only receive payments + linked to their own borrowing records. + """ queryset = Payment.objects.select_related("borrowing") if self.request.user.is_staff: return queryset return queryset.filter(borrowing__user__id=self.request.user.id) def get_serializer_class(self): + """ + Return the appropriate serializer based on the action. + + Returns: + Serializer: + - PaymentListSerializer for listing payments. + - PaymentDetailSerializer for retrieving specific payments. + - PaymentSerializer for other actions by default. + """ if self.action == "list": return PaymentListSerializer if self.action == "retrieve": diff --git a/poetry.lock b/poetry.lock index 2cd8265..6236f62 100644 --- a/poetry.lock +++ b/poetry.lock @@ -53,6 +53,21 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-debug-toolbar" +version = "4.4.6" +description = "A configurable set of panels that display various debug information about the current request/response." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"}, + {file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"}, +] + +[package.dependencies] +django = ">=4.2.9" +sqlparse = ">=0.2" + [[package]] name = "django-filter" version = "24.3" @@ -537,4 +552,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12.8" -content-hash = "42f1e8dbb5c88e2669cf5a00e2600f4cb38bc311523375a99ed13773e41a0bff" +content-hash = "cecf17797f30c8a6f25ec9a3b5ad688bfebbf8a6d5839f8cb87057131b26c1dd" diff --git a/pyproject.toml b/pyproject.toml index f26fa77..2408566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ drf-spectacular = "^0.28.0" psycopg2-binary = "^2.9.10" djangorestframework-simplejwt = "^5.3.1" django-filter = "^24.3" +django-debug-toolbar = "^4.4.6" [build-system] requires = ["poetry-core"]