diff --git a/.env.sample b/.env.sample index 91da3f6..4ba9211 100644 --- a/.env.sample +++ b/.env.sample @@ -4,4 +4,6 @@ POSTGRES_PASSWORD= POSTGRES_USER= POSTGRES_DB= POSTGRES_HOST= -POSTGRES_PORT= \ No newline at end of file +POSTGRES_PORT= +STRIPE_SECRET_KEY= +STRIPE_PUBLIC_KEY= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5ab7f20..e4bb32f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,5 +37,7 @@ jobs: env: SECRET_KEY: "j+(#!sag4%^ay+oanu&t-&3x@2$!+s%x4u!4%pser4o9)2!ua1" ENVIRONMENT: "local" + STRIPE_SECRET_KEY: "fail" + STRIPE_PUBLIC_KEY: "fail" timeout-minutes: 5 run: poetry run python manage.py test diff --git a/book/permissions.py b/book/permissions.py index 6680917..cb036f8 100644 --- a/book/permissions.py +++ b/book/permissions.py @@ -1,7 +1,4 @@ -from rest_framework.permissions import ( - SAFE_METHODS, - BasePermission -) +from rest_framework.permissions import SAFE_METHODS, BasePermission class IsAdminOrReadOnly(BasePermission): diff --git a/book/tests/test_book_view_set.py b/book/tests/test_book_view_set.py index 5f5f303..93a17d7 100644 --- a/book/tests/test_book_view_set.py +++ b/book/tests/test_book_view_set.py @@ -5,6 +5,7 @@ User = get_user_model() + class BookViewSetTest(APITestCase): def setUp(self): """Set up test data before running tests""" @@ -18,14 +19,12 @@ def setUp(self): # Create a superuser using the custom user model self.superuser = User.objects.create_superuser( - email="admin@example.com", - password="admin123" + email="admin@example.com", password="admin123" ) # Create a regular user using the custom user model self.user = User.objects.create_user( - email="user@example.com", - password="user123" + email="user@example.com", password="user123" ) def test_list_books_unauthenticated(self): @@ -76,7 +75,9 @@ def test_update_book_as_superuser(self): "cover": self.book.cover, "daily_fee": self.book.daily_fee, } - response = self.client.put(f"/api/books/{self.book.id}/", data, format="json") + response = self.client.put( + f"/api/books/{self.book.id}/", data, format="json" + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.book.refresh_from_db() self.assertEqual(self.book.inventory, 10) @@ -91,7 +92,9 @@ def test_update_book_as_regular_user(self): "cover": self.book.cover, "daily_fee": self.book.daily_fee, } - response = self.client.put(f"/api/books/{self.book.id}/", data, format="json") + response = self.client.put( + f"/api/books/{self.book.id}/", data, format="json" + ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_delete_book_as_superuser(self): diff --git a/book/views.py b/book/views.py index b178aa1..8b2990e 100644 --- a/book/views.py +++ b/book/views.py @@ -14,4 +14,6 @@ class BookViewSet(viewsets.ModelViewSet): queryset = Book.objects.prefetch_related("borrowings") serializer_class = BookSerializer - permission_classes = [IsAdminOrReadOnly,] + permission_classes = [ + IsAdminOrReadOnly, + ] diff --git a/borrowing/filters.py b/borrowing/filters.py index 1aac12d..ef875ad 100644 --- a/borrowing/filters.py +++ b/borrowing/filters.py @@ -7,7 +7,10 @@ class CustomFilter(filters.FilterSet): """ FilterSet for CustomFilter """ - is_active = filters.BooleanFilter(method="filter_is_active", label="Active") + + is_active = filters.BooleanFilter( + method="filter_is_active", label="Active" + ) user_id = filters.NumberFilter(method="filter_user_id") class Meta: @@ -27,4 +30,4 @@ def filter_user_id(self, queryset, name, value): return queryset.filter(user_id=value) return queryset else: - return queryset.filter(user=request.user) \ No newline at end of file + return queryset.filter(user=request.user) diff --git a/borrowing/migrations/0001_initial.py b/borrowing/migrations/0001_initial.py index e3a3603..cd0e402 100644 --- a/borrowing/migrations/0001_initial.py +++ b/borrowing/migrations/0001_initial.py @@ -5,22 +5,39 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('book', '0001_initial'), + ("book", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Borrowing', + name="Borrowing", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('borrow_date', models.DateField(auto_now_add=True)), - ('expected_return_date', models.DateField()), - ('actual_return_date', models.DateField(blank=True, null=True)), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='borrowings', to='book.book')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("borrow_date", models.DateField(auto_now_add=True)), + ("expected_return_date", models.DateField()), + ( + "actual_return_date", + models.DateField(blank=True, null=True), + ), + ( + "book", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="borrowings", + to="book.book", + ), + ), ], ), ] diff --git a/borrowing/migrations/0002_initial.py b/borrowing/migrations/0002_initial.py index c95baa8..e249fc0 100644 --- a/borrowing/migrations/0002_initial.py +++ b/borrowing/migrations/0002_initial.py @@ -6,18 +6,21 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('borrowing', '0001_initial'), + ("borrowing", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='borrowing', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='borrowings', to=settings.AUTH_USER_MODEL), + model_name="borrowing", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="borrowings", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/borrowing/models.py b/borrowing/models.py index 4376a2d..9e79c0f 100644 --- a/borrowing/models.py +++ b/borrowing/models.py @@ -9,11 +9,18 @@ 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") + 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) diff --git a/borrowing/serializers.py b/borrowing/serializers.py index dc78b62..1934ea9 100644 --- a/borrowing/serializers.py +++ b/borrowing/serializers.py @@ -3,30 +3,26 @@ from book.models import Book from book.serializers import BookSerializer from borrowing.models import Borrowing +from payment.models import Payment class BorrowingSerializer(serializers.ModelSerializer): """ Borrowing Serializer with validation for only one active borrowing. """ + book = serializers.SlugRelatedField( - queryset=Book.objects.all(), - slug_field="title" + queryset=Book.objects.all(), slug_field="title" ) class Meta: model = Borrowing - fields = [ - "id", - "expected_return_date", - "book" - ] + 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 + user=user, actual_return_date__isnull=True ) if active_borrowings.exists(): @@ -39,8 +35,9 @@ def validate(self, attrs): class BorrowingReturnBookSerializer(serializers.ModelSerializer): """ - Borrowing Serializer for returning a borrowing book. + Borrowing Serializer for returning a borrowing book. """ + return_book = serializers.BooleanField() class Meta: @@ -48,13 +45,27 @@ class Meta: fields = ["return_book"] +class PaymentInfoSerializer(serializers.ModelSerializer): + class Meta: + model = Payment + fields = ( + "id", + "status", + "type", + "money_to_pay", + ) + read_only_fields = fields + + class BorrowingListSerializer(serializers.ModelSerializer): """ Borrowing Serializer for borrowing list. """ + book = serializers.SlugRelatedField( many=False, read_only=True, slug_field="title" ) + payments = PaymentInfoSerializer(many=True, read_only=True) class Meta: model = Borrowing @@ -64,6 +75,7 @@ class Meta: "expected_return_date", "actual_return_date", "book", + "payments", ] def to_representation(self, instance): @@ -77,6 +89,7 @@ class BorrowingDetailSerializer(serializers.ModelSerializer): """ Borrowing Serializer for detail of a borrowing book. """ + book = BookSerializer(many=False, read_only=True) class Meta: diff --git a/borrowing/tests/test_borrowing_api.py b/borrowing/tests/test_borrowing_api.py index e23417b..634a5c7 100644 --- a/borrowing/tests/test_borrowing_api.py +++ b/borrowing/tests/test_borrowing_api.py @@ -10,25 +10,20 @@ 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.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("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) - 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 @@ -96,7 +91,9 @@ def test_return_book_already_returned(self): ) 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.") + 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.""" diff --git a/borrowing/tests/test_borrowing_models.py b/borrowing/tests/test_borrowing_models.py index f12b816..bd019d3 100644 --- a/borrowing/tests/test_borrowing_models.py +++ b/borrowing/tests/test_borrowing_models.py @@ -6,17 +6,14 @@ User = get_user_model() + class BorrowingModelTest(TestCase): def setUp(self): self.user = User.objects.create_user( - email="test@user.com", - password="password123" + email="test@user.com", password="password123" ) self.book = Book.objects.create( - title="Test Book", - author="Test Author", - inventory=1, - daily_fee=1 + 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( @@ -31,7 +28,9 @@ def test_borrowing_creation(self): 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.assertEqual( + borrowing.expected_return_date, self.expected_return_date + ) self.assertIsNone(borrowing.actual_return_date) self.assertEqual(borrowing.borrow_date, now().date()) diff --git a/borrowing/tests/test_borrowing_serializers.py b/borrowing/tests/test_borrowing_serializers.py index b37d915..d7c2e43 100644 --- a/borrowing/tests/test_borrowing_serializers.py +++ b/borrowing/tests/test_borrowing_serializers.py @@ -21,14 +21,10 @@ class BorrowingSerializerTest(APITestCase): def setUp(self): self.user = User.objects.create_user( - email="test@user.com", - password="password123" + email="test@user.com", password="password123" ) self.book = Book.objects.create( - title="Test Book", - author="Test Author", - inventory=1, - daily_fee=1.0 + title="Test Book", author="Test Author", inventory=1, daily_fee=1.0 ) self.context = {"request": self.client} self.client.force_authenticate(user=self.user) @@ -43,7 +39,9 @@ def test_borrowing_serializer_validation_no_active_borrowings(self): mock_request = MagicMock() mock_request.user = self.user - serializer = BorrowingSerializer(data=data, context={"request": mock_request}) + serializer = BorrowingSerializer( + data=data, context={"request": mock_request} + ) self.assertTrue(serializer.is_valid()) def test_borrowing_serializer_validation_with_active_borrowings(self): @@ -61,7 +59,9 @@ def test_borrowing_serializer_validation_with_active_borrowings(self): mock_request = MagicMock() mock_request.user = self.user - serializer = BorrowingSerializer(data=data, context={"request": mock_request}) + serializer = BorrowingSerializer( + data=data, context={"request": mock_request} + ) with self.assertRaises(ValidationError): serializer.is_valid(raise_exception=True) @@ -86,6 +86,7 @@ def test_borrowing_list_serializer(self): "borrow_date": str(borrowing.borrow_date), "expected_return_date": str(borrowing.expected_return_date), "book": self.book.title, + "payments": [] } self.assertEqual(serializer.data, expected_data) @@ -118,7 +119,7 @@ def test_borrowing_detail_serializer(self): "cover": "SOFT", "inventory": self.book.inventory, "daily_fee": "1.00", - "unreturned_borrowings_count": 1 + "unreturned_borrowings_count": 1, }, } self.assertEqual(serializer.data, expected_data) diff --git a/borrowing/views.py b/borrowing/views.py index 93391a4..d2a1922 100644 --- a/borrowing/views.py +++ b/borrowing/views.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime from django.http import HttpResponseRedirect from rest_framework import viewsets, mixins, status @@ -6,6 +6,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from django_filters.rest_framework import DjangoFilterBackend +from django.db import transaction from borrowing.filters import CustomFilter from borrowing.models import Borrowing @@ -13,20 +14,23 @@ BorrowingSerializer, BorrowingDetailSerializer, BorrowingListSerializer, - BorrowingReturnBookSerializer + BorrowingReturnBookSerializer, ) +from payment.models import Payment +from payment.service import create_stripe_session class BorrowingViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, - viewsets.GenericViewSet + viewsets.GenericViewSet, ): """ Viewset for borrowing related objects. Provides actions: list, create, retrieve. """ + permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend] filterset_class = CustomFilter @@ -35,7 +39,9 @@ def get_queryset(self): queryset = Borrowing.objects.select_related("book", "user") if self.request.user.is_staff: return queryset.order_by("actual_return_date") - return queryset.filter(user=self.request.user).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": @@ -47,16 +53,33 @@ def get_serializer_class(self): return BorrowingSerializer - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) book = serializer.validated_data["book"] + expected_return_date = serializer.validated_data[ + "expected_return_date" + ] + + if datetime.date.today() > expected_return_date: + raise ValidationError("No valid expected return date") + if book.inventory <= 0: raise ValidationError("No copies available in inventory.") book.inventory -= 1 book.save() - serializer.save(user=self.request.user) + borrowing = serializer.save(user=self.request.user) + + create_stripe_session(borrowing, request) + + payment = Payment.objects.get(borrowing=borrowing) + + return HttpResponseRedirect( + payment.session_url, status=status.HTTP_302_FOUND + ) @action( methods=["POST"], @@ -68,16 +91,32 @@ def return_book(self, request, pk=None): """ Additional post action to return a book. """ - borrowing = self.get_object() + with transaction.atomic(): + borrowing = self.get_object() - if borrowing.actual_return_date: - raise ValidationError("This borrowing has already been returned.") + if borrowing.actual_return_date: + raise ValidationError( + "This borrowing has already been returned." + ) - borrowing.actual_return_date = datetime.now() - borrowing.save() + borrowing.actual_return_date = datetime.date.today() + borrowing.save() - book = borrowing.book - book.inventory += 1 - book.save() + book = borrowing.book + book.inventory += 1 + book.save() + + response = create_stripe_session(borrowing, request) + + if response: + return HttpResponseRedirect( + "/api/borrowings/", status=status.HTTP_302_FOUND + ) + + payment = Payment.objects.filter(type="FINE", borrowing=borrowing)[ + 0 + ] - return HttpResponseRedirect("/api/borrowings/", status=status.HTTP_302_FOUND) + return HttpResponseRedirect( + payment.session_url, status=status.HTTP_302_FOUND + ) diff --git a/core/settings.py b/core/settings.py index 0ab2b57..8083026 100644 --- a/core/settings.py +++ b/core/settings.py @@ -9,6 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ + import os from datetime import timedelta from pathlib import Path @@ -42,20 +43,18 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # additional "rest_framework", "rest_framework_simplejwt", "drf_spectacular", "django_filters", "debug_toolbar", - # custom apps "book", "user", "borrowing", "payment", - "tg_bot" + "tg_bot", ] MIDDLEWARE = [ @@ -155,6 +154,10 @@ "127.0.0.1", ] +# Stripe +STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"] +STRIPE_PUBLIC_KEY = os.environ["STRIPE_PUBLIC_KEY"] + # Internationalization # https://docs.djangoproject.com/en/5.1/topics/i18n/ diff --git a/core/urls.py b/core/urls.py index aa42d86..c6dafb6 100644 --- a/core/urls.py +++ b/core/urls.py @@ -20,7 +20,7 @@ from drf_spectacular.views import ( SpectacularAPIView, SpectacularRedocView, - SpectacularSwaggerView + SpectacularSwaggerView, ) from debug_toolbar.toolbar import debug_toolbar_urls @@ -30,7 +30,15 @@ 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"), + 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/migrations/0002_alter_payment_borrowing.py b/payment/migrations/0002_alter_payment_borrowing.py new file mode 100644 index 0000000..0c91567 --- /dev/null +++ b/payment/migrations/0002_alter_payment_borrowing.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.4 on 2025-01-04 11:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("borrowing", "0002_initial"), + ("payment", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="payment", + name="borrowing", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="borrowing.borrowing", + ), + ), + ] diff --git a/payment/models.py b/payment/models.py index 55fff2a..9531e97 100644 --- a/payment/models.py +++ b/payment/models.py @@ -56,7 +56,9 @@ class Type(models.TextChoices): 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) + borrowing = models.ForeignKey( + Borrowing, on_delete=models.CASCADE, related_name="payments" + ) 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) diff --git a/payment/service.py b/payment/service.py new file mode 100644 index 0000000..dee3ba0 --- /dev/null +++ b/payment/service.py @@ -0,0 +1,116 @@ +import stripe +from django.conf import settings +from django.urls import reverse + +from borrowing.models import Borrowing +from payment.models import Payment + + +OVERDUE_COEFFICIENT = 2 +stripe.api_key = settings.STRIPE_SECRET_KEY + + +def calculate_money_to_pay(borrowing: Borrowing): + """ + Calculate the amount of money to be paid based + on the borrowing details. + + This function calculates the payment or fine + amount depending on the following conditions: + - If the book has not yet been returned + (i.e., `actual_return_date` is None), it calculates the fee + based on the number of days the book is expected to be borrowed. + - If the book is returned late, a fine is applied based on + the number of overdue days and the book's + daily fee. + - If the book is returned on time, no charge is applied. + + Args: + borrowing (Borrowing): The borrowing object containing + details of the book, borrowing dates, and fees. + + Returns: + tuple: A tuple containing: + - The calculated amount of money to be paid (float or int). + - A string indicating the type of payment + ("PAYMENT" for normal payments, "FINE" for fines, "ok" for no charge). + """ + if not borrowing.actual_return_date: + if borrowing.expected_return_date == borrowing.borrow_date: + result = (borrowing.book.daily_fee, "PAYMENT") + return result + + clean_days = ( + borrowing.expected_return_date - borrowing.borrow_date + ).days + count_of_money = clean_days * borrowing.book.daily_fee + result = (count_of_money, "PAYMENT") + return result + + if borrowing.actual_return_date > borrowing.expected_return_date: + overdue_days = ( + borrowing.actual_return_date - borrowing.expected_return_date + ).days + + count_of_money = overdue_days * borrowing.book.daily_fee * 2 + result = (count_of_money, "FINE") + return result + + result = (0, "ok") + return result + + +def create_stripe_session(borrowing: Borrowing, request): + """ + Creates a Stripe checkout session for the given borrowing. + + This function calculates the money to pay for the borrowing using the + `calculate_money_to_pay` function. If a payment or fine is applicable, it + creates a new `Payment` object, initializes a Stripe checkout session, + and returns the URL for the user to complete the payment. + + Args: + borrowing (Borrowing): The borrowing object containing + details of the book and payment information. + request (HttpRequest): The HTTP request object, used + to build absolute URLs for success and cancel URLs. + + Returns: + str: A URL where the user can complete the payment via Stripe, + or "ok" if no payment is required. + """ + money_to_pay, _type = calculate_money_to_pay(borrowing) + if _type == "ok": + return _type + payment = Payment.objects.create( + status="PENDING", + type=_type, + borrowing=borrowing, + money_to_pay=money_to_pay, + ) + session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[ + { + "price_data": { + "currency": "usd", + "product_data": { + "name": borrowing.book.title, + }, + "unit_amount": int(payment.money_to_pay * 100), + }, + "quantity": 1, + } + ], + mode="payment", + success_url=request.build_absolute_uri( + reverse("payment:payment-success") + f"?payment_id={payment.id}" + ), + cancel_url=request.build_absolute_uri( + reverse("payment:payment-cancel") + f"?payment_id={payment.id}" + ), + ) + + payment.session_url = session.url + payment.session_id = session.id + payment.save() diff --git a/payment/urls.py b/payment/urls.py index 1736b82..ec3384b 100644 --- a/payment/urls.py +++ b/payment/urls.py @@ -1,11 +1,20 @@ +from django.urls import path, include from rest_framework import routers -from payment.views import PaymentListCreateView +from payment.views import ( + PaymentListCreateView, + PaymentSuccessView, + PaymentCancelView, +) router = routers.DefaultRouter() router.register("", PaymentListCreateView, basename="payments") -urlpatterns = router.urls +urlpatterns = [ + path("success/", PaymentSuccessView.as_view(), name="payment-success"), + path("cancel/", PaymentCancelView.as_view(), name="payment-cancel"), + path("", include(router.urls)), +] app_name = "payment" diff --git a/payment/views.py b/payment/views.py index c18235b..cb6b5be 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,5 +1,12 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404 from rest_framework import viewsets, mixins +from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status + +import stripe from payment.models import Payment from payment.serializers import ( @@ -9,6 +16,9 @@ ) +stripe.api_key = settings.STRIPE_SECRET_KEY + + class PaymentListCreateView( mixins.ListModelMixin, mixins.RetrieveModelMixin, @@ -75,3 +85,81 @@ def get_serializer_class(self): if self.action == "retrieve": return PaymentDetailSerializer return PaymentSerializer + + +class PaymentSuccessView(APIView): + """ + View for handling successful payment responses from Stripe. + + This view processes the successful payment callback from Stripe, updates the + payment status to 'PAID', and returns the payment details including the + amount paid and the currency. + + Methods: + get: Handles the GET request for successful payment. Updates the payment status + and returns the payment details. + """ + + def get(self, request, *args, **kwargs): + try: + payment_id = request.query_params.get("payment_id") + payment = get_object_or_404(Payment, id=int(payment_id)) + session = stripe.checkout.Session.retrieve(payment.session_id) + + if payment.status != "PAID": + payment.status = "PAID" + payment.save() + + return Response( + { + "message": "Payment successful", + "amount_paid": session.amount_total / 100, + "currency": session.currency, + }, + status=status.HTTP_200_OK, + ) + + except stripe.error.StripeError as e: + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + +class PaymentCancelView(APIView): + """ + View for handling cancelled payment responses from Stripe. + + This view processes the cancelled payment callback from Stripe, updates the + payment status to 'PENDING', and provides a link to the Stripe session + for the user to retry the payment. + + Methods: + get: Handles the GET request for a cancelled payment. Updates the payment + status and returns a message indicating cancellation and a link for + retrying the payment. + """ + + def get(self, request, *args, **kwargs): + try: + payment_id = request.query_params.get("payment_id") + payment = get_object_or_404(Payment, id=int(payment_id)) + session = stripe.checkout.Session.retrieve(payment.session_id) + + if payment.status != "PENDING": + payment.status = "PENDING" + payment.save() + + return Response( + { + "message": "Payment was cancelled.You can pay the rent within 24 hours", + "pay": session.url, + "amount_paid": session.amount_total / 100, + "currency": session.currency, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + except stripe.error.StripeError as e: + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) diff --git a/poetry.lock b/poetry.lock index 6236f62..66947f7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,6 +33,118 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +[[package]] +name = "certifi" +version = "2024.12.14" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + [[package]] name = "django" version = "5.1.4" @@ -143,6 +255,20 @@ uritemplate = ">=2.0.0" offline = ["drf-spectacular-sidecar"] sidecar = ["drf-spectacular-sidecar"] +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "inflection" version = "0.5.1" @@ -373,6 +499,27 @@ files = [ attrs = ">=22.2.0" rpds-py = ">=0.7.0" +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "rpds-py" version = "0.22.3" @@ -527,6 +674,32 @@ files = [ dev = ["build", "hatch"] doc = ["sphinx"] +[[package]] +name = "stripe" +version = "11.4.1" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "stripe-11.4.1-py2.py3-none-any.whl", hash = "sha256:8aa47a241de0355c383c916c4ef7273ab666f096a44ee7081e357db4a36f0cce"}, + {file = "stripe-11.4.1.tar.gz", hash = "sha256:7ddd251b622d490fe57d78487855dc9f4d95b1bb113607e81fd377037a133d5a"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "tzdata" version = "2024.2" @@ -549,7 +722,24 @@ files = [ {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, ] +[[package]] +name = "urllib3" +version = "2.3.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +files = [ + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [metadata] lock-version = "2.0" python-versions = "^3.12.8" -content-hash = "cecf17797f30c8a6f25ec9a3b5ad688bfebbf8a6d5839f8cb87057131b26c1dd" +content-hash = "7b0c9f66bdab2b4d5810ebcd9244ee628b01096ca3ab46b5e43b723f669a6452" diff --git a/pyproject.toml b/pyproject.toml index 2408566..7f94e97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ psycopg2-binary = "^2.9.10" djangorestframework-simplejwt = "^5.3.1" django-filter = "^24.3" django-debug-toolbar = "^4.4.6" +stripe = "^11.4.1" [build-system] requires = ["poetry-core"] diff --git a/tg_bot/admin.py b/tg_bot/admin.py index b97a94f..846f6b4 100644 --- a/tg_bot/admin.py +++ b/tg_bot/admin.py @@ -1,2 +1 @@ - # Register your models here. diff --git a/tg_bot/apps.py b/tg_bot/apps.py index 5c425ff..2c2275e 100644 --- a/tg_bot/apps.py +++ b/tg_bot/apps.py @@ -2,5 +2,5 @@ class TgBotConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'tg_bot' + default_auto_field = "django.db.models.BigAutoField" + name = "tg_bot" diff --git a/tg_bot/models.py b/tg_bot/models.py index 35e0d64..6b20219 100644 --- a/tg_bot/models.py +++ b/tg_bot/models.py @@ -1,2 +1 @@ - # Create your models here. diff --git a/tg_bot/tests.py b/tg_bot/tests.py index 4929020..a39b155 100644 --- a/tg_bot/tests.py +++ b/tg_bot/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/tg_bot/views.py b/tg_bot/views.py index b8e4ee0..60f00ef 100644 --- a/tg_bot/views.py +++ b/tg_bot/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py index 6e32667..99db662 100644 --- a/user/migrations/0001_initial.py +++ b/user/migrations/0001_initial.py @@ -7,38 +7,131 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "password", + models.CharField(max_length=128, verbose_name="password"), + ), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, + max_length=254, + verbose_name="email address", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="date joined", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), ] diff --git a/user/models.py b/user/models.py index 0513c96..8307cc7 100644 --- a/user/models.py +++ b/user/models.py @@ -39,6 +39,7 @@ def create_superuser(self, email, password, **extra_fields): class User(AbstractUser): """Custom user model with email field used for authentication.""" + username = None email = models.EmailField(_("email address"), unique=True) diff --git a/user/serializers.py b/user/serializers.py index 384b862..4b49427 100644 --- a/user/serializers.py +++ b/user/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from django.utils.translation import gettext as _ + class UserSerializer(serializers.ModelSerializer): """User model serializer.""" diff --git a/user/tests/test_user_models.py b/user/tests/test_user_models.py index f397e44..4823ad6 100644 --- a/user/tests/test_user_models.py +++ b/user/tests/test_user_models.py @@ -28,7 +28,9 @@ def test_create_superuser(self): def test_create_user_without_email(self): with self.assertRaises(ValueError): - get_user_model().objects.create_user(email=None, password=self.password) + get_user_model().objects.create_user( + email=None, password=self.password + ) def test_create_user_with_invalid_email(self): invalid_email = "invalid_email" @@ -50,7 +52,9 @@ def test_create_superuser_with_no_is_superuser(self): ) def test_user_email_uniqueness(self): - get_user_model().objects.create_user(email=self.email, password=self.password) + get_user_model().objects.create_user( + email=self.email, password=self.password + ) with self.assertRaises(IntegrityError): get_user_model().objects.create_user( email=self.email, password="newpassword123" diff --git a/user/tests/test_user_serializers.py b/user/tests/test_user_serializers.py index 22cb71e..6f06078 100644 --- a/user/tests/test_user_serializers.py +++ b/user/tests/test_user_serializers.py @@ -26,7 +26,10 @@ def test_update_user(self): user = get_user_model().objects.create_user( email="existinguser@example.com", password="oldpassword" ) - new_data = {"email": "newemail@example.com", "password": "newpassword123"} + new_data = { + "email": "newemail@example.com", + "password": "newpassword123", + } serializer = UserSerializer(user, data=new_data, partial=True) self.assertTrue(serializer.is_valid()) diff --git a/user/urls.py b/user/urls.py index a148077..c09a362 100644 --- a/user/urls.py +++ b/user/urls.py @@ -2,14 +2,14 @@ from rest_framework_simplejwt.views import ( TokenObtainPairView, TokenRefreshView, - TokenVerifyView + TokenVerifyView, ) from user.views import CreateUserView, ManageUserView app_name = "user" -urlpatterns= [ +urlpatterns = [ path("register/", CreateUserView.as_view(), name="create"), path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),