diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ecc1af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +venv +.env +.gitignore +.github +media +.idea diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..578c251 --- /dev/null +++ b/.env.sample @@ -0,0 +1,12 @@ +DJANGO_SECRET_KEY=DJANGO_SECRET_KEY +ALLOWED_HOSTS=ALLOWED_HOSTS +POSTGRES_DB=POSTGRES_DB +POSTGRES_USER=POSTGRES_USER +POSTGRES_PASSWORD=POSTGRES_PASSWORD +POSTGRES_HOST=POSTGRES_HOST +POSTGRES_PORT=POSTGRES_PORT +TELEGRAM_BOT_TOKEN=TELEGRAM_BOT_TOKEN +TELEGRAM_CHAT_ID=TELEGRAM_CHAT_ID +CELERY_BROKER_URL=CELERY_BROKER_URL +CELERY_RESULT_BACKEND=CELERY_RESULT_BACKEND +STRIPE_API_KEY=STRIPE_API_KEY diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aec7a75 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11.2-slim-buster +LABEL maintainer="callogan217@gmail.com" + +ENV PYTHONUNBUFFERED 1 + +WORKDIR app/ + +COPY requirements.txt requirements.txt +RUN pip install debugpy + +RUN apt-get update \ + && apt-get -y install libpq-dev gcc + +RUN pip install -r requirements.txt + +COPY . . + +RUN mkdir -p /vol/web/media + +RUN adduser \ + --disabled-password \ + --no-create-home \ + django-user + +RUN chown -R django-user:django-user /vol/ +RUN chown -R 755 /vol/web/ + +USER django-user diff --git a/README.md b/README.md index 37141e9..6f1eed6 100644 --- a/README.md +++ b/README.md @@ -1 +1,156 @@ # Library Service API + +Library Service API is overarching web application programming interface aimed to provide +a diverse features and operations for managing the library. Its main goal is to make functioning +of the library more efficient by applying automation means. + +All the API features are grouped by the following services (as stacks of homogenous functionalities). + +## Book Service + +Book Service is intended for managing book stock at the library, including creation of books and making +various operations with them. Nonetheless, only admin can create, update or delete books. Ordinary users +have an access to a list of all books as well as to single book details without the possibility +to make operations with them. + +## User Service + +Users can register providing an email and a password. After going through authentication procedure users +can view and update their profile information. + +## Borrowing Service + +Borrowing Service operates the borrowing of books by users and provides all the necessary actions related +to that functionality. + +* Create new borrowing: borrowing the book results in making the record in the ledger about borrowing +creation and corresponding changes of the inventory. +* Get borrowing collection - glean a list of borrowings based on 2 criteria: + - user ID (for admin only); + - "is_active" status for the borrowing (either the book is returned or not). +* Get certain borrowing: retrieve detailed information about specified borrowing information. +* Return the book: assign the value for "actual return date" of borrowed book. Simultaneously the payment +for the borrowing is being created. + +## Notification Service (Telegram chat) + +Notification Service is in charge of sending notifications associated with library operations using +Telegram chat. + +* Keep library administrators informed about creation of new borrowing. +* Issue the alert on overdue borrowings. +* Notify administrators about successful payments. + +## Payment Service (Stripe API integration) + +Payment Service provides payment processing for borrowings in reliable and secure way using +Stripe payment gateway. + +* Perform payments for borrowings: deliver full-fledged financial infrastructure to users +for making payments. +* Checking payment processing status: verify that the payment has been processed successfully +or inform about payment cancellation. +* Ensures the control of expiration time for the payments session and for the payment. + +## Installation + +Clone this repository: + + ```bash + git clone https://github.com/callogan/library-service-api + cd library-service-api + ``` + +* The main branch is considered as the most sustainable branch, therefore it is recommended to work from it. + +* If you intend to run the application locally, follow the next steps: + +1. Create the virtual environment: + + ```bash + python -m venv venv + ``` + +2. Activate the virtual environment: + + On Windows: + + ```bash + venv\Scripts\activate + ``` + + On macOS and Linux: + + ```bash + source venv/bin/activate + ``` + +3. Install dependencies: + + ```bash + pip install -r requirements.txt + ``` + +4. Copy this file ".env.sample" and rename it to ".env", then fill in the actual values for your local environment. + +STRIPE_API_KEY (as secret key) is available here: https://sripe.com/. You can get Telegram Bot Token +here: https://t.me/BotFather. + +5. Apply the migrations: + + ```bash + python manage.py migrate + ``` + +6. In order to run the development server, use the following command: + + ```bash + python manage.py runserver + ``` + +* You might as well run the application via the Docker. For this purpose make certain the Docker is installed +on your computer and follow the next steps: +1. Fill the actual data for ".env" file (as was mentioned above). +2. Build the Docker image and start the containers for the application and the database: + ```bash + docker-compose up --build + ``` + +Access the application in your web browser at http://localhost:8000. + +## Project Fixture + + - This project includes the fixture that is used for demonstration purpose. The fixture contains basic dataset +representing various project entities. + - The fixture named `library_service_db_data.json` is located in the root directory. + - In order to load the fixture data into the application, use the following command: + + ```bash + python manage.py loaddata library_service_db_data.json + ``` + +## Technologies + +* [Django REST Framework](https://www.django-rest-framework.org/) This is toolbox for designing Web APIs, providing +features such as serialization, authentication, API views and viewsets to streamline the development of RESTful services +in Django applications. +* [Celery](https://docs.celeryq.dev/en/stable/) This is the system to operate task queue with focus on real-time +processing and option to schedule tasks. +* [Redis](https://redis.io/) This is a source available, im-memory storage, used as distributed, in-memory key-value +database, cache and message broker. +* [Docker](https://www.docker.com/) This is open source containerization platform that enables developers to package +applications into containers, simplifying the process of building, running, managing and distributing applications +throughout different execution environments. +* [PostgreSQL](https://www.postgresql.org/) This is a powerful, open source object-relational database management +system. +* [Swagger](https://swagger.io/) This is open source suite of tools to generate API documentation. + +## Demo + +![ER Diagram](demo/entity-relationship-diagram.png) +![API Endpoints part 1](demo/swagger-all-endpoints-1.png) +![API Endpoints part 2](demo/swagger-all-endpoints-2.png) + +## Copyright + +Copyright (c) 2024 Ruslan Kazmiryk diff --git a/book/__init__.py b/book/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/book/admin.py b/book/admin.py new file mode 100644 index 0000000..dc357dd --- /dev/null +++ b/book/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from book.models import Book + + +@admin.register(Book) +class BookAdmin(admin.ModelAdmin): + list_display = ( + "id", "title", "author", "cover", "inventory", "daily_fee" + ) + list_filter = ("author", ) + search_fields = ("title", ) diff --git a/book/apps.py b/book/apps.py new file mode 100644 index 0000000..0bdbe44 --- /dev/null +++ b/book/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BookConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "book" diff --git a/book/migrations/0001_initial.py b/book/migrations/0001_initial.py new file mode 100644 index 0000000..6f8066c --- /dev/null +++ b/book/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.6 on 2024-06-16 13:35 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Book", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("author", models.CharField(max_length=255)), + ( + "cover", + models.CharField( + choices=[("H", "HARD"), ("S", "SOFT")], max_length=1 + ), + ), + ("inventory", models.PositiveIntegerField()), + ( + "daily_fee", + models.DecimalField( + decimal_places=2, + max_digits=5, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ], + options={ + "ordering": ("title",), + }, + ), + ] diff --git a/book/migrations/__init__.py b/book/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/book/models.py b/book/models.py new file mode 100644 index 0000000..73514cb --- /dev/null +++ b/book/models.py @@ -0,0 +1,21 @@ +from django.core.validators import MinValueValidator +from django.db import models + + +class Book(models.Model): + COVER_CHOICES = [("H", "HARD"), ("S", "SOFT")] + + title = models.CharField(max_length=255) + author = models.CharField(max_length=255) + cover = models.CharField(max_length=1, choices=COVER_CHOICES) + inventory = models.PositiveIntegerField() + daily_fee = models.DecimalField( + max_digits=5, decimal_places=2, validators=[MinValueValidator(0)] + ) + + class Meta: + ordering = ("title",) + + def __str__(self): + return self.title + \ No newline at end of file diff --git a/book/serializers.py b/book/serializers.py new file mode 100644 index 0000000..ef48905 --- /dev/null +++ b/book/serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from book.models import Book + + +class BookSerializer(serializers.ModelSerializer): + class Meta: + model = Book + fields = ("id", "title", "author", "cover", "inventory", "daily_fee") + + +class BookListSerializer(serializers.ModelSerializer): + class Meta: + model = Book + fields = ("id", "title", "author", "daily_fee") diff --git a/book/tests/__init__.py b/book/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/book/tests/test_book_api.py b/book/tests/test_book_api.py new file mode 100644 index 0000000..18f5bbc --- /dev/null +++ b/book/tests/test_book_api.py @@ -0,0 +1,186 @@ +from django.contrib.auth import get_user_model +from django.urls import reverse + +from rest_framework.test import APIClient, APITestCase +from rest_framework import status +from rest_framework_simplejwt.tokens import RefreshToken + +from book.models import Book +from book.serializers import BookSerializer, BookListSerializer + +BOOK_URL = reverse("book:book-list") + + +def sample_book(**kwargs): + defaults = { + "title": "Test title 1", + "author": "Test author 1", + "cover": "S", + "inventory": 25, + "daily_fee": 20.0 + } + defaults.update(kwargs) + + return Book.objects.create(**defaults) + + +def detail_url(book_id): + return reverse("book:book-detail", args=[book_id]) + + +class UnauthenticatedBooksApiTests(APITestCase): + def setUp(self) -> None: + self.client = APIClient() + self.book = sample_book() + + def test_list_books_auth_not_required(self): + resp = self.client.get(BOOK_URL) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + + def test_create_book_not_allowed(self): + payload = { + "title": "Test title 2", + "author": "Test author 2", + "cover": "S", + "inventory": 25, + "daily_fee": 20.0 + } + + resp = self.client.post(BOOK_URL, payload) + + self.assertEquals(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_detail_book_allowed(self): + book = sample_book() + url = detail_url(book.id) + + resp = self.client.get(url) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + + +class AuthenticatedBookApiTests(APITestCase): + def setUp(self) -> None: + self.client = APIClient() + self.user = get_user_model().objects.create_user( + "test_user@test.com", + "testpass" + ) + + refresh = RefreshToken.for_user(self.user) + self.token = refresh.access_token + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + self.book = sample_book() + + def test_list_books(self): + resp = self.client.get(BOOK_URL) + + books = Book.objects.all() + serializer = BookListSerializer(books, many=True) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertEquals(resp.data, serializer.data) + + def test_retrieve_book_detail(self): + book = self.book + url = detail_url(book.id) + + resp = self.client.get(url) + + serializer = BookSerializer(book) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertEquals(resp.data, serializer.data) + + def test_book_create_not_allowed(self): + payload = { + "title": "Test title 2", + "author": "Test author 2", + "cover": "S", + "inventory": 25, + "daily_fee": 20.0 + } + + resp = self.client.post(BOOK_URL, payload) + + self.assertEquals(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_book_update_not_allowed(self): + book = self.book + url = detail_url(book.id) + + payload = { + "title": "Test title 1 updated", + "author": "Test author 1 updated", + "cover": "H", + "inventory": 28, + "daily_fee": 30.0 + } + + resp = self.client.put(url, payload) + + self.assertEquals(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_book_delete_not_allowed(self): + book = self.book + url = detail_url(book.id) + + resp = self.client.delete(url) + + self.assertEquals(resp.status_code, status.HTTP_403_FORBIDDEN) + + +class AdminBookApiTests(APITestCase): + def setUp(self) -> None: + self.client = APIClient() + self.user = get_user_model().objects.create_user( + "admin@admin.com", "testpass", is_staff=True + ) + + refresh = RefreshToken.for_user(self.user) + self.token = refresh.access_token + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + self.book = sample_book() + + def test_book_create(self): + payload = { + "title": "Test title 2", + "author": "Test author 2", + "cover": "S", + "inventory": 60, + "daily_fee": 50.0 + } + + resp = self.client.post(BOOK_URL, payload) + + serializer = BookSerializer(resp.data, many=False) + + self.assertEquals(resp.status_code, status.HTTP_201_CREATED) + self.assertEquals(resp.data, serializer.data) + + def test_book_update(self): + book = self.book + url = detail_url(book.id) + + payload = { + "title": "Test title 2 updated", + "author": "Test author 2 updated", + "cover": "S", + "inventory": 80, + "daily_fee": 90.0 + } + + resp = self.client.put(url, payload) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + + def test_book_delete(self): + book = self.book + url = detail_url(book.id) + + resp = self.client.delete(url) + + self.assertEquals(resp.status_code, status.HTTP_204_NO_CONTENT) diff --git a/book/urls.py b/book/urls.py new file mode 100644 index 0000000..965138f --- /dev/null +++ b/book/urls.py @@ -0,0 +1,11 @@ +from rest_framework import routers + +from book.views import BookViewSet + + +router = routers.DefaultRouter() +router.register("", BookViewSet) + +urlpatterns = router.urls + +app_name = "book" diff --git a/book/views.py b/book/views.py new file mode 100644 index 0000000..0d53377 --- /dev/null +++ b/book/views.py @@ -0,0 +1,21 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser, AllowAny + +from book.models import Book +from book.serializers import BookSerializer, BookListSerializer + + +class BookViewSet(viewsets.ModelViewSet): + queryset = Book.objects.all() + serializer_class = BookSerializer + permission_classes = (IsAdminUser,) + + def get_serializer_class(self): + if self.action == "list": + return BookListSerializer + return self.serializer_class + + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [AllowAny()] + return super().get_permissions() diff --git a/borrowing/__init__.py b/borrowing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowing/admin.py b/borrowing/admin.py new file mode 100644 index 0000000..ef42e7d --- /dev/null +++ b/borrowing/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from borrowing.models import Borrowing + +admin.site.register(Borrowing) diff --git a/borrowing/apps.py b/borrowing/apps.py new file mode 100644 index 0000000..3bb22a7 --- /dev/null +++ b/borrowing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BorrowingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "borrowing" diff --git a/borrowing/management/__init__.py b/borrowing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowing/management/commands/__init__.py b/borrowing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowing/management/commands/send_notification.py b/borrowing/management/commands/send_notification.py new file mode 100644 index 0000000..6bfaf4b --- /dev/null +++ b/borrowing/management/commands/send_notification.py @@ -0,0 +1,9 @@ +import requests +from django.conf import settings + + +def notification(message): + chat_id = settings.TELEGRAM_CHAT_ID + url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage" + data = {"chat_id": chat_id, "text": message} + requests.post(url, data) diff --git a/borrowing/management/commands/wait_for_db.py b/borrowing/management/commands/wait_for_db.py new file mode 100644 index 0000000..2df7f2d --- /dev/null +++ b/borrowing/management/commands/wait_for_db.py @@ -0,0 +1,20 @@ +import time + +from django.core.management.base import BaseCommand +from django.db import connections +from django.db.utils import OperationalError + + +class Command(BaseCommand): + """Django command to pause execution until database is available""" + + def handle(self, *args, **kwargs): + self.stdout.write("waiting for db ...") + db_conn = None + while not db_conn: + try: + db_conn = connections["default"] + self.stdout.write(self.style.SUCCESS("Database available!")) + except OperationalError: + self.stdout.write("Database unavailable, waiting 1 second ...") + time.sleep(1) diff --git a/borrowing/migrations/0001_initial.py b/borrowing/migrations/0001_initial.py new file mode 100644 index 0000000..3978a3d --- /dev/null +++ b/borrowing/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.6 on 2024-06-17 08:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("book", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + 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", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="borrowings", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("borrow_date",), + }, + ), + ] diff --git a/borrowing/migrations/__init__.py b/borrowing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowing/models.py b/borrowing/models.py new file mode 100644 index 0000000..d99cbe2 --- /dev/null +++ b/borrowing/models.py @@ -0,0 +1,55 @@ +from django.db import models +from rest_framework.generics import get_object_or_404 + +from book.models import Book +from borrowing.management.commands.send_notification import notification +from library_service import settings + + +class Borrowing(models.Model): + + 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" + ) + + class Meta: + ordering = ("borrow_date",) + + def __str__(self): + return f"{self.book.title} borrowed by {self.user}" + + @staticmethod + def validate_inventory(book, error_to_raise): + if book.inventory < 1: + raise error_to_raise("There are no books in inventory to borrow.") + + @staticmethod + def validate_pending_borrowings(user, error_to_raise): + pending_borrowings = user.borrowings.filter(payments__status="PENDING") + + if pending_borrowings: + raise error_to_raise("You have not yet completed your paying. " + "Please complete it before borrowing a new book.") + + def save(self, *args, **kwargs): + book = get_object_or_404(Book, pk=self.book.id) + message = ( + f"You have borrowed the book:\n'{book.title}'." + f"\nExpected return date:" + f"\n{self.expected_return_date}\n" + f"Rental fee per day:\n" + f"{book.daily_fee} $" + ) + if self.pk is None: + notification(message) + super().save(*args, **kwargs) diff --git a/borrowing/notifications.py b/borrowing/notifications.py new file mode 100644 index 0000000..08fb5d0 --- /dev/null +++ b/borrowing/notifications.py @@ -0,0 +1,32 @@ +import logging +from datetime import datetime, timedelta + +from borrowing.management.commands.send_notification import notification + +from borrowing.models import Borrowing + +logger = logging.getLogger(__name__) + + +def get_overdue_borrowings(): + tomorrow = datetime.now().date() + timedelta(days=1) + queryset = Borrowing.objects.filter( + actual_return_date__isnull=True, + expected_return_date__lte=tomorrow + ) + return queryset + + +def send_overdue_borrowing_notification(): + borrowings = get_overdue_borrowings() + if not borrowings: + notification("There are no overdue borrowings today.") + + for borrowing in borrowings: + logger.info(f"Creating message for borrowing with id: {borrowing.id}") + message = f"The expiration date of your borrowing is " \ + f"{borrowing.expected_return_date}.\n" \ + f"Please return the book '{borrowing.book.title}' " \ + f"by that time." + notification(message) + logger.info(f"The message has been sent successfully.") diff --git a/borrowing/serializers.py b/borrowing/serializers.py new file mode 100644 index 0000000..cc20aa8 --- /dev/null +++ b/borrowing/serializers.py @@ -0,0 +1,116 @@ +from datetime import datetime + +from django.db import transaction +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from borrowing.models import Borrowing +from book.serializers import BookSerializer +from payment.payment_session import create_payment + + +class BorrowingSerializer(serializers.ModelSerializer): + + class Meta: + model = Borrowing + fields = ( + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + "user" + ) + + +class BorrowingCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Borrowing + fields = ("id", "borrow_date", "expected_return_date", "book", "user") + + def validate(self, attrs): + data = super().validate(attrs) + user = self.context["request"].user + Borrowing.validate_inventory( + attrs["book"], + ValidationError + ) + Borrowing.validate_pending_borrowings(user, ValidationError) + return data + + @transaction.atomic() + def create(self, validated_data): + borrowing = Borrowing.objects.create(**validated_data) + book = validated_data["book"] + book.inventory -= 1 + book.save() + + return borrowing + + +class BorrowingListSerializer(serializers.ModelSerializer): + borrow_date = serializers.DateField(format="%Y-%m-%d") + actual_return_date = serializers.DateField(format="%Y-%m-%d") + expected_return_date = serializers.DateField(format="%Y-%m-%d") + + class Meta: + model = Borrowing + fields = ( + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + "user" + ) + + +class BorrowingDetailSerializer(BorrowingListSerializer): + user = serializers.SlugRelatedField( + many=False, + read_only=True, + slug_field="email" + ) + book = BookSerializer(read_only=True) + + class Meta: + model = Borrowing + fields = ( + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + "user" + ) + + +class BorrowingReturnBookSerializer(serializers.ModelSerializer): + class Meta: + model = Borrowing + fields = ( + "id", + "borrow_date", + "expected_return_date", + "actual_return_date" + ) + + def validate(self, attrs): + borrowing = self.instance + if borrowing.actual_return_date is not None: + raise ValidationError(detail="Book has been already returned.") + return super().validate(attrs=attrs) + + @transaction.atomic + def update(self, instance, validated_data): + book = instance.book + instance.actual_return_date = datetime.now().date() + instance.save() + book.inventory += 1 + book.save() + + request = self.context.get("request") + borrowing = instance + create_payment(borrowing, request) + + return instance diff --git a/borrowing/tasks.py b/borrowing/tasks.py new file mode 100644 index 0000000..28f2d24 --- /dev/null +++ b/borrowing/tasks.py @@ -0,0 +1,8 @@ +from celery import shared_task + +from borrowing.notifications import send_overdue_borrowing_notification + + +@shared_task +def overdue_borrowing_notifications(): + send_overdue_borrowing_notification() 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..26e3c8d --- /dev/null +++ b/borrowing/tests/test_borrowing_api.py @@ -0,0 +1,481 @@ +import stripe + +from datetime import datetime, timedelta +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, RequestFactory +from django.urls import reverse + +from rest_framework.test import APIClient, APITestCase +from rest_framework import status +from rest_framework_simplejwt.tokens import RefreshToken + +from book.models import Book +from borrowing.models import Borrowing +from borrowing.serializers import ( + BorrowingSerializer, + BorrowingListSerializer, + BorrowingDetailSerializer, +) +from payment.models import Payment + +BORROWING_URL = reverse("borrowing:borrowing-list") + + +def sample_book(**kwargs): + defaults = { + "title": "Sample book 1", + "author": "Test Author 1", + "cover": "S", + "inventory": 5, + "daily_fee": 10.0 + } + defaults.update(kwargs) + + return Book.objects.create(**defaults) + + +def sample_borrowing(**kwargs): + book = sample_book() + expected_return_date = kwargs.get("expected_return_date") + user = kwargs.get("user") + + defaults = { + "expected_return_date": expected_return_date, + "book": book, + "user": user + } + defaults.update(kwargs) + + return Borrowing.objects.create(**defaults) + + +def detail_url(borrowing_id): + return reverse("borrowing:borrowing-detail", args=[borrowing_id]) + + +def return_url(borrowing_id): + return reverse("borrowing:return-book", args=[borrowing_id]) + + +class UnauthenticatedBorrowingApiTests(APITestCase): + def setUp(self) -> None: + self.client = APIClient() + + def test_auth_required(self): + resp = self.client.get(BORROWING_URL) + self.assertEquals(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + +class AuthenticatedBorrowingApiTests(TestCase): + def setUp(self) -> None: + self.client = APIClient() + self.user_1 = get_user_model().objects.create_user( + "test1@test.com", + "testpass1" + ) + self.user_2 = get_user_model().objects.create_user( + "test2@test.com", + "testpass2" + ) + self.user_3 = get_user_model().objects.create_user( + "test3@test.com", + "testpass3" + ) + + refresh = RefreshToken.for_user(self.user_1) + self.token = refresh.access_token + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + self.book_1 = Book.objects.create( + title="Sample book 2", + author="Test Author 2", + cover="S", + inventory=2, + daily_fee=10.0 + ) + self.book_2 = Book.objects.create( + title="Sample book 3", + author="Test Author 3", + cover="S", + inventory=5, + daily_fee=15.0 + ) + self.book_3 = Book.objects.create( + title="Sample book 4", + author="Test Author 4", + cover="S", + inventory=4, + daily_fee=20.0 + ) + self.book_4 = Book.objects.create( + title="Sample book 5", + author="Test Author 5", + cover="S", + inventory=3, + daily_fee=25.0 + ) + + self.borrowing_1 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=15), + book=self.book_1, + user=self.user_1 + ) + + self.borrowing_2 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=20), + book=self.book_2, + user=self.user_2 + ) + self.borrowing_3 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=25), + book=self.book_3, + user=self.user_3 + ) + + self.factory = RequestFactory() + + def test_list_borrowings(self): + sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=10), + user=self.user_1 + ) + sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=15), + user=self.user_2 + ) + + resp = self.client.get(BORROWING_URL) + + borrowings = Borrowing.objects.all() + serializer = BorrowingListSerializer(borrowings, many=True) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data[0], serializer.data[0]) + + def test_retrieve_own_borrowing_detail(self): + borrowing_user1 = self.borrowing_1 + borrowing_user2 = self.borrowing_2 + + borrowing_user1_url = detail_url(borrowing_user1.id) + borrowing_user2_url = detail_url(borrowing_user2.id) + + resp_1 = self.client.get(borrowing_user1_url) + resp_2 = self.client.get(borrowing_user2_url) + + serializer = BorrowingDetailSerializer(borrowing_user1) + + self.assertEquals(resp_1.status_code, status.HTTP_200_OK) + self.assertEquals(resp_1.data, serializer.data) + self.assertEquals(resp_2.status_code, status.HTTP_404_NOT_FOUND) + + @patch("celery.app.task.Task.delay", return_value=1) + @patch("celery.app.task.Task.apply_async", return_value=1) + def test_create_borrowing(self, *args, **kwargs): + payload = { + "expected_return_date": datetime.now().date() + timedelta(days=10), + "book": self.book_1.id, + "user": self.user_1.id + } + + resp = self.client.post(BORROWING_URL, payload) + + self.book_1.inventory -= 1 + + self.book_1.save() + + self.book_1.refresh_from_db() + + self.assertEquals(resp.status_code, status.HTTP_201_CREATED) + self.assertEquals(self.book_1.inventory, 1) + + def test_borrowing_create_not_allowed_if_previous_not_payed(self): + payment = Payment.objects.create( + borrowing=self.borrowing_1 + ) + + price_data = stripe.Price.create( + unit_amount=1200, + currency="usd", + product_data={ + "name": f"Payment for borrowing of {self.book_1.title}", + } + ) + stripe_session = stripe.checkout.Session.create( + line_items=[ + { + "price": price_data.id, + "quantity": 1 + } + ], + mode="payment", + success_url="http://localhost:8000/success/", + cancel_url="http://localhost:8000/cancel/" + ) + + payment.session_id = stripe_session.id + payment.session_url = stripe_session.url + payment.money_to_pay = stripe_session.amount_total + payment.expires_at = datetime.fromtimestamp(stripe_session.expires_at) + + payment.save() + + book = self.book_1 + payload = { + "expected_return_date": datetime.now().date() + timedelta(days=30), + "book": book.id, + "user": self.user_1.id + } + + resp = self.client.post(BORROWING_URL, payload) + + self.assertEquals(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEquals( + resp.data["non_field_errors"][0], + "You have not yet completed your paying. " + "Please complete it before borrowing a new book." + ) + + def test_borrowing_create_not_allowed_if_zero_inventory(self): + book = self.book_1 + sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=15), + user=self.user_1 + ) + + self.book_1.inventory -= 1 + + self.book_1.save() + + sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=20), + user=self.user_2 + ) + + self.book_1.inventory -= 1 + + self.book_1.save() + + payload = { + "expected_return_date": datetime.now().date() + timedelta(days=25), + "book": book.id, + "user": self.user_3.id + } + + resp = self.client.post(BORROWING_URL, payload) + + self.assertEquals(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEquals( + resp.data["non_field_errors"][0], + "There are no books in inventory to borrow." + ) + + def test_return_book(self): + borrowing = self.borrowing_1 + + url = detail_url(borrowing.id) + "return-book/" + + resp_1 = self.client.patch(url) + resp_2 = self.client.patch(url) + + self.assertEquals(resp_1.status_code, status.HTTP_200_OK) + self.assertEquals(resp_1.data["status"], "book returned") + self.assertEquals( + resp_2.data["non_field_errors"][0], + "Book has been already returned." + ) + + def test_create_payment(self): + borrowing = Borrowing.objects.create( + expected_return_date=datetime.now().date() - timedelta(days=15), + book=self.book_1, + user=self.user_1 + ) + + borrowing.borrow_date = datetime.now().date() - timedelta(days=20) + + borrowing.save() + + url = detail_url(borrowing.id) + "return-book/" + + self.client.patch(url) + + borrowing.actual_return_date = datetime.now().date() + + borrowing_period = ( + borrowing.expected_return_date - borrowing.borrow_date + ).days + overdue_period = ( + borrowing.actual_return_date - borrowing.expected_return_date + ).days + borrowing_amount = int(self.book_1.daily_fee * borrowing_period * 100) + overdue_amount = int(self.book_1.daily_fee * 2 * overdue_period * 100) + calculated_total_amount = borrowing_amount + overdue_amount + + payments = borrowing.payments.filter(status="PENDING") + + self.assertTrue(payments.exists()) + self.assertEqual(payments.count(), 1) + payment = payments.first() + self.assertEqual(payment.money_to_pay, calculated_total_amount / 100) + + def test_filter_borrowings_is_active_true_or_false(self): + borrowing_1 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=10), + book=self.book_1, + user=self.user_1 + ) + borrowing_2 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=15), + actual_return_date=datetime.now().date() + timedelta(days=12), + book=self.book_2, + user=self.user_1 + ) + borrowing_3 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=20), + actual_return_date=datetime.now().date() + timedelta(days=18), + book=self.book_3, + user=self.user_1 + ) + borrowing_4 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=25), + book=self.book_4, + user=self.user_1 + ) + + resp_1 = self.client.get(BORROWING_URL, payload={"is_active": "True"}) + resp_2 = self.client.get(BORROWING_URL, payload={"is_active": "False"}) + + serializer1 = BorrowingSerializer(borrowing_1) + serializer2 = BorrowingSerializer(borrowing_2) + serializer3 = BorrowingSerializer(borrowing_3) + serializer4 = BorrowingSerializer(borrowing_4) + + self.assertIn(serializer1.data, resp_1.data) + self.assertIn(serializer4.data, resp_1.data) + self.assertIn(serializer3.data, resp_2.data) + self.assertIn(serializer2.data, resp_2.data) + + +class AdminBorrowingApiTests(APITestCase): + def setUp(self) -> None: + self.client = APIClient() + self.user_1 = get_user_model().objects.create_user( + "admin@admin.com", "testpass1", is_staff=True + ) + self.user_2 = get_user_model().objects.create_user( + "test1@tests.com", "testpass2" + ) + self.user_3 = get_user_model().objects.create_user( + "test2@tests.com", "testpass3" + ) + + refresh = RefreshToken.for_user(self.user_1) + self.token = refresh.access_token + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + self.book = sample_book() + + def test_list_all_borrowings(self): + sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=10), + user=self.user_1 + ) + sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=15), + user=self.user_2 + ) + + resp = self.client.get(BORROWING_URL) + + borrowings = Borrowing.objects.all() + + serializer = BorrowingListSerializer(borrowings, many=True) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertEquals(resp.data, serializer.data) + + def test_borrowing_detail_another_user(self): + borrowing = sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=20), + user=self.user_3 + ) + + url = detail_url(borrowing.id) + + resp = self.client.get(url) + + serializer = BorrowingDetailSerializer(borrowing) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertEquals(resp.data, serializer.data) + + def test_filter_borrowing_by_user_id(self): + borrowing_1 = sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=25), + user=self.user_2 + ) + borrowing_2 = sample_borrowing( + expected_return_date=datetime.now().date() + timedelta(days=30), + user=self.user_3 + ) + + user = self.user_3 + + resp = self.client.get(BORROWING_URL, data={"user": f"{user.id}"}) + + serializer1 = BorrowingSerializer(borrowing_1) + serializer2 = BorrowingSerializer(borrowing_2) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertIn(serializer2.data, resp.data) + self.assertNotIn(serializer1.data, resp.data) + + def test_filter_borrowings_is_active_true_or_false(self): + Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=10), + book=self.book, + user=self.user_2, + ) + Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=15), + actual_return_date=datetime.now().date() + timedelta(days=12), + book=self.book, + user=self.user_2, + ) + Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=20), + actual_return_date=datetime.now().date() + timedelta(days=18), + book=self.book, + user=self.user_3 + ) + Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=25), + book=self.book, + user=self.user_3 + ) + + resp_1 = self.client.get(BORROWING_URL, {"is_active": "True"}) + resp_2 = self.client.get(BORROWING_URL, {"is_active": "False"}) + + borrowings_active_true = Borrowing.objects.filter( + actual_return_date__isnull=True + ) + borrowings_active_false = Borrowing.objects.filter( + actual_return_date__isnull=False + ) + + serializer_active_true_borrowings = BorrowingListSerializer( + borrowings_active_true, many=True + ) + serializer_active_false_borrowings = BorrowingListSerializer( + borrowings_active_false, many=True + ) + + self.assertEquals( + resp_1.data, serializer_active_true_borrowings.data + ) + self.assertEquals( + resp_2.data, serializer_active_false_borrowings.data + ) diff --git a/borrowing/urls.py b/borrowing/urls.py new file mode 100644 index 0000000..d0d1d8a --- /dev/null +++ b/borrowing/urls.py @@ -0,0 +1,9 @@ +from rest_framework import routers +from borrowing.views import BorrowingViewSet + +router = routers.DefaultRouter() +router.register("", BorrowingViewSet) + +urlpatterns = router.urls + +app_name = "borrowing" diff --git a/borrowing/views.py b/borrowing/views.py new file mode 100644 index 0000000..431e309 --- /dev/null +++ b/borrowing/views.py @@ -0,0 +1,123 @@ +from datetime import datetime + +from django.db import transaction +from drf_spectacular.utils import extend_schema, OpenApiParameter +from rest_framework import mixins +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from borrowing.models import Borrowing +from borrowing.serializers import ( + BorrowingSerializer, + BorrowingCreateSerializer, + BorrowingListSerializer, + BorrowingDetailSerializer, + BorrowingReturnBookSerializer, +) +from borrowing.tasks import overdue_borrowing_notifications + + +class BorrowingViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + GenericViewSet +): + queryset = Borrowing.objects.all() + serializer_class = BorrowingSerializer + permission_classes = (IsAuthenticated,) + + def create(self, request, *args, **kwargs): + response = super().create(request, *args, **kwargs) + overdue_borrowing_notifications.delay() + return response + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + @staticmethod + def _params_to_ints(qs): + """Converts a list of string IDs to a list of integers""" + return [int(str_id) for str_id in qs.split(",")] + + def get_queryset(self): + queryset = super().get_queryset() + if self.action in ("list", "retrieve") and not self.request.user.is_staff: + return queryset.filter(user_id=self.request.user.id) + + user = self.request.query_params.get("user") + is_active = self.request.query_params.get("is_active", "").lower() + + if is_active == "true": + queryset = queryset.filter( + actual_return_date__isnull=True + ) + + if is_active == "false": + queryset = queryset.filter( + actual_return_date__isnull=False + ) + + if self.request.user.is_staff: + if user: + user_id = self._params_to_ints(user) + queryset = queryset.filter(user_id__in=user_id) + + return queryset + + def get_serializer_class(self): + if self.action == "list": + return BorrowingListSerializer + if self.action == "retrieve": + return BorrowingDetailSerializer + if self.action == "create": + return BorrowingCreateSerializer + if self.action == "return_book": + return BorrowingReturnBookSerializer + return self.serializer_class + + @transaction.atomic + @action( + methods=["PATCH"], + detail=True, + url_path="return-book", + serializer_class=BorrowingReturnBookSerializer, + ) + def return_book(self, request, pk=None): + """Endpoint for returning borrowed book""" + borrowing = self.get_object() + actual_return_date = datetime.now().date() + + serializer_update = BorrowingReturnBookSerializer( + borrowing, + context={"request": self.request}, + data={"actual_return_date": actual_return_date}, + partial=True, + ) + serializer_update.is_valid(raise_exception=True) + serializer_update.save() + return Response({"status": "book returned"}) + + # For documentation purposes only + @extend_schema( + parameters=[ + OpenApiParameter( + "is active", + type={"type": "string"}, + description="Filter borrowings by actual_return_date " + "(if it's None - borrowing is still active). " + "ex. /?is_active=True", + ), + OpenApiParameter( + "user", + type={"type": "list", "items": {"type": "number"}}, + description="Filter borrowings by user ids. For admin only. " + "(ex. /?user=1,2)", + ), + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) diff --git a/demo/entity-relationship-diagram.png b/demo/entity-relationship-diagram.png new file mode 100644 index 0000000..5ea9b94 Binary files /dev/null and b/demo/entity-relationship-diagram.png differ diff --git a/demo/swagger-all-endpoints-1.png b/demo/swagger-all-endpoints-1.png new file mode 100644 index 0000000..b71737a Binary files /dev/null and b/demo/swagger-all-endpoints-1.png differ diff --git a/demo/swagger-all-endpoints-2.png b/demo/swagger-all-endpoints-2.png new file mode 100644 index 0000000..00974ca Binary files /dev/null and b/demo/swagger-all-endpoints-2.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32cb3b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: "3" + +services: + app: + build: + context: . + ports: + - "8002:8000" + volumes: + - ./:/app + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate && + python manage.py runserver 0.0.0.0:8000" + env_file: + - .env + depends_on: + - db + - redis + + db: + image: postgres:14-alpine + ports: + - "5433:5432" + env_file: + - .env + redis: + image: redis:alpine + + celery: + build: + context: . + command: celery -A library_service worker -l info + volumes: + - ./:/app + env_file: + - .env + depends_on: + - redis + - app + restart: no + + celery-beat: + build: + context: . + command: celery -A library_service beat -l info + volumes: + - ./:/app + env_file: + - .env + depends_on: + - redis + - app diff --git a/library_service/__init__.py b/library_service/__init__.py index e69de29..2da78d6 100644 --- a/library_service/__init__.py +++ b/library_service/__init__.py @@ -0,0 +1,3 @@ +from library_service.celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/library_service/celery.py b/library_service/celery.py new file mode 100644 index 0000000..f94460b --- /dev/null +++ b/library_service/celery.py @@ -0,0 +1,23 @@ +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings") + + +app = Celery("library_service") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f"Request: {self.request!r}") diff --git a/library_service/settings.py b/library_service/settings.py index 67d0bcb..d92d844 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -10,8 +10,15 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +import os +import sys +from datetime import timedelta from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,12 +27,16 @@ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-0dba@=og9y67h47k0f-6o66(!!p(rme$zmhwvk$v6i-*jek!j8" +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",") + +INTERNAL_IPS = [ + "127.0.0.1", +] # Application definition @@ -37,6 +48,14 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django_celery_beat", + "rest_framework", + "rest_framework_simplejwt", + "drf_spectacular", + "book", + "user", + "borrowing", + "payment", ] MIDDLEWARE = [ @@ -49,6 +68,15 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +ENABLE_DEBUG_TOOLBAR = DEBUG and "test" not in sys.argv +if ENABLE_DEBUG_TOOLBAR: + INSTALLED_APPS += [ + "debug_toolbar", + ] + MIDDLEWARE += [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + ] + ROOT_URLCONF = "library_service.urls" TEMPLATES = [ @@ -75,8 +103,12 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB"), + "USER": os.environ.get("POSTGRES_USER"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("POSTGRES_HOST"), + "PORT": os.environ.get("POSTGRES_PORT"), } } @@ -99,6 +131,8 @@ }, ] +AUTH_USER_MODEL = "user.User" + # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ @@ -121,3 +155,42 @@ # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Library Service API", + "DESCRIPTION": "An online management system for book borrowings " + "on a paid basis.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "defaultModelRendering": "model", + "defaultModelsExpandDepth": 2, + "defaultModelExpandDepth": 2, + }, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": False, +} + +TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") +TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID") + + +CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL") +CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND") +CELERY_TIMEZONE = "Europe/Kyiv" +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" diff --git a/library_service/urls.py b/library_service/urls.py index 0ee5c85..665829c 100644 --- a/library_service/urls.py +++ b/library_service/urls.py @@ -15,8 +15,29 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) urlpatterns = [ path("admin/", admin.site.urls), + path("api/books/", include("book.urls", namespace="book")), + path("api/users/", include("user.urls", namespace="user")), + 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/doc/swagger/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/doc/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc" + ), + path("__debug__/", include("debug_toolbar.urls")), ] diff --git a/library_service_db_data.json b/library_service_db_data.json new file mode 100644 index 0000000..b9646be Binary files /dev/null and b/library_service_db_data.json differ diff --git a/payment/__init__.py b/payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/admin.py b/payment/admin.py new file mode 100644 index 0000000..2d3cff2 --- /dev/null +++ b/payment/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from payment.models import Payment + +admin.site.register(Payment) diff --git a/payment/apps.py b/payment/apps.py new file mode 100644 index 0000000..ab29d31 --- /dev/null +++ b/payment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payment" diff --git a/payment/check_expired_payments.py b/payment/check_expired_payments.py new file mode 100644 index 0000000..542466a --- /dev/null +++ b/payment/check_expired_payments.py @@ -0,0 +1,23 @@ +import logging +import stripe +from datetime import datetime + +from payment.models import Payment + +logger = logging.getLogger(__name__) + + +def check_expired_payments(): + current_time = datetime.now() + expired_payments = Payment.objects.filter( + expires_at__lte=current_time, status="PENDING" + ) + + for payment in expired_payments: + session_id = payment.session_id + session = stripe.checkout.Session.retrieve(session_id) + payment.status = "EXPIRED" + payment.save() + + logger.info(f"Session with ID {session.id} " + f"and payment with ID {payment.id} are expired") diff --git a/payment/migrations/0001_initial.py b/payment/migrations/0001_initial.py new file mode 100644 index 0000000..f6a4549 --- /dev/null +++ b/payment/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 5.0.6 on 2024-06-23 14:38 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("borrowing", "0001_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, + ), + ), + ( + "session_url", + models.TextField( + blank=True, max_length=450, null=True, unique=True + ), + ), + ( + "session_id", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ( + "money_to_pay", + models.DecimalField( + decimal_places=2, + max_digits=10, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "borrowing", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="borrowing.borrowing", + ), + ), + ], + ), + ] diff --git a/payment/migrations/0002_alter_payment_money_to_pay.py b/payment/migrations/0002_alter_payment_money_to_pay.py new file mode 100644 index 0000000..868dfcb --- /dev/null +++ b/payment/migrations/0002_alter_payment_money_to_pay.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.6 on 2024-06-23 14:50 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payment", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="payment", + name="money_to_pay", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=10, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ] diff --git a/payment/migrations/0003_payment_expires_at_alter_payment_status.py b/payment/migrations/0003_payment_expires_at_alter_payment_status.py new file mode 100644 index 0000000..a095cff --- /dev/null +++ b/payment/migrations/0003_payment_expires_at_alter_payment_status.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.6 on 2024-06-23 20:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payment", "0002_alter_payment_money_to_pay"), + ] + + operations = [ + migrations.AddField( + model_name="payment", + name="expires_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="payment", + name="status", + field=models.CharField( + choices=[ + ("PENDING", "Pending"), + ("PAID", "Paid"), + ("EXPIRED", "Expired"), + ], + default="PENDING", + max_length=7, + ), + ), + ] diff --git a/payment/migrations/__init__.py b/payment/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/models.py b/payment/models.py new file mode 100644 index 0000000..581b13c --- /dev/null +++ b/payment/models.py @@ -0,0 +1,46 @@ +from django.core.validators import MinValueValidator +from django.db import models + +from borrowing.models import Borrowing + + +class Payment(models.Model): + STATUS_CHOICE = [ + ("PENDING", "Pending"), + ("PAID", "Paid"), + ("EXPIRED", "Expired") + ] + + status = models.CharField( + max_length=7, + choices=STATUS_CHOICE, + default="PENDING" + ) + borrowing = models.ForeignKey( + Borrowing, + on_delete=models.CASCADE, + related_name="payments" + ) + session_url = models.TextField( + max_length=450, + null=True, + blank=True, + unique=True + ) + session_id = models.CharField( + max_length=255, + null=True, + blank=True, + unique=True + ) + money_to_pay = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(0)] + ) + expires_at = models.DateTimeField(null=True, blank=True) + + def __str__(self) -> str: + return f"{self.status} ({self.money_to_pay}USD)" diff --git a/payment/payment_session.py b/payment/payment_session.py new file mode 100644 index 0000000..fe0bbd0 --- /dev/null +++ b/payment/payment_session.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import os +import stripe +import time + +from datetime import datetime, timedelta +from rest_framework.request import Request +from rest_framework.reverse import reverse +from stripe.checkout import Session + +from borrowing.management.commands.send_notification import notification +from borrowing.models import Borrowing +from payment.models import Payment + + +stripe.api_key = os.environ.get("STRIPE_API_KEY") +LATE_FEE_MULTIPLIER = 2 + + +def calculate_rental_fee_amount(borrowing: Borrowing) -> int: + borrowing_period = ( + borrowing.expected_return_date - borrowing.borrow_date + ).days + pay_rate = borrowing.book.daily_fee + return int(pay_rate * borrowing_period * 100) + + +def calculate_late_fee_amount(borrowing: Borrowing) -> int: + overdue_period = ( + borrowing.actual_return_date - borrowing.expected_return_date + ).days + pay_rate = borrowing.book.daily_fee + return int(pay_rate * LATE_FEE_MULTIPLIER * overdue_period * 100) + + +def create_payment(borrowing: Borrowing, request: Request) -> Payment | None: + payment = Payment.objects.create( + status="PENDING", + borrowing=borrowing + ) + + stripe_session = create_stripe_session(borrowing, request) + + payment.session_id = stripe_session.id + payment.session_url = stripe_session.url + payment.money_to_pay = stripe_session.amount_total / 100 + payment.expires_at = datetime.fromtimestamp(stripe_session.expires_at) + + payment.save() + + return payment + + +def create_stripe_session(borrowing: Borrowing, request: Request) -> Session: + book = borrowing.book + + if ( + borrowing.actual_return_date + and borrowing.actual_return_date > borrowing.expected_return_date + ): + + rental_fee_amount = calculate_rental_fee_amount(borrowing) + late_fee_amount = calculate_late_fee_amount(borrowing) + total_amount = rental_fee_amount + late_fee_amount + product_name = f"Payment for borrowing of {book.title}, " \ + f"consisting of rental fee amount " \ + f"{rental_fee_amount} and late fee amount " \ + f"{late_fee_amount}" + else: + total_amount = calculate_rental_fee_amount(borrowing) + product_name = f"Payment for borrowing of {book.title}, " \ + f"including only rental fee amount {total_amount}" + + success_url = reverse("payment:payment-success", request=request) + cancel_url = reverse("payment:payment-cancel", request=request) + price_data = stripe.Price.create( + unit_amount=total_amount, + currency="usd", + product_data={ + "name": product_name + } + ) + + session = stripe.checkout.Session.create( + line_items=[ + { + "price": price_data.id, + "quantity": 1 + } + ], + mode="payment", + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel_url + "?session_id={CHECKOUT_SESSION_ID}", + expires_at=int(time.mktime(( + datetime.now() + timedelta(hours=16) + ).timetuple())) + + ) + + return session + + +def send_payment_notification(payment): + message = f"You have successfully paid {payment.money_to_pay}$\n" \ + f"Your borrowing ID: {payment.borrowing.id}" + notification(message) diff --git a/payment/serializers.py b/payment/serializers.py new file mode 100644 index 0000000..d521d57 --- /dev/null +++ b/payment/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers + +from payment.models import Payment + + +class PaymentSerializer(serializers.ModelSerializer): + + class Meta: + model = Payment + fields = ( + "id", + "status", + "borrowing", + "money_to_pay" + ) + + +class PaymentListSerializer(PaymentSerializer): + + class Meta: + model = Payment + fields = ( + "id", + "status", + "money_to_pay" + ) + + +class PaymentDetailSerializer(PaymentSerializer): + borrowing = serializers.StringRelatedField( + many=False, read_only=True + ) + + class Meta: + model = Payment + fields = ( + "id", + "status", + "borrowing", + "session_url", + "session_id", + "money_to_pay" + ) diff --git a/payment/tasks.py b/payment/tasks.py new file mode 100644 index 0000000..2fc8544 --- /dev/null +++ b/payment/tasks.py @@ -0,0 +1,8 @@ +from celery import shared_task + +from payment.check_expired_payments import check_expired_payments + + +@shared_task +def check_stripe_expired_payments(): + check_expired_payments() 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..9b16efb --- /dev/null +++ b/payment/tests/test_payment_api.py @@ -0,0 +1,377 @@ +import stripe + +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock + +from django.contrib.auth import get_user_model +from django.test import TestCase, RequestFactory +from django.urls import reverse + +from rest_framework.test import APIClient, APITestCase +from rest_framework import status +from rest_framework_simplejwt.tokens import RefreshToken + +from book.models import Book +from borrowing.models import Borrowing +from payment.models import Payment +from payment.serializers import ( + PaymentSerializer, + PaymentListSerializer, + PaymentDetailSerializer, +) + +PAYMENT_URL = reverse("payment:payment-list") +SUCCESS_URL = reverse("payment:payment-success") +CANCEL_URL = reverse("payment:payment-cancel") + + +def sample_book(**kwargs): + defaults = { + "title": "Sample book 1", + "author": "Test Author 1", + "cover": "S", + "inventory": 5, + "daily_fee": 10.0 + } + defaults.update(kwargs) + + return Book.objects.create(**defaults) + + +def sample_borrowing(**kwargs): + book = sample_book() + user = kwargs.get("user") + + defaults = { + "expected_return_date": datetime.now().date() + timedelta(days=15), + "book": book, + "user": user + } + defaults.update(kwargs) + + return Borrowing.objects.create(**defaults) + + +def sample_payment(**kwargs): + borrowing = kwargs.get("borrowing") + + defaults = { + "borrowing": borrowing + } + defaults.update(kwargs) + + return Payment.objects.create(**defaults) + + +def detail_url(payment_id): + return reverse("payment:payment-detail", args=[payment_id]) + + +class UnauthenticatedPaymentApiTests(APITestCase): + def setUp(self) -> None: + self.client = APIClient() + + def test_auth_required(self): + resp = self.client.get(PAYMENT_URL) + self.assertEquals(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + +class AuthenticatedPaymentApiTests(TestCase): + def setUp(self) -> None: + self.client = APIClient() + self.user_1 = get_user_model().objects.create_user( + "test1@test.com", + "testpass1" + ) + self.user_2 = get_user_model().objects.create_user( + "test2@test.com", + "testpass2" + ) + self.user_3 = get_user_model().objects.create_user( + "test3@test.com", + "testpass3" + ) + + refresh = RefreshToken.for_user(self.user_1) + self.token = refresh.access_token + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + self.book_1 = Book.objects.create( + title="Sample book 2", + author="Test Author 2", + cover="S", + inventory=5, + daily_fee=10.0 + ) + self.book_2 = Book.objects.create( + title="Sample book 3", + author="Test Author 3", + cover="H", + inventory=6, + daily_fee=15.0 + ) + self.book_3 = Book.objects.create( + title="Sample book 4", + author="Test Author 4", + cover="S", + inventory=7, + daily_fee=20.0 + ) + + self.borrowing_1 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=10), + book=self.book_1, + user=self.user_1 + ) + + self.borrowing_2 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=15), + book=self.book_2, + user=self.user_1 + ) + self.borrowing_3 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=20), + book=self.book_2, + user=self.user_2 + ) + self.borrowing_4 = Borrowing.objects.create( + expected_return_date=datetime.now().date() + timedelta(days=25), + book=self.book_3, + user=self.user_3 + ) + + self.factory = RequestFactory() + + def test_list_payments(self): + sample_payment( + borrowing=self.borrowing_1 + ) + sample_payment( + borrowing=self.borrowing_2 + ) + + resp = self.client.get(PAYMENT_URL) + + payments_only_auth_user = Payment.objects.filter( + borrowing__user=self.user_1 + ) + + serializer = PaymentListSerializer(payments_only_auth_user, many=True) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertEquals(resp.data, serializer.data) + + def test_user_can_see_only_his_own_payments(self): + own_payment = sample_payment( + borrowing=self.borrowing_1 + ) + another_user_payment = sample_payment( + borrowing=self.borrowing_3 + ) + + own_url_payment = detail_url(own_payment.id) + another_user_url_payment = detail_url(another_user_payment.id) + + resp_own = self.client.get(own_url_payment) + resp_another_user = self.client.get(another_user_url_payment) + + serializer_own = PaymentDetailSerializer(own_payment) + + self.assertEquals(resp_own.data, serializer_own.data) + self.assertEquals( + resp_another_user.status_code, + status.HTTP_404_NOT_FOUND + ) + + @patch("payment.payment_session.send_payment_notification") + @patch("stripe.checkout.Session.retrieve") + def test_payment_success( + self, + mock_session_retrieve, + mock_send_notification + ): + price_data = stripe.Price.create( + unit_amount=1200, + currency="usd", + product_data={ + "name": f"Payment for borrowing of {self.book_1.title}", + } + ) + stripe_session = stripe.checkout.Session.create( + line_items=[ + { + "price": price_data.id, + "quantity": 1 + } + ], + mode="payment", + success_url="http://localhost:8000/success/", + cancel_url="http://localhost:8000/cancel/" + ) + + payment = sample_payment( + borrowing=self.borrowing_1 + ) + + payment.session_id = stripe_session.id + payment.session_url = stripe_session.url + payment.money_to_pay = stripe_session.amount_total + payment.expires_at = datetime.fromtimestamp(stripe_session.expires_at) + + payment.save() + + mock_session_retrieve.return_value = MagicMock(payment_status="paid") + + url_success_payment = ( + SUCCESS_URL + f"?session_id={payment.session_id}" + ) + + resp = self.client.get(url_success_payment) + + payment = Payment.objects.get(session_id=payment.session_id) + + serializer = PaymentDetailSerializer(payment) + + self.assertEquals(resp.status_code, status.HTTP_200_OK) + self.assertEquals(resp.data, serializer.data) + self.assertEquals(payment.status, "PAID") + + def test_payment_cancel(self): + price_data = stripe.Price.create( + unit_amount=1200, + currency="usd", + product_data={ + "name": f"Payment for borrowing of {self.book_1.title}", + } + ) + stripe_session = stripe.checkout.Session.create( + line_items=[ + { + "price": price_data.id, + "quantity": 1 + } + ], + mode="payment", + success_url="http://localhost:8000/success/", + cancel_url="http://localhost:8000/cancel/" + ) + + payment = sample_payment( + borrowing=self.borrowing_1 + ) + + payment.session_id = stripe_session.id + payment.session_url = stripe_session.url + payment.money_to_pay = stripe_session.amount_total + payment.expires_at = datetime.fromtimestamp(stripe_session.expires_at) + + payment.save() + + url_cancel_payment = CANCEL_URL + f"?session_id={payment.session_id}" + + resp = self.client.get(url_cancel_payment) + serializer = PaymentSerializer(payment) + + self.assertEquals( + resp.data["message"], + "You can make a payment during the next 16 hours." + ) + + self.assertEquals(resp.data["id"], serializer.data["id"]) + self.assertEquals(resp.data["status"], serializer.data["status"]) + self.assertEquals(resp.data["borrowing"], serializer.data["borrowing"]) + self.assertEquals( + resp.data["money_to_pay"], serializer.data["money_to_pay"] + ) + + @patch("celery.app.task.Task.delay", return_value=1) + @patch("celery.app.task.Task.apply_async", return_value=1) + def test_recreate_payment_session(self, *args, **kwargs): + borrowing = sample_borrowing(user=self.user_1) + + price_data = stripe.Price.create( + unit_amount=1200, + currency="usd", + product_data={ + "name": f"Payment for borrowing of {self.book_1.title}", + } + ) + stripe_session = stripe.checkout.Session.create( + line_items=[ + { + "price": price_data.id, + "quantity": 1 + } + ], + mode="payment", + success_url="http://localhost:8000/success/", + cancel_url="http://localhost:8000/cancel/" + ) + + payment = sample_payment( + borrowing=borrowing + ) + + payment.session_id = stripe_session.id + payment.session_url = stripe_session.url + payment.money_to_pay = stripe_session.amount_total + payment.expires_at = datetime.fromtimestamp(stripe_session.expires_at) + + payment.status = "EXPIRED" + + payment.save() + + url = reverse("payment:payment-recreate", args=[payment.id]) + + request = self.factory.post(url) + request.user = self.user_1 + + resp = self.client.post(url) + + payment.refresh_from_db() + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertNotEqual(payment.session_id, stripe_session.id) + self.assertNotEqual(payment.session_url, stripe_session.url) + self.assertEqual(payment.status, "PENDING") + self.assertEqual(resp.data["status"], "Session has been recreated") + + +class AdminPaymentApiTests(TestCase): + def setUp(self) -> None: + self.client = APIClient() + self.user_1 = get_user_model().objects.create_user( + "admin@admin.com", "testpass", is_staff=True + ) + self.user_2 = get_user_model().objects.create_user( + "test2@test.com", + "testpass2" + ) + self.user_3 = get_user_model().objects.create_user( + "test3@test.com", + "testpass3" + ) + + refresh = RefreshToken.for_user(self.user_1) + self.token = refresh.access_token + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + def test_admin_can_see_all_payments(self): + borrowing_1 = sample_borrowing(user=self.user_1) + borrowing_2 = sample_borrowing(user=self.user_2) + borrowing_3 = sample_borrowing(user=self.user_3) + + payment_1 = sample_payment(borrowing=borrowing_1) + payment_2 = sample_payment(borrowing=borrowing_2) + payment_3 = sample_payment(borrowing=borrowing_3) + + resp = self.client.get(PAYMENT_URL) + + serializer_1 = PaymentListSerializer(payment_1) + serializer_2 = PaymentListSerializer(payment_2) + serializer_3 = PaymentListSerializer(payment_3) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertIn(serializer_1.data, resp.data) + self.assertIn(serializer_2.data, resp.data) + self.assertIn(serializer_3.data, resp.data) diff --git a/payment/urls.py b/payment/urls.py new file mode 100644 index 0000000..0e487d9 --- /dev/null +++ b/payment/urls.py @@ -0,0 +1,10 @@ +from rest_framework import routers + +from payment.views import PaymentViewSet + +router = routers.DefaultRouter() +router.register("", PaymentViewSet) + +urlpatterns = router.urls + +app_name = "payment" diff --git a/payment/views.py b/payment/views.py new file mode 100644 index 0000000..42757ac --- /dev/null +++ b/payment/views.py @@ -0,0 +1,122 @@ +import stripe + +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework import status, generics, viewsets + +from payment.models import Payment +from payment.payment_session import ( + create_stripe_session, + send_payment_notification, +) +from payment.serializers import ( + PaymentSerializer, + PaymentListSerializer, + PaymentDetailSerializer, +) +from payment.tasks import check_stripe_expired_payments + + +class PaymentViewSet( + generics.ListAPIView, + generics.RetrieveAPIView, + generics.CreateAPIView, + viewsets.GenericViewSet +): + queryset = Payment.objects.all() + serializer_class = PaymentSerializer + + def get_serializer_class(self): + if self.action == "list": + return PaymentListSerializer + if self.action == "retrieve": + return PaymentDetailSerializer + return PaymentSerializer + + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [IsAuthenticated()] + return super().get_permissions() + + def get_queryset(self): + if self.request.user.is_staff: + return super().get_queryset() + return super().get_queryset().filter(borrowing__user=self.request.user) + + @action( + methods=["GET"], + detail=False, + url_path="payment-success", + url_name="success", + permission_classes=[AllowAny], + ) + def payment_success(self, request: Request): + """Endpoint for successful payment session""" + session_id = request.query_params.get("session_id") + payment = Payment.objects.get(session_id=session_id) + + session = stripe.checkout.Session.retrieve(session_id) + + if session.payment_status == "paid": + serializer = PaymentDetailSerializer( + payment, data={"status": "PAID"}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + send_payment_notification(payment) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_400_BAD_REQUEST) + + @action( + methods=["GET"], + detail=False, + url_path="payment-cancel", + url_name="cancel", + permission_classes=[AllowAny], + ) + def payment_cancel(self, request: Request): + """Endpoint for canceled payment session""" + session_id = request.query_params.get("session_id") + payment = Payment.objects.get(session_id=session_id) + serializer = PaymentSerializer(payment) + data = { + "message": "You can make a payment during the next 16 hours.", + **serializer.data + } + return Response(data=data, status=status.HTTP_200_OK) + + @action( + methods=["POST"], + detail=True, + url_path="recreate-session", + url_name="recreate", + permission_classes=[AllowAny], + ) + def recreate_payment_session(self, request, pk=None): + """ + Endpoint for creating new payment session + in the case current one has expired + """ + payment = self.get_object() + borrowing = payment.borrowing + + check_stripe_expired_payments.delay() + + if payment.status == "EXPIRED": + new_payment_session = create_stripe_session( + borrowing=borrowing, request=self.request + ) + + payment.status = "PENDING" + payment.session_id = new_payment_session.id + payment.session_url = new_payment_session.url + + payment.save() + + return Response({"status": "Session has been recreated"}) + + return Response({"status": "Session is still active"}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..39567b7 Binary files /dev/null and b/requirements.txt differ diff --git a/user/__init__.py b/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user/admin.py b/user/admin.py new file mode 100644 index 0000000..d81c010 --- /dev/null +++ b/user/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.utils.translation import gettext as _ + +from user.models import User + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + """Define admin model for custom User model with no email field.""" + + fieldsets = ( + (None, {"fields": ("email", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) + list_display = ("email", "first_name", "last_name", "is_staff") + search_fields = ("email", "first_name", "last_name") + ordering = ("email",) diff --git a/user/apps.py b/user/apps.py new file mode 100644 index 0000000..578292c --- /dev/null +++ b/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "user" diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py new file mode 100644 index 0000000..eb11e32 --- /dev/null +++ b/user/migrations/0001_initial.py @@ -0,0 +1,115 @@ +# Generated by Django 5.0.6 on 2024-06-16 13:57 + +import django.utils.timezone +import user.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + 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", + ), + ), + ( + "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" + ), + ), + ( + "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" + ), + ), + ( + "email", + models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), + ), + ( + "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, + }, + managers=[ + ("objects", user.models.UserManager()), + ], + ), + ] diff --git a/user/migrations/__init__.py b/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user/models.py b/user/models.py new file mode 100644 index 0000000..4d5023b --- /dev/null +++ b/user/models.py @@ -0,0 +1,52 @@ +from django.contrib.auth.models import ( + AbstractUser, + BaseUserManager, +) +from django.db import models +from django.utils.translation import gettext as _ + + +class UserManager(BaseUserManager): + """Define a model manager for User model with no username field.""" + + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """Create and save a User with the given email and password.""" + if not email: + raise ValueError("The given email must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + """Create and save a regular User with + the given email and password.""" + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + """Create and save a SuperUser + with the given email and password.""" + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) + + +class User(AbstractUser): + username = None + email = models.EmailField(_("email address"), unique=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = UserManager() diff --git a/user/serializers.py b/user/serializers.py new file mode 100644 index 0000000..69754c4 --- /dev/null +++ b/user/serializers.py @@ -0,0 +1,32 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ( + "id", + "email", + "first_name", + "last_name", + "password", + "is_staff" + ) + read_only_fields = ("id", "is_staff") + extra_kwargs = {"password": {"write_only": True, "min_length": 5}} + + def create(self, validated_data): + """Create a new user with encrypted password""" + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + """Update a user with correctly encrypted password""" + password = validated_data.pop("password", None) + user = super().update(instance, validated_data) + + if password: + user.set_password(password) + user.save() + + return user diff --git a/user/tests.py b/user/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/user/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/user/urls.py b/user/urls.py new file mode 100644 index 0000000..bd9b93e --- /dev/null +++ b/user/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) + +from user.views import CreateUserView, ManageUserView + +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"), + path("token/verify/", TokenVerifyView.as_view(), name="token_verify"), + path("me/", ManageUserView.as_view(), name="manage"), +] + +app_name = "user" diff --git a/user/views.py b/user/views.py new file mode 100644 index 0000000..ad42384 --- /dev/null +++ b/user/views.py @@ -0,0 +1,19 @@ +from rest_framework import generics +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication + +from user.serializers import UserSerializer + + +class CreateUserView(generics.CreateAPIView): + serializer_class = UserSerializer + permission_classes = (AllowAny,) + + +class ManageUserView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = UserSerializer + authentication_classes = (JWTAuthentication,) + permission_classes = (IsAuthenticated,) + + def get_object(self): + return self.request.user