From 403f90889bb494f4a23e29a6dc27e68f8eeed784 Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 13:07:55 +0200 Subject: [PATCH 1/8] added DS_Store to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2cbc65e..b4a2009 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ local_settings.py db.sqlite3 db.sqlite3-journal media +.DS_Store # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # in your Git repository. Update and uncomment the following line accordingly. From d8f1239d3eec84b72764c21cab7c8958365a09d1 Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 13:17:01 +0200 Subject: [PATCH 2/8] fix test.yml --- .DS_Store | Bin 6148 -> 0 bytes .github/workflows/test.yml | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 49e8e4a3c54f57728dbf8b584685e91b0373ccb8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKOHRWu5PdEYwEV=9Wlj(Yae;u!f(08m0Q5%%q!a=Ob(b|e4glBS9IQDEyct_4 zabXJx%~bY_?RlQ@OB2Tc-0t0Q3)leAW)V!5S$$zLE@jSIp=XWgWRCq}I_MYEKHD;( znJHij{A~r~*-g;Ll)sDU`F-SA_O7Oj=%`>uc$&J=3kPNF(b_|V(suX`%sIq zPS%V??#DUf49L$|MDbYRS4MoQaXUD~9(K^h0Wl+E*o>yS)pK0r%wrtue;`_q>Q(nm zaHsdXY|bevx%FBWOv>!n6yTmM)?V>wwJBf)$onB>5zGTt9`)71N{;}_x<_5NwStHU<&*z1zgfS>h5w$VQ-yVob0tJ%L$8^_*EX2!YXXX gdLdi!CW{)!Qn?`J0V|K}q1lIk%3zf#@S_TR0Hv$FzW@LL diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b16f96a..7478a4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,9 +42,9 @@ jobs: - name: Run Tests 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" + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ENVIRONMENT: ${{ secrets.ENVIRONMENT }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_PUBLIC_KEY }} timeout-minutes: 5 run: poetry run python manage.py test From c6b838910073c965f67849d300e5df1abb78404a Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 13:30:09 +0200 Subject: [PATCH 3/8] fixed dockerfile --- Dockerfile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 15977ee..a65bc0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,15 +11,12 @@ RUN adduser \ --disabled-password \ --gecos "" \ --home "/nonexistent" \ - --shell "/sbin/nologin" \ - --no-create-home \ - --uid "${UID}" \ appuser RUN pip install --no-cache-dir poetry COPY pyproject.toml poetry.lock ./ -RUN poetry config virtualenvs.create false && poetry install --no-dev --no-interaction +RUN poetry config virtualenvs.create false && poetry install --only main --no-interaction --no-root USER appuser From 6f75dca0adac3de72299fd567154553dc0ec7dde Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 13:35:31 +0200 Subject: [PATCH 4/8] fix readme.md --- README.md | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 867133e..406df49 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,200 @@ -# LibraryServiceAPI -Online management system for book borrowings. +# πŸ“š Library Service + +πŸŽ‰Welcome to the **Library Service API**! This system allows you to manage books, users, and borrowings effectively. It's designed to track books, handle borrow requests, and process payments. + +--- + +## βš™οΈ Features + +- **✨ Book Management**: Add, update, and delete books in the library. +- **πŸ‘€ User Registration**: Users can register, log in, and manage their profiles. +- **πŸ“– Borrowing System**: Users can borrow books with an automated return and fee calculation. +- **πŸ’³ Payment Tracking**: Keeps track of payments for borrowings. + +--- + +## πŸš€ Technologies Used + +- **🐍 Django** for backend API +- **🐘 PostgreSQL** for database +- **πŸ”§ Django Rest Framework (DRF)** for API development +- **⏳ Django Celery** for background tasks +- **πŸ’³ Stripe** for payment processing +- **πŸ“± Telegram API** for notifications +- **πŸ‹ Docker** for containerization + +--- + +# πŸ› οΈ Setup + +## πŸ“ Prerequisites +* Python 3.12.8+ +* Poetry +* Docker & Docker Compose + +## **Local** Setup + +Follow these steps to set up the project locally. πŸ’» + +### 1. πŸ–‡οΈ Clone the Repository +Clone the repository to your local machine using Git: + +```bash +git clone https://github.com/dmytrik/LibraryServiceAPI.git +cd LibraryServiceAPI +``` + +### 2. πŸ“„ Environment +Create a `.env` file based on the `.env.sample` file. You can do this by copying the `.env.sample` to `.env`: + +```bash +cp .env.sample .env +``` + +### 3. πŸ“¦ Install Dependencies +Use Poetry to install the necessary dependencies: +```bash +poetry install +``` + +### 4. πŸ”„ Apply Migrations +Run the migrations to set up the database schema: +```bash +python manage.py migrate +``` + +### 5. Install Redis +Redis is required for Celery task management. Follow the instructions for your operating system: +### MacOS: +```bash +brew install redis +``` +Start Redis: +```bash +redis-server +``` + +### Linux (Ubuntu): +```bash +sudo apt update +sudo apt install redis-server +sudo systemctl enable redis +sudo systemctl start redis +``` + +### Windows: +1. Download the Redis installer from [here](https://github.com/microsoftarchive/redis/releases) +2. Install Redis and start the server by running: +```bash +redis-server +``` + +### 6. πŸ“š Start Celery Workers and Beat +Start Celery worker and beat scheduler to handle background tasks: +#### Start Celery Worker: +```bash +celery -A core worker --loglevel=info --pool=solo +``` + +#### Start Celery Beat: +```bash +celery -A core beat --loglevel=info +``` + +### 7. πŸš€ Start the Server +Run the Django development server: +```bash +python manage.py runserver +``` + +🌐 Your server will be available at: http://localhost:8000/ + +--- + +## πŸ‹ Running with **Docker** + +If you'd like to run the project using Docker, follow the steps below. 🐳 + +### 1. πŸ”§ Build and Start the Services +Use Docker Compose to build and run the application and database containers: +```bash +docker-compose up --build +``` + +### 2. 🌐 Access the API +Once the services are up, the API will be available at: http://localhost:8000/ + +The PostgreSQL database will be available on port `5432`. + +--- +## πŸ“š Components + +### 1. πŸ“• **Books Service**: +Manages the quantity of books in the library. + +- **POST** `/api/books/` - Add a new book +- **GET** `/api/books/` - Get a list of all books +- **GET** `/api/books//` - Get detailed information about a specific book +- **PUT/PATCH** `/api/books//` - Update a book (also manages inventory) +- **DELETE** `/api/books//` - Delete a book + +### 2. πŸ‘€ **Users Service**: +Manages authentication and user registration. + +- **POST** `/api/users/register/` - Register a new user +- **POST** `/api/users/token/` - Get JWT tokens +- **POST** `/api/users/token/refresh/` - Refresh JWT token +- **GET** `/api/users/me/` - Get current user profile information +- **PUT/PATCH** `/api/users/me/` - Update user profile information + +### 3. πŸ“– **Borrowings Service**: +Manages users' borrowing actions and keeps track of borrowed books. + +- **POST** `/api/borrowings/` - Add a new borrowing (decrease inventory by 1 when borrowing a book) +- **GET** `/api/borrowings/?user_id=&is_active=` - Get borrowings by user id and active status +- **GET** `/api/borrowings//` - Get specific borrowing details +- **POST** `/api/borrowings//return/` - Set the actual return date (increase inventory by 1 when book is returned) + +### 4. πŸ“² **Notifications Service (Telegram)**: +Notifies about new borrowings and overdue borrowings. + +- Uses Django Celery to manage background tasks. +- Interacts with other services to send notifications to library administrators. +- Leverages Telegram API, Chats, and Bots for communication. + +### 5. πŸ’³ **Payments Service (Stripe)**: +Handles payments for book borrowings via Stripe. + +- **GET** `/api/payments/` - +- **GET** `/api/payments/{id}/` - +- **GET** `/api/payments/success/` - Check for successful Stripe payment +- **GET** `/api/payments/cancel/` - Return a message if the payment was paused or canceled + +### 6. πŸ–₯️ **View Service**: +- Delegated to the Front-end Team (Not implemented in this repository). +- Provides the user interface for interacting with the library system. + +--- + +## πŸ“„ Documentation +API documentation is available via Redoc at: + +🌐 `http://localhost:8000/api/schema/redoc/` + +and via Swagger at: + +🌐 `http://localhost:8000/api/schema/swagger-ui/` + +--- + +## πŸ“… Contributing + +1. Fork the repository +2. Create a new branch (`git checkout -b feature-name`) +3. Commit your changes (`git commit -am 'Add new feature'`) +4. Push to the branch (`git push origin feature-name`) +5. Open a pull request + +--- + +πŸš€ Happy Coding and enjoy building with **Library Service API**! πŸŽ‰ From ed831d1e3c18eef1d218ce6510200d35396d7496 Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 13:53:40 +0200 Subject: [PATCH 5/8] fix imports --- book/management/commands/wait_for_db.py | 4 +- book/tests/test_book_model.py | 1 + book/tests/test_book_serializer.py | 1 + book/tests/test_book_view_set.py | 9 +- book/urls.py | 1 + borrowing/models.py | 2 +- borrowing/signals.py | 3 +- borrowing/tasks.py | 2 +- borrowing/tests/test_borrowing_api.py | 3 +- borrowing/tests/test_borrowing_models.py | 5 +- borrowing/tests/test_borrowing_serializers.py | 3 +- borrowing/urls.py | 1 + core/__init__.py | 3 +- core/asgi.py | 1 + core/celery.py | 9 +- core/urls.py | 1 + core/wsgi.py | 1 + payment/admin.py | 1 + payment/service.py | 2 +- payment/views.py | 3 +- poetry.lock | 96 ++++++++++++++++++- pyproject.toml | 1 + tg_bot/admin.py | 1 - tg_bot/models.py | 1 - tg_bot/tests.py | 1 - tg_bot/utils.py | 3 +- tg_bot/views.py | 1 - user/urls.py | 1 + 28 files changed, 128 insertions(+), 33 deletions(-) delete mode 100644 tg_bot/admin.py delete mode 100644 tg_bot/models.py delete mode 100644 tg_bot/tests.py delete mode 100644 tg_bot/views.py diff --git a/book/management/commands/wait_for_db.py b/book/management/commands/wait_for_db.py index 51a4782..b5cd964 100644 --- a/book/management/commands/wait_for_db.py +++ b/book/management/commands/wait_for_db.py @@ -14,8 +14,6 @@ def handle(self, *args, **options): db_conn.cursor() except OperationalError: self.stdout.write( - self.style.WARNING( - "waiting for connection with database..." - ) + self.style.WARNING("waiting for connection with database...") ) time.sleep(1) diff --git a/book/tests/test_book_model.py b/book/tests/test_book_model.py index d68cefd..945a138 100644 --- a/book/tests/test_book_model.py +++ b/book/tests/test_book_model.py @@ -1,4 +1,5 @@ from django.test import TestCase + from book.models import Book diff --git a/book/tests/test_book_serializer.py b/book/tests/test_book_serializer.py index a905bec..3e1c2d9 100644 --- a/book/tests/test_book_serializer.py +++ b/book/tests/test_book_serializer.py @@ -1,5 +1,6 @@ from rest_framework.exceptions import ValidationError from rest_framework.test import APITestCase + from book.models import Book from book.serializers import BookSerializer diff --git a/book/tests/test_book_view_set.py b/book/tests/test_book_view_set.py index 93a17d7..4a391ff 100644 --- a/book/tests/test_book_view_set.py +++ b/book/tests/test_book_view_set.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.test import APITestCase + from book.models import Book User = get_user_model() @@ -75,9 +76,7 @@ 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) @@ -92,9 +91,7 @@ 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/urls.py b/book/urls.py index bc8dbb4..6ab3754 100644 --- a/book/urls.py +++ b/book/urls.py @@ -1,4 +1,5 @@ from rest_framework import routers + from book.views import BookViewSet app_name = "book" diff --git a/borrowing/models.py b/borrowing/models.py index 9e79c0f..f62ad6f 100644 --- a/borrowing/models.py +++ b/borrowing/models.py @@ -1,7 +1,7 @@ from django.db import models +from django.conf import settings from book.models import Book -from django.conf import settings class Borrowing(models.Model): diff --git a/borrowing/signals.py b/borrowing/signals.py index 4d13d92..7edbcbf 100644 --- a/borrowing/signals.py +++ b/borrowing/signals.py @@ -1,6 +1,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from .models import Borrowing + +from borrowing.models import Borrowing from tg_bot.utils import send_telegram_notification diff --git a/borrowing/tasks.py b/borrowing/tasks.py index 8dcca31..ac90eb6 100644 --- a/borrowing/tasks.py +++ b/borrowing/tasks.py @@ -1,8 +1,8 @@ import datetime from celery import shared_task -from tg_bot.utils import send_telegram_notification +from tg_bot.utils import send_telegram_notification from borrowing.models import Borrowing diff --git a/borrowing/tests/test_borrowing_api.py b/borrowing/tests/test_borrowing_api.py index 634a5c7..0b16a83 100644 --- a/borrowing/tests/test_borrowing_api.py +++ b/borrowing/tests/test_borrowing_api.py @@ -1,7 +1,8 @@ +from datetime import timedelta + from rest_framework.test import APITestCase from rest_framework import status from django.urls import reverse -from datetime import timedelta from django.utils.timezone import now from django.contrib.auth import get_user_model diff --git a/borrowing/tests/test_borrowing_models.py b/borrowing/tests/test_borrowing_models.py index bd019d3..586e191 100644 --- a/borrowing/tests/test_borrowing_models.py +++ b/borrowing/tests/test_borrowing_models.py @@ -1,9 +1,12 @@ +from datetime import timedelta + from django.test import TestCase from django.contrib.auth import get_user_model from django.utils.timezone import now -from datetime import timedelta + from borrowing.models import Borrowing, Book + User = get_user_model() diff --git a/borrowing/tests/test_borrowing_serializers.py b/borrowing/tests/test_borrowing_serializers.py index d7c2e43..341706b 100644 --- a/borrowing/tests/test_borrowing_serializers.py +++ b/borrowing/tests/test_borrowing_serializers.py @@ -1,8 +1,8 @@ +from datetime import timedelta from unittest.mock import MagicMock from rest_framework.test import APITestCase from rest_framework.exceptions import ValidationError -from datetime import timedelta from django.utils.timezone import now from django.contrib.auth import get_user_model @@ -15,6 +15,7 @@ BorrowingDetailSerializer, ) + User = get_user_model() diff --git a/borrowing/urls.py b/borrowing/urls.py index 6d73855..7bcb355 100644 --- a/borrowing/urls.py +++ b/borrowing/urls.py @@ -2,6 +2,7 @@ from borrowing.views import BorrowingViewSet + router = routers.DefaultRouter() router.register("", BorrowingViewSet, basename="borrowings") diff --git a/core/__init__.py b/core/__init__.py index 8a891ca..0118d0c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals -from .celery import app as celery_app +from core.celery import app as celery_app + __all__ = ("celery_app",) diff --git a/core/asgi.py b/core/asgi.py index 788f33e..d863d63 100644 --- a/core/asgi.py +++ b/core/asgi.py @@ -11,6 +11,7 @@ from django.core.asgi import get_asgi_application + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_asgi_application() diff --git a/core/celery.py b/core/celery.py index 279dc67..2c8a3e1 100644 --- a/core/celery.py +++ b/core/celery.py @@ -1,22 +1,17 @@ from __future__ import absolute_import, unicode_literals import os + from celery import Celery -# set the default Django settings module for the 'celery' program. + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") app = Celery("core") -# 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 app configs. app.autodiscover_tasks() - @app.task(bind=True) def debug_task(self): print("Request: {0!r}".format(self.request)) diff --git a/core/urls.py b/core/urls.py index a2c46f6..6e3f0e8 100644 --- a/core/urls.py +++ b/core/urls.py @@ -24,6 +24,7 @@ ) from debug_toolbar.toolbar import debug_toolbar_urls + urlpatterns = [ path("admin/", admin.site.urls), path("api/users/", include("user.urls", namespace="user")), diff --git a/core/wsgi.py b/core/wsgi.py index 3a0e1d4..15d1bc8 100644 --- a/core/wsgi.py +++ b/core/wsgi.py @@ -11,6 +11,7 @@ from django.core.wsgi import get_wsgi_application + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_wsgi_application() diff --git a/payment/admin.py b/payment/admin.py index 2d3cff2..7a5f1b0 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -2,4 +2,5 @@ from payment.models import Payment + admin.site.register(Payment) diff --git a/payment/service.py b/payment/service.py index dee3ba0..b884674 100644 --- a/payment/service.py +++ b/payment/service.py @@ -52,7 +52,7 @@ def calculate_money_to_pay(borrowing: Borrowing): borrowing.actual_return_date - borrowing.expected_return_date ).days - count_of_money = overdue_days * borrowing.book.daily_fee * 2 + count_of_money = overdue_days * borrowing.book.daily_fee * OVERDUE_COEFFICIENT result = (count_of_money, "FINE") return result diff --git a/payment/views.py b/payment/views.py index cb6b5be..42bda3a 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,3 +1,4 @@ +import stripe from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework import viewsets, mixins @@ -6,8 +7,6 @@ from rest_framework.response import Response from rest_framework import status -import stripe - from payment.models import Payment from payment.serializers import ( PaymentSerializer, diff --git a/poetry.lock b/poetry.lock index 9772539..cd4e855 100644 --- a/poetry.lock +++ b/poetry.lock @@ -79,6 +79,50 @@ files = [ {file = "billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f"}, ] +[[package]] +name = "black" +version = "24.10.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +files = [ + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "celery" version = "5.4.0" @@ -628,6 +672,55 @@ sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=2.8.0)"] +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + [[package]] name = "prompt-toolkit" version = "3.0.48" @@ -696,7 +789,6 @@ files = [ {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, @@ -1186,4 +1278,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12.8" -content-hash = "5f8b167b23adb24e025cabae018bcf416f97c23dd800275a5f01da5debffdc19" +content-hash = "69a73d6114eb26a58b71bb620fc0ef83a465aa3000c7d961b99c078cacbc199d" diff --git a/pyproject.toml b/pyproject.toml index c43687a..263c8bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ stripe = "^11.4.1" celery = {extras = ["redis"], version = "^5.4.0"} python-telegram-bot = "^21.10" django-celery-beat = "^2.7.0" +black = "^24.10.0" [build-system] requires = ["poetry-core"] diff --git a/tg_bot/admin.py b/tg_bot/admin.py deleted file mode 100644 index 846f6b4..0000000 --- a/tg_bot/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/tg_bot/models.py b/tg_bot/models.py deleted file mode 100644 index 6b20219..0000000 --- a/tg_bot/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/tg_bot/tests.py b/tg_bot/tests.py deleted file mode 100644 index a39b155..0000000 --- a/tg_bot/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/tg_bot/utils.py b/tg_bot/utils.py index 0e1109f..fbb27dc 100644 --- a/tg_bot/utils.py +++ b/tg_bot/utils.py @@ -1,8 +1,9 @@ +import os import asyncio from celery import shared_task from telegram import Bot -import os + BOT_TOKEN = os.getenv("BOT_TOKEN") CHAT_ID = os.getenv("CHAT_ID") diff --git a/tg_bot/views.py b/tg_bot/views.py deleted file mode 100644 index 60f00ef..0000000 --- a/tg_bot/views.py +++ /dev/null @@ -1 +0,0 @@ -# Create your views here. diff --git a/user/urls.py b/user/urls.py index c09a362..da1ca22 100644 --- a/user/urls.py +++ b/user/urls.py @@ -7,6 +7,7 @@ from user.views import CreateUserView, ManageUserView + app_name = "user" urlpatterns = [ From 6a41cfefe1662507033b0f5efc35ebb7c213a410 Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 14:01:17 +0200 Subject: [PATCH 6/8] fix: fixing tests on using get_user_model --- book/tests/test_book_view_set.py | 2 -- borrowing/tests/test_borrowing_api.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/book/tests/test_book_view_set.py b/book/tests/test_book_view_set.py index 4a391ff..ee2cc03 100644 --- a/book/tests/test_book_view_set.py +++ b/book/tests/test_book_view_set.py @@ -18,12 +18,10 @@ def setUp(self): daily_fee=10.00, ) - # Create a superuser using the custom user model self.superuser = User.objects.create_superuser( 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" ) diff --git a/borrowing/tests/test_borrowing_api.py b/borrowing/tests/test_borrowing_api.py index 0b16a83..db94913 100644 --- a/borrowing/tests/test_borrowing_api.py +++ b/borrowing/tests/test_borrowing_api.py @@ -17,8 +17,8 @@ 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.staff_user = User.objects.create_superuser( + email="test@admin.com", password="adminpassword" ) self.book = Book.objects.create( title="Test Book", author="Author", inventory=5, daily_fee=1 From 3c7ed2cdca31410b85c2ce9edea305bb7295d46d Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 14:16:49 +0200 Subject: [PATCH 7/8] fix: fixing borrowing filters --- borrowing/filters.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/borrowing/filters.py b/borrowing/filters.py index ef875ad..c506dce 100644 --- a/borrowing/filters.py +++ b/borrowing/filters.py @@ -11,23 +11,23 @@ class CustomFilter(filters.FilterSet): is_active = filters.BooleanFilter( method="filter_is_active", label="Active" ) - user_id = filters.NumberFilter(method="filter_user_id") + user_id = filters.NumberFilter(method="filter_by_user_id") class Meta: model = Borrowing fields = ["is_active", "user_id"] - def filter_is_active(self, queryset, name, value): - if value: + def filter_is_active(self, queryset, value): + if value is True: return queryset.filter(actual_return_date__isnull=True) - else: + elif value is False: return queryset.filter(actual_return_date__isnull=False) + return queryset - def filter_user_id(self, queryset, name, value): - request = self.request - if request.user.is_staff: + def filter_by_user_id(self, queryset, value): + if self.request.user.is_staff: if value: return queryset.filter(user_id=value) return queryset - else: - return queryset.filter(user=request.user) + + return queryset.filter(user=self.request.user) From f41a8d5f975c4be52484ed0a55312c7b010aaea9 Mon Sep 17 00:00:00 2001 From: dmytro Date: Sat, 11 Jan 2025 15:05:32 +0200 Subject: [PATCH 8/8] fix n+1 problem --- borrowing/serializers.py | 5 +- borrowing/views.py | 41 ++++++----- payment/views.py | 144 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 26 deletions(-) diff --git a/borrowing/serializers.py b/borrowing/serializers.py index 1934ea9..0fc60c9 100644 --- a/borrowing/serializers.py +++ b/borrowing/serializers.py @@ -27,8 +27,9 @@ def validate(self, attrs): if active_borrowings.exists(): raise serializers.ValidationError( - "You already have an active borrowing. " - "Please return the current book before borrowing another." + "It looks like you already have a book borrowed." + " Please return it before borrowing another." + " Thank you for your understanding!" ) return attrs diff --git a/borrowing/views.py b/borrowing/views.py index e822f1d..7696021 100644 --- a/borrowing/views.py +++ b/borrowing/views.py @@ -1,5 +1,6 @@ import datetime +from django.utils import timezone from django.http import HttpResponseRedirect from rest_framework import viewsets, mixins, status from rest_framework.decorators import action @@ -36,7 +37,10 @@ class BorrowingViewSet( filterset_class = CustomFilter def get_queryset(self): - queryset = Borrowing.objects.select_related("book", "user") + queryset = ( + Borrowing.objects.select_related("book", "user"). + prefetch_related("payments") + ) if self.request.user.is_staff: return queryset.order_by("actual_return_date") return queryset.filter(user=self.request.user).order_by( @@ -56,30 +60,31 @@ def get_serializer_class(self): def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - book = serializer.validated_data["book"] + with transaction.atomic(): + book = serializer.validated_data["book"] - expected_return_date = serializer.validated_data[ - "expected_return_date" - ] + expected_return_date = serializer.validated_data[ + "expected_return_date" + ] - if datetime.date.today() > expected_return_date: - raise ValidationError("No valid 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.") + if book.inventory <= 0: + raise ValidationError("No copies available in inventory.") - book.inventory -= 1 - book.save() + book.inventory -= 1 + book.save() - borrowing = serializer.save(user=self.request.user) + borrowing = serializer.save(user=self.request.user) - create_stripe_session(borrowing, request) + create_stripe_session(borrowing, request) - payment = Payment.objects.get(borrowing=borrowing) + payment = Payment.objects.get(borrowing=borrowing) - return HttpResponseRedirect( - payment.session_url, status=status.HTTP_302_FOUND - ) + return HttpResponseRedirect( + payment.session_url, status=status.HTTP_302_FOUND + ) @action( methods=["POST"], @@ -99,7 +104,7 @@ def return_book(self, request, pk=None): "This borrowing has already been returned." ) - borrowing.actual_return_date = datetime.date.today() + borrowing.actual_return_date = timezone.now().date() borrowing.save() book = borrowing.book diff --git a/payment/views.py b/payment/views.py index 42bda3a..9b21786 100644 --- a/payment/views.py +++ b/payment/views.py @@ -6,6 +6,14 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status +from stripe import ( + CardError, + RateLimitError, + InvalidRequestError, + AuthenticationError, + APIConnectionError, + StripeError +) from payment.models import Payment from payment.serializers import ( @@ -98,11 +106,19 @@ class PaymentSuccessView(APIView): get: Handles the GET request for successful payment. Updates the payment status and returns the payment details. """ + permission_classes = [IsAuthenticated] 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)) + if not payment_id: + return Response( + {"error": "Payment ID is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + payment = get_object_or_404( + Payment.objects.filter(borrowing__user=request.user), id=int(payment_id) + ) session = stripe.checkout.Session.retrieve(payment.session_id) if payment.status != "PAID": @@ -117,10 +133,64 @@ def get(self, request, *args, **kwargs): }, status=status.HTTP_200_OK, ) + except CardError: + return Response( + { + "error": "Your card has been declined. " + "Please check your card details or use a different card." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except RateLimitError: + return Response( + + { + "error": "Payment failed due to high request traffic. " + "Please try again later." + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + except InvalidRequestError: + return Response( + + { + "error": "There was an issue with the payment details. " + "Please verify the information and try again." + }, - except stripe.error.StripeError as e: + status=status.HTTP_400_BAD_REQUEST, + ) + except AuthenticationError: return Response( - {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + { + "error": "Authentication with the payment gateway failed. " + "Please contact support." + }, + status=status.HTTP_401_UNAUTHORIZED, + ) + except APIConnectionError: + return Response( + { + "error": "A network error occurred. " + "Please check your internet connection and try again." + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + except StripeError: + return Response( + { + "error": "An error occurred with the payment process. " + "Please try again or contact support." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception: + return Response( + { + "error": "An unexpected error occurred. " + "Please try again or contact support." + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -137,11 +207,19 @@ class PaymentCancelView(APIView): status and returns a message indicating cancellation and a link for retrying the payment. """ + permission_classes = [IsAuthenticated] 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)) + if not payment_id: + return Response( + {"error": "Payment ID is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + payment = get_object_or_404( + Payment.objects.filter(borrowing__user=request.user), id=int(payment_id) + ) session = stripe.checkout.Session.retrieve(payment.session_id) if payment.status != "PENDING": @@ -157,8 +235,62 @@ def get(self, request, *args, **kwargs): }, status=status.HTTP_400_BAD_REQUEST, ) + except CardError: + return Response( + { + "error": "Your card has been declined. " + "Please check your card details or use a different card." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except RateLimitError: + return Response( - except stripe.error.StripeError as e: + { + "error": "Payment failed due to high request traffic. " + "Please try again later." + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + except InvalidRequestError: return Response( - {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + + { + "error": "There was an issue with the payment details. " + "Please verify the information and try again." + }, + + status=status.HTTP_400_BAD_REQUEST, + ) + except AuthenticationError: + return Response( + { + "error": "Authentication with the payment gateway failed. " + "Please contact support." + }, + status=status.HTTP_401_UNAUTHORIZED, + ) + except APIConnectionError: + return Response( + { + "error": "A network error occurred. " + "Please check your internet connection and try again." + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + except StripeError: + return Response( + { + "error": "An error occurred with the payment process. " + "Please try again or contact support." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception: + return Response( + { + "error": "An unexpected error occurred. " + "Please try again or contact support." + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, )