diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3fa4ae9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Python venv +venv/ +.venv/ + +# SQLite DB +*.sqlite3 +db.sqlite3 + +# Env files +.env +.env.* +*.env + +# Logs +*.log + +# Test & coverage +.mypy_cache/ +.pytest_cache/ +*.coverage +.coverage.* +htmlcov/ + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# IDE & OS +.idea/ +.vscode/ +.DS_Store +*.swp diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..362262c --- /dev/null +++ b/.env.sample @@ -0,0 +1,19 @@ +# Django settings +DJANGO_SECRET_KEY= +PRODUCTION_HOST= +DJANGO_SETTINGS_MODULE=core.settings.dev + +# Database settings +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_HOST= +POSTGRES_PORT= + +# Stripe settings +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= + +# Telegram Bot settings +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e213317 --- /dev/null +++ b/.flake8 @@ -0,0 +1,12 @@ +[flake8] +inline-quotes = " +ignore = E203, E266, W503, N807, N818, F401 +max-line-length = 79 +max-complexity = 18 +select = B,C,E,F,W,T4,B9,Q0,N8,VNE +exclude = + **migrations + **settings + venv + .venv + tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f48365 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [ develop, main ] + pull_request: + branches: [ develop, main ] + +jobs: + test: + runs-on: ubuntu-latest + env: + DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} + DJANGO_SETTINGS_MODULE: core.settings.dev + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run black + run: black --check . + + - name: Run flake8 + run: flake8 . + + - name: Run tests with coverage + run: pytest --cov=. + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: .coverage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f753ef5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,261 @@ +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# 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. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..754f8b3 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,11 @@ +[settings] +profile = black +multi_line_output = 3 +force_grid_wrap=0 +include_trailing_comma=True +line_length = 88 +known_first_party = users, books, borrowings, notifications, payments +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +no_lines_before = LOCALFOLDER +skip_gitignore = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51ddca0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim +LABEL maintainer="arthur.oleinikov.py@gmail.com" + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y gcc libpq-dev \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /files/media /files/static + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt + +RUN useradd -m django_user \ + && chown -R django_user /files/media /files/static \ + && chmod -R 755 /files/media /files/static + +COPY --chown=django_user:django_user . . + +USER django_user + +EXPOSE 8000 + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/README.md b/README.md index 9b200c1..6766481 100644 --- a/README.md +++ b/README.md @@ -1 +1,187 @@ -# drf-library-api \ No newline at end of file +# ๐Ÿ“š Library Service API + +**Library Service API** is a Django REST Framework project for managing a library system: books, users, borrowings, payments, and notifications. +It replaces outdated paper-based tracking with a modern API-driven solution that supports inventory management, online payments, and automated notifications. + +--- + +## ๐Ÿš€ Features + +- **Books Service** + - CRUD operations for books (admin only for create/update/delete) + - Automatic inventory updates on borrowing/returning + - Public access to view books (even unauthenticated users) + +- **Users Service** + - Custom user model with email as the unique identifier + - Registration, login, JWT authentication + - Profile management (`/users/me/`) + +- **Borrowings Service** + - Borrow creation with inventory validation + - Automatic decrease/increase of inventory + - Prevents double returns + - Filtering by `is_active`, `user_id` (for admins) + +- **Payments Service (Stripe)** + - Automatic Stripe Session creation on borrowing + - Endpoints for `/success/` and `/cancel/` + - Fine calculation for overdue returns + +- **Notifications Service (Telegram)** + - Notifications on new borrowings + - Daily overdue checks + - Implemented with Celery or Django-Q + +- **API Docs** + - Auto-generated Swagger / OpenAPI schema + +--- + +## ๐Ÿ› ๏ธ Tech Stack + +- Python 3.12 +- Django 5.2 +- Django REST Framework +- PostgreSQL +- Redis +- Celery +- Docker & Docker Compose +- drf-spectacular (OpenAPI schema) +- Simple JWT (authentication) +- Stripe (payments) +- python-dotenv + +--- + +## ๐Ÿณ Running the Project with Docker + +### 1. Clone the Repository + +```bash +git clone https://github.com//drf-library-api.git +cd drf-library-api +``` + +--- + +### 2. โš™๏ธ Environment Variables Setup + +The project requires a `.env` file in the root directory. +You can create it manually or copy from the template: + +```bash +cp .env.sample .env +``` + +Then open `.env` and configure the following values: + +#### Django +- `DEBUG` โ€” set `True` for local development, `False` for production. +- `SECRET_KEY` โ€” generate your own Django secret key: + ```bash + python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' + ``` + +#### Database +- `POSTGRES_DB` โ€” name of the database (e.g., `library`) +- `POSTGRES_USER` โ€” database username +- `POSTGRES_PASSWORD` โ€” database password +- `POSTGRES_HOST` โ€” database host (usually `db` in Docker) +- `POSTGRES_PORT` โ€” default is `5432` + +#### Stripe +- `STRIPE_SECRET_KEY` โ€” create a **test API key** in your [Stripe Dashboard](https://dashboard.stripe.com/test/apikeys) +- `STRIPE_PUBLISHABLE_KEY` โ€” optional, for frontend usage + +#### Telegram +- `TELEGRAM_BOT_TOKEN` โ€” create a bot via [BotFather](https://t.me/botfather) +- `TELEGRAM_CHAT_ID` โ€” get your chat ID using [@userinfobot](https://t.me/userinfobot) or by adding the bot to a group + +> ๐Ÿ”‘ Keep `.env` private and **never commit it** to GitHub. + +--- + +### 3. Build and Run + +```bash +docker-compose up --build +``` + +๐Ÿ“ API will be available at: +http://localhost:8000 + +--- + +## ๐Ÿ” Authentication + +JWT-based authentication: + +- `POST /users/token/` โ€” obtain access and refresh tokens +- `POST /users/token/refresh/` โ€” refresh the access token + +Add the header to access protected endpoints: + +``` +Authorization: Bearer +``` + +--- + +## ๐Ÿ“š API Endpoints + +### Users Service +- `POST /users/register/` โ€” register a new user +- `POST /users/token/` โ€” obtain JWT tokens +- `GET /users/me/` โ€” get profile info +- `PUT /users/me/` โ€” update profile + +### Books Service +- `GET /books/` โ€” list books +- `POST /books/` โ€” create a book (admin only) +- `PUT/PATCH /books/{id}/` โ€” update a book (admin only) +- `DELETE /books/{id}/` โ€” delete a book (admin only) + +### Borrowings Service +- `POST /borrowings/` โ€” create borrowing +- `GET /borrowings/` โ€” list borrowings (filters: `is_active`, `user_id`) +- `GET /borrowings/{id}/` โ€” get borrowing detail +- `POST /borrowings/{id}/return/` โ€” return a book + +### Payments Service +- `GET /payments/` โ€” list payments +- `GET /payments/{id}/` โ€” payment details +- `GET /payments/success/` โ€” confirm Stripe payment +- `GET /payments/cancel/` โ€” cancel payment + +--- + +## ๐Ÿ“š API Documentation + +- Swagger UI: [http://localhost:8000/api/docs/](http://localhost:8000/api/docs/) +- OpenAPI schema (JSON): [http://localhost:8000/api/schema/](http://localhost:8000/api/schema/) + +--- + +## ๐Ÿงช Running Tests + +```bash +docker-compose exec app python manage.py test +``` + +--- + +## ๐Ÿ“ธ Screenshots + +![Api](screenshots/User_swagger.jpg) +![Api](screenshots/Books_swagger.jpg) +![Api](screenshots/Borrowings_swagger.jpg) +![PAY](screenshots/pay.png) +![PAY](screenshots/paid.jpg) +![BOT](screenshots/bot.png) + +--- + +## ๐Ÿ“Š DB Structure + +![DB](screenshots/DB_diagram.jpg) diff --git a/books/__init__.py b/books/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/books/admin.py b/books/admin.py new file mode 100644 index 0000000..f7a9a77 --- /dev/null +++ b/books/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from books.models import Book + + +@admin.register(Book) +class BookAdmin(admin.ModelAdmin): + list_display = ( + "title", + "author", + "cover", + "inventory", + "daily_fee", + ) + search_fields = ( + "title", + "author", + ) + list_filter = ("cover",) diff --git a/books/apps.py b/books/apps.py new file mode 100644 index 0000000..ca1a219 --- /dev/null +++ b/books/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BooksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "books" diff --git a/books/migrations/0001_initial.py b/books/migrations/0001_initial.py new file mode 100644 index 0000000..6666b75 --- /dev/null +++ b/books/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.6 on 2025-09-21 19:04 + +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=256)), + ("author", models.CharField(max_length=128)), + ( + "cover", + models.CharField( + choices=[("HARD", "Hardcover"), ("SOFT", "Softcover")], + max_length=16, + ), + ), + ("inventory", models.PositiveIntegerField()), + ( + "daily_fee", + models.DecimalField(decimal_places=2, max_digits=5), + ), + ], + options={ + "verbose_name": "Book", + "verbose_name_plural": "Books", + "ordering": ["title", "author"], + }, + ), + ] diff --git a/books/migrations/0002_alter_book_author_alter_book_cover_and_more.py b/books/migrations/0002_alter_book_author_alter_book_cover_and_more.py new file mode 100644 index 0000000..27f714b --- /dev/null +++ b/books/migrations/0002_alter_book_author_alter_book_cover_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.6 on 2025-09-22 09:15 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("books", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="book", + name="author", + field=models.CharField( + help_text="The author of the book.", max_length=128 + ), + ), + migrations.AlterField( + model_name="book", + name="cover", + field=models.CharField( + choices=[("HARD", "Hardcover"), ("SOFT", "Softcover")], + help_text="The cover type of the book (Hardcover or Softcover).", + max_length=16, + ), + ), + migrations.AlterField( + model_name="book", + name="daily_fee", + field=models.DecimalField( + decimal_places=2, + help_text="The daily fee for renting this book.", + max_digits=5, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + migrations.AlterField( + model_name="book", + name="inventory", + field=models.PositiveIntegerField( + help_text="The number of books available in inventory." + ), + ), + migrations.AlterField( + model_name="book", + name="title", + field=models.CharField( + help_text="The title of the book.", max_length=256 + ), + ), + migrations.AlterUniqueTogether( + name="book", + unique_together={("title", "author", "cover")}, + ), + ] diff --git a/books/migrations/0003_alter_book_table.py b/books/migrations/0003_alter_book_table.py new file mode 100644 index 0000000..ae4b977 --- /dev/null +++ b/books/migrations/0003_alter_book_table.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-09-23 19:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("books", "0002_alter_book_author_alter_book_cover_and_more"), + ] + + operations = [ + migrations.AlterModelTable( + name="book", + table="books", + ), + ] diff --git a/books/migrations/__init__.py b/books/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/books/models.py b/books/models.py new file mode 100644 index 0000000..551fb67 --- /dev/null +++ b/books/models.py @@ -0,0 +1,40 @@ +from django.db import models +from django.core.validators import MinValueValidator + + +class Book(models.Model): + title = models.CharField( + max_length=256, + help_text="The title of the book.", + ) + author = models.CharField( + max_length=128, + help_text="The author of the book.", + ) + cover = models.CharField( + max_length=16, + choices=[ + ("HARD", "Hardcover"), + ("SOFT", "Softcover"), + ], + help_text="The cover type of the book (Hardcover or Softcover).", + ) + inventory = models.PositiveIntegerField( + help_text="The number of books available in inventory.", + ) + daily_fee = models.DecimalField( + max_digits=5, + decimal_places=2, + validators=[MinValueValidator(0)], + help_text="The daily fee for renting this book.", + ) + + def __str__(self): + return f"{self.title} by {self.author}" + + class Meta: + ordering = ["title", "author"] + verbose_name = "Book" + verbose_name_plural = "Books" + unique_together = ("title", "author", "cover") + db_table = "books" diff --git a/books/serializers.py b/books/serializers.py new file mode 100644 index 0000000..91e66a5 --- /dev/null +++ b/books/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from books.models import Book + + +class BookSerializer(serializers.ModelSerializer): + class Meta: + model = Book + fields = [ + "id", + "title", + "author", + "cover", + "inventory", + "daily_fee", + ] diff --git a/books/tests/__init__.py b/books/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/books/tests/test_models.py b/books/tests/test_models.py new file mode 100644 index 0000000..d18946b --- /dev/null +++ b/books/tests/test_models.py @@ -0,0 +1,39 @@ +from django.test import TestCase +from django.core.exceptions import ValidationError +from books.models import Book + + +class BookModelTest(TestCase): + def test_daily_fee_cannot_be_negative(self): + book = Book( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=10, + daily_fee=-1.01, + ) + with self.assertRaises(ValidationError): + book.full_clean() + + def test_inventory_cannot_be_negative(self): + book = Book( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=-5, + daily_fee=10, + ) + with self.assertRaises(ValidationError): + book.full_clean() + + def test_valid_book_can_be_saved(self): + book = Book( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=5, + daily_fee=2.5, + ) + book.full_clean() + book.save() + self.assertEqual(Book.objects.count(), 1) diff --git a/books/tests/test_views.py b/books/tests/test_views.py new file mode 100644 index 0000000..b4a36af --- /dev/null +++ b/books/tests/test_views.py @@ -0,0 +1,193 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from books.models import Book +from books.serializers import BookSerializer + + +def setup_books(self): + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=5, + daily_fee=9.99, + ) + + self.list_url = reverse("book-list") + self.detail_url = reverse( + "book-detail", + kwargs={"pk": self.book.pk}, + ) + + +class UnauthenticatedBookApiTests(TestCase): + def setUp(self): + self.client = APIClient() + setup_books(self) + + def test_books_list_unauthorized(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + serializer = BookSerializer(self.book) + self.assertIn(serializer.data, response.data["results"]) + + def test_get_valid_book_detail(self): + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + serializer = BookSerializer(self.book) + self.assertEqual(response.data, serializer.data) + + def test_get_invalid_book_detail(self): + url = reverse("book-detail", kwargs={"pk": 999}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class AuthenticatedBookApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + email="testuser@example.com", + password="testpass123", + ) + self.client.force_authenticate(self.user) + setup_books(self) + + def test_can_list_books(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + serializer = BookSerializer(self.book) + self.assertIn(serializer.data, response.data["results"]) + + def test_cannot_create_book(self): + data = { + "title": "New Book", + "author": "New Author", + "cover": "SOFT", + "inventory": 10, + "daily_fee": 5.99, + } + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cannot_put_update_book(self): + data = { + "title": "Updated Book Title", + "author": "Updated Author", + "cover": "SOFT", + "inventory": 15, + "daily_fee": 12.99, + } + response = self.client.put(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cannot_patch_update_book(self): + data = {"inventory": 25} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cannot_delete_book(self): + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class StaffBookApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.staff_user = get_user_model().objects.create_user( + email="staff@example.com", + password="staffpass123", + is_staff=True, + ) + self.client.force_authenticate(self.staff_user) + setup_books(self) + + def test_staff_can_create_book(self): + data = { + "title": "Staff Book", + "author": "Staff Author", + "cover": "HARD", + "inventory": 20, + "daily_fee": 7.99, + } + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Book.objects.count(), 2) + + created_book = Book.objects.get(title="Staff Book") + serializer = BookSerializer(created_book) + self.assertEqual(response.data, serializer.data) + + def test_staff_can_put_update_book(self): + data = { + "title": "Updated Book Title", + "author": "Updated Author", + "cover": "SOFT", + "inventory": 15, + "daily_fee": 12.99, + } + response = self.client.put(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.book.refresh_from_db() + serializer = BookSerializer(self.book) + self.assertEqual(response.data, serializer.data) + + def test_staff_can_patch_update_inventory(self): + data = {"inventory": 30} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.book.refresh_from_db() + serializer = BookSerializer(self.book) + self.assertEqual(response.data, serializer.data) + + def test_staff_cannot_update_with_invalid_inventory(self): + data = {"inventory": -5} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("inventory", response.data) + + def test_staff_cannot_update_with_invalid_daily_fee(self): + data = {"daily_fee": -10.00} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("daily_fee", response.data) + + def test_staff_cannot_update_with_invalid_cover(self): + data = {"cover": "INVALID"} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("cover", response.data) + + def test_staff_cannot_update_with_duplicate_title_author_cover(self): + Book.objects.create( + title="Another Book", + author="Another Author", + cover="HARD", + inventory=3, + daily_fee=8.99, + ) + + data = { + "title": "Another Book", + "author": "Another Author", + "cover": "HARD", + "inventory": 10, + "daily_fee": 9.99, + } + response = self.client.put(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non_field_errors", response.data) + + def test_staff_can_delete_book(self): + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Book.objects.count(), 0) diff --git a/books/urls.py b/books/urls.py new file mode 100644 index 0000000..c714b0a --- /dev/null +++ b/books/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from books.views import BookViewSet + +router = DefaultRouter() +router.register(r"", BookViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/books/views.py b/books/views.py new file mode 100644 index 0000000..11091f7 --- /dev/null +++ b/books/views.py @@ -0,0 +1,59 @@ +from rest_framework import viewsets +from rest_framework.permissions import AllowAny +from drf_spectacular.utils import extend_schema_view, extend_schema + +from core.permissions import IsStaffUser +from books.models import Book +from books.serializers import BookSerializer + + +@extend_schema_view( + list=extend_schema( + summary="List Books", + description="Get a list of all books. Available to all users.", + tags=["Books"], + ), + create=extend_schema( + summary="Create Book", + description="Create a new book. Available only to staff users", + tags=["Books"], + ), + retrieve=extend_schema( + summary="Book Details", + description="Get detailed information about a specific book. " + "Available to all users.", + tags=["Books"], + ), + update=extend_schema( + summary="Update Book", + description="Completely update book information. " + "Available only to staff users", + tags=["Books"], + ), + partial_update=extend_schema( + summary="Partially Update Book", + description="Partially update book information. " + "Available only to staff users", + tags=["Books"], + ), + destroy=extend_schema( + summary="Delete Book", + description="Delete a book from the library. " + "Available only to staff users.", + tags=["Books"], + ), +) +class BookViewSet(viewsets.ModelViewSet): + queryset = Book.objects.all() + serializer_class = BookSerializer + search_fields = ["title", "author"] + filterset_fields = ["cover", "inventory"] + ordering_fields = ["title", "author", "inventory", "daily_fee"] + ordering = ["title", "author"] + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsStaffUser] + return [permission() for permission in permission_classes] diff --git a/borrowings/__init__.py b/borrowings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowings/admin.py b/borrowings/admin.py new file mode 100644 index 0000000..ba11e09 --- /dev/null +++ b/borrowings/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin + +from borrowings.models import Borrowing + + +@admin.register(Borrowing) +class BorrowingAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "book", + "borrow_date", + "expected_return_date", + "actual_return_date", + "is_active", + ) + + list_filter = ("borrow_date", "expected_return_date", "actual_return_date") + + search_fields = ( + "user__email", + "book__title", + "book__author", + ) + + readonly_fields = ("borrow_date",) + + ordering = ("-borrow_date",) + + def is_active(self, obj): + return obj.actual_return_date is None + + is_active.boolean = True + is_active.short_description = "Active" diff --git a/borrowings/apps.py b/borrowings/apps.py new file mode 100644 index 0000000..9ab9f08 --- /dev/null +++ b/borrowings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BorrowingsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "borrowings" diff --git a/borrowings/migrations/0001_initial.py b/borrowings/migrations/0001_initial.py new file mode 100644 index 0000000..71c26b0 --- /dev/null +++ b/borrowings/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.6 on 2025-09-22 10:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("books", "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="books.book", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="borrowings", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q( + ( + "expected_return_date__gte", + models.F("borrow_date"), + ) + ), + name="expected_after_borrow", + ), + models.CheckConstraint( + condition=models.Q( + ("actual_return_date__isnull", True), + ( + "actual_return_date__gte", + models.F("borrow_date"), + ), + _connector="OR", + ), + name="actual_after_borrow", + ), + models.UniqueConstraint( + condition=models.Q( + ("actual_return_date__isnull", True) + ), + fields=("book", "user"), + name="unique_active_borrowing", + ), + ], + }, + ), + ] diff --git a/borrowings/migrations/0002_alter_borrowing_table.py b/borrowings/migrations/0002_alter_borrowing_table.py new file mode 100644 index 0000000..cffdd47 --- /dev/null +++ b/borrowings/migrations/0002_alter_borrowing_table.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-09-23 19:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("borrowings", "0001_initial"), + ] + + operations = [ + migrations.AlterModelTable( + name="borrowing", + table="borrowings", + ), + ] diff --git a/borrowings/migrations/__init__.py b/borrowings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowings/models.py b/borrowings/models.py new file mode 100644 index 0000000..c9c5ce0 --- /dev/null +++ b/borrowings/models.py @@ -0,0 +1,69 @@ +from django.contrib.auth import get_user_model +from django.db import models + +from books.models import Book + + +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( + get_user_model(), + on_delete=models.CASCADE, + related_name="borrowings", + ) + + def __str__(self): + return ( + f"From: {self.borrow_date} " + f"till: {self.expected_return_date} " + f"returned: {self.actual_return_date}" + ) + + @staticmethod + def validate_expected_return_date( + expected_return_date, borrow_date, error_to_raise + ): + if expected_return_date < borrow_date: + raise error_to_raise( + "Expected return date cannot be before borrow date." + ) + + @staticmethod + def validate_actual_return_date( + actual_return_date, borrow_date, error_to_raise + ): + if actual_return_date < borrow_date: + raise error_to_raise( + "Actual return date cannot be before borrow date" + ) + + class Meta: + db_table = "borrowings" + ordering = ["-borrow_date"] + constraints = [ + models.CheckConstraint( + condition=models.Q( + expected_return_date__gte=models.F("borrow_date") + ), + name="expected_after_borrow", + ), + models.CheckConstraint( + condition=( + models.Q(actual_return_date__isnull=True) + | models.Q(actual_return_date__gte=models.F("borrow_date")) + ), + name="actual_after_borrow", + ), + models.UniqueConstraint( + fields=["book", "user"], + condition=models.Q(actual_return_date__isnull=True), + name="unique_active_borrowing", + ), + ] diff --git a/borrowings/serializers.py b/borrowings/serializers.py new file mode 100644 index 0000000..9c8f0c5 --- /dev/null +++ b/borrowings/serializers.py @@ -0,0 +1,90 @@ +from typing import Any + +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from books.models import Book +from books.serializers import BookSerializer +from borrowings.models import Borrowing +from notifications.tasks import notify_new_borrowing +from users.serializers import UserSerializer + + +class BorrowingSerializer(serializers.ModelSerializer): + + class Meta: + model = Borrowing + fields = ( + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + ) + read_only_fields = ("id", "borrow_date", "actual_return_date") + + +class BorrowingListSerializer(serializers.ModelSerializer): + payments = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + book = serializers.SlugRelatedField( + many=False, + read_only=True, + slug_field="title", + ) + user = serializers.SlugRelatedField( + many=False, + read_only=True, + slug_field="email", + ) + + class Meta: + model = Borrowing + fields = ( + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + "user", + "payments", + ) + read_only_fields = ( + "id", + "borrow_date", + "actual_return_date", + "book", + "user", + ) + + +class BorrowingDetailSerializer(BorrowingListSerializer): + book = BookSerializer() + user = UserSerializer() + + +class BorrowingCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Borrowing + fields = ("expected_return_date", "book") + + @transaction.atomic + def create(self, validated_data: dict[str, Any]) -> Borrowing: + book = validated_data.pop("book") + book.inventory -= 1 + book.save() + borrowing = Borrowing.objects.create(book=book, **validated_data) + transaction.on_commit(lambda: notify_new_borrowing.delay(borrowing.id)) + return borrowing + + def validate_book(self, value: Book) -> Book: + if value.inventory <= 0: + raise serializers.ValidationError("Not enough books") + return value + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + Borrowing.validate_expected_return_date( + expected_return_date=attrs["expected_return_date"], + borrow_date=timezone.now().date(), + error_to_raise=serializers.ValidationError, + ) + return attrs diff --git a/borrowings/tests/__init__.py b/borrowings/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borrowings/tests/test_models.py b/borrowings/tests/test_models.py new file mode 100644 index 0000000..5b86381 --- /dev/null +++ b/borrowings/tests/test_models.py @@ -0,0 +1,96 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model + +from datetime import date, timedelta + +from books.models import Book +from borrowings.models import Borrowing + + +User = get_user_model() + + +class BorrowingModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="denis@example.com", password="12345" + ) + + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=5, + daily_fee=2.5, + ) + + self.borrow_date = date.today() + self.expected_return_date = self.borrow_date + timedelta(days=7) + + def test_create_borrowing(self): + expected_return = date.today() + timedelta(days=7) + borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=expected_return, + ) + self.assertEqual(borrowing.user, self.user) + self.assertEqual(borrowing.book, self.book) + self.assertIsNone(borrowing.actual_return_date) + self.assertEqual( + str(borrowing), + f"From: {borrowing.borrow_date} " + f"till: {borrowing.expected_return_date} " + f"returned: {borrowing.actual_return_date}", + ) + + def test_expected_return_date_constraint(self): + past_date = date.today() - timedelta(days=1) + with self.assertRaises(Exception): + Borrowing.objects.create( + user=self.user, book=self.book, expected_return_date=past_date + ) + + def test_actual_return_date_constraint(self): + expected_return = date.today() + timedelta(days=7) + borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=expected_return, + ) + borrowing.actual_return_date = borrowing.borrow_date - timedelta( + days=1 + ) + with self.assertRaises(Exception): + borrowing.save() + + def test_unique_active_borrowing_constraint(self): + expected_return = date.today() + timedelta(days=7) + Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=expected_return, + ) + with self.assertRaises(Exception): + Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=expected_return, + ) + + def test_allow_new_borrow_after_return(self): + expected_return = date.today() + timedelta(days=7) + borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=expected_return, + ) + borrowing.actual_return_date = date.today() + borrowing.save() + + new_borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=date.today() + timedelta(days=7), + ) + self.assertIsNotNone(new_borrowing) diff --git a/borrowings/tests/test_serializers.py b/borrowings/tests/test_serializers.py new file mode 100644 index 0000000..0dbd546 --- /dev/null +++ b/borrowings/tests/test_serializers.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from datetime import date, timedelta +from books.models import Book +from borrowings.models import Borrowing +from borrowings.serializers import ( + BorrowingSerializer, + BorrowingCreateSerializer, +) + +User = get_user_model() + + +class BorrowingSerializerTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="test@example.com", password="12345" + ) + self.book = Book.objects.create( + title="Book", + author="Author", + cover="HARD", + inventory=2, + daily_fee=1.0, + ) + self.borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=date.today() + timedelta(days=3), + ) + + def test_borrowing_serializer_data(self): + serializer = BorrowingSerializer(self.borrowing) + data = serializer.data + self.assertEqual(data["id"], self.borrowing.id) + self.assertEqual(data["borrow_date"], str(self.borrowing.borrow_date)) + + def test_borrowing_create_serializer_valid(self): + data = { + "expected_return_date": str(date.today() + timedelta(days=5)), + "book": self.book.id, + } + serializer = BorrowingCreateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_borrowing_create_serializer_invalid_inventory(self): + self.book.inventory = 0 + self.book.save() + data = { + "expected_return_date": str(date.today() + timedelta(days=5)), + "book": self.book.id, + } + serializer = BorrowingCreateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("book", serializer.errors) diff --git a/borrowings/tests/test_views.py b/borrowings/tests/test_views.py new file mode 100644 index 0000000..7fce9ba --- /dev/null +++ b/borrowings/tests/test_views.py @@ -0,0 +1,64 @@ +from django.urls import reverse +from django.test import TransactionTestCase +from django.db import transaction +from rest_framework.test import APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from unittest.mock import patch + +from books.models import Book +from borrowings.models import Borrowing + + +User = get_user_model() + + +class BorrowingViewSetTest(TransactionTestCase): + def setUp(self): + self.user = User.objects.create_user( + email="testuser@example.com", password="12345" + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.book = Book.objects.create( + title="Book", + author="Author", + cover="HARD", + inventory=2, + daily_fee=1.0, + ) + self.book2 = Book.objects.create( + title="Book 2", + author="Author 2", + cover="SOFT", + inventory=1, + daily_fee=1.5, + ) + self.borrowing = Borrowing.objects.create( + user=self.user, book=self.book, expected_return_date="2030-01-01" + ) + + def test_list_borrowings(self): + url = reverse("borrowings:borrowing-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + if "results" in response.data: + self.assertEqual(len(response.data["results"]), 1) + else: + self.assertEqual(len(response.data), 1) + + def test_retrieve_borrowing(self): + url = reverse("borrowings:borrowing-detail", args=[self.borrowing.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], self.borrowing.id) + + @patch("borrowings.serializers.notify_new_borrowing.delay") + def test_create_borrowing(self, mock_notify_task): + url = reverse("borrowings:borrowing-list") + data = {"expected_return_date": "2030-01-10", "book": self.book2.id} + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Borrowing.objects.count(), 2) + + mock_notify_task.assert_called_once() diff --git a/borrowings/urls.py b/borrowings/urls.py new file mode 100644 index 0000000..177c922 --- /dev/null +++ b/borrowings/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework import routers + +from borrowings.views import BorrowingViewSet + +app_name = "borrowings" + +router = routers.DefaultRouter() +router.register("", BorrowingViewSet, basename="borrowing") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/borrowings/views.py b/borrowings/views.py new file mode 100644 index 0000000..0c0706d --- /dev/null +++ b/borrowings/views.py @@ -0,0 +1,190 @@ +from decimal import Decimal +from typing import Any + +from django.db import transaction +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view, extend_schema +from rest_framework import mixins, viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from borrowings.models import Borrowing +from borrowings.serializers import ( + BorrowingCreateSerializer, + BorrowingDetailSerializer, + BorrowingListSerializer, + BorrowingSerializer, +) +from payments.models import Payment, PaymentType +from payments.stripe_helper import create_stripe_session + + +FINE_MULTIPLIER = 2 + + +@extend_schema_view( + list=extend_schema( + summary="List Borrowings", + description="Staff see all borrowings," + " users see only their own. Supports filters.", + tags=["Borrowings"], + ), + retrieve=extend_schema( + summary="Borrowing Details", + description="Staff can view any borrowing, users only their own.", + tags=["Borrowings"], + ), + create=extend_schema( + summary="Create Borrowing", + description="Create a borrowing." + " A Stripe payment session is generated.", + tags=["Borrowings"], + ), + return_book=extend_schema( + summary="Return Borrowing", + description="Return a borrowed book and increase inventory.", + tags=["Borrowings"], + ), +) +class BorrowingViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet, +): + permission_classes = (IsAuthenticated,) + filter_backends = [DjangoFilterBackend] + filterset_fields = ["user", "book", "borrow_date", "actual_return_date"] + + def get_serializer_class(self) -> type: + if self.action == "list": + return BorrowingListSerializer + if self.action == "retrieve": + return BorrowingDetailSerializer + if self.action == "create": + return BorrowingCreateSerializer + return BorrowingSerializer + + def perform_create(self, serializer: BorrowingCreateSerializer) -> None: + borrowing = serializer.save(user=self.request.user) + + session_data = create_stripe_session( + borrowing=borrowing, + payment_type=PaymentType.PAYMENT, + request=self.request, + ) + + Payment.objects.create( + borrowing=borrowing, + session_id=session_data["session_id"], + session_url=session_data["session_url"], + money_to_pay=session_data["amount"], + payment_type=PaymentType.PAYMENT, + status="PENDING", + ) + + def get_queryset(self) -> Any: + queryset = Borrowing.objects.all().select_related("book", "user") + user = self.request.user + if not user.is_staff: + queryset = queryset.filter(user=user) + return queryset + + @action( + detail=True, + methods=["post"], + url_path="return", + permission_classes=[IsAuthenticated], + ) + def return_book(self, request: Request, pk: str = None) -> Response: + borrowing = self.get_object() + + if not request.user.is_staff and borrowing.user != request.user: + return Response( + {"detail": "You don't have permission to return this book."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if borrowing.actual_return_date is not None: + return Response( + {"detail": "This book has already been returned."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + today = timezone.now().date() + + with transaction.atomic(): + borrowing.actual_return_date = timezone.now().date() + borrowing.save() + borrowing.book.inventory += 1 + borrowing.book.save() + + if today > borrowing.expected_return_date: + fine_amount = self.calculate_fine(borrowing, today) + + try: + session_data = create_stripe_session( + borrowing=borrowing, + payment_type=PaymentType.FINE, + request=request, + fine_amount=fine_amount, + ) + + Payment.objects.create( + borrowing=borrowing, + session_id=session_data["session_id"], + session_url=session_data["session_url"], + money_to_pay=fine_amount, + payment_type=PaymentType.FINE, + status="PENDING", + ) + + serializer = self.get_serializer(borrowing) + return Response( + { + "borrowing": serializer.data, + "message": "Book returned successfully, " + " but you have a fine to pay", + "days_overdue": ( + today - borrowing.expected_return_date + ).days, + "fine_amount": str(fine_amount), + "payment_url": session_data["session_url"], + }, + status=status.HTTP_200_OK, + ) + + except Exception as e: + serializer = self.get_serializer(borrowing) + return Response( + { + "borrowing": serializer.data, + "message": "Book returned successfully," + " but error creating fine payment", + "error": str(e), + "days_overdue": ( + today - borrowing.expected_return_date + ).days, + "fine_amount": str(fine_amount), + }, + status=status.HTTP_200_OK, + ) + + serializer = self.get_serializer(borrowing) + return Response(serializer.data, status=status.HTTP_200_OK) + + def calculate_fine( + self, borrowing: Borrowing, actual_return_date: Any + ) -> Decimal: + days_overdue = ( + actual_return_date - borrowing.expected_return_date + ).days + daily_fee = borrowing.book.daily_fee + + fine_amount = ( + Decimal(days_overdue) * daily_fee * Decimal(FINE_MULTIPLIER) + ) + return fine_amount diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..085ba3b --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,4 @@ +from core.celery import app as celery_app + + +__all__ = ("celery_app",) diff --git a/core/asgi.py b/core/asgi.py new file mode 100644 index 0000000..49f4a0a --- /dev/null +++ b/core/asgi.py @@ -0,0 +1,19 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + + +settings_module = os.environ.get("DJANGO_SETTINGS_MODULE", "core.settings.dev") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module) + + +application = get_asgi_application() diff --git a/core/celery.py b/core/celery.py new file mode 100644 index 0000000..af7eccf --- /dev/null +++ b/core/celery.py @@ -0,0 +1,10 @@ +import os + +from celery import Celery + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.dev") + +app = Celery("core") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/core/management/__init__.py b/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/wait_for_db.py b/core/management/commands/wait_for_db.py new file mode 100644 index 0000000..8dd9472 --- /dev/null +++ b/core/management/commands/wait_for_db.py @@ -0,0 +1,32 @@ +import time +import os + +import psycopg2 +from psycopg2 import OperationalError +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Waits for the database to be available." + + def handle(self, *args, **options): + db_host = os.environ["POSTGRES_HOST"] + db_name = os.environ["POSTGRES_DB"] + db_user = os.environ["POSTGRES_USER"] + db_pass = os.environ["POSTGRES_PASSWORD"] + db_port = os.environ["POSTGRES_PORT"] + while True: + try: + conn = psycopg2.connect( + dbname=db_name, + user=db_user, + password=db_pass, + host=db_host, + port=db_port, + ) + conn.close() + self.stdout.write(self.style.SUCCESS("Database is ready!")) + break + except OperationalError: + self.stdout.write("Waiting for database...", ending="\r") + time.sleep(1) diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..efb5749 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,12 @@ +from rest_framework import permissions +from rest_framework.request import Request +from rest_framework.views import APIView + + +class IsStaffUser(permissions.BasePermission): + def has_permission(self, request: Request, view: APIView) -> bool: + return bool( + request.user + and request.user.is_authenticated + and request.user.is_staff + ) diff --git a/core/settings/__init__.py b/core/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/settings/base.py b/core/settings/base.py new file mode 100644 index 0000000..15bcf8a --- /dev/null +++ b/core/settings/base.py @@ -0,0 +1,145 @@ +from datetime import timedelta +from pathlib import Path +import os +from dotenv import load_dotenv + +from celery.schedules import crontab + + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] + +INSTALLED_APPS = [ + # Django core apps + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Third-party apps + "debug_toolbar", + "django_filters", + "drf_spectacular", + "rest_framework", + "rest_framework_simplejwt", + # Local apps + "core", + "users", + "books", + "borrowings", + "payments", + "notifications", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "core.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "core.wsgi.application" + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "Europe/Kyiv" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" + +MEDIA_URL = "/media/" +MEDIA_ROOT = "/files/media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + "rest_framework.filters.OrderingFilter", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 10, +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Library Service API", + "DESCRIPTION": "Book borrowing and payment management service", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "defaultModelRendering": "model", + "defaultModelsExpandDepth": 2, + "defaultModelExpandDepth": 2, + }, +} + +AUTH_USER_MODEL = "users.User" + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, +} + +STRIPE_PUBLISHABLE_KEY = os.environ["STRIPE_PUBLISHABLE_KEY"] +STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"] + +TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] +TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"] + +CELERY_BROKER_URL = "redis://redis:6379/0" +CELERY_RESULT_BACKEND = "redis://redis:6379/0" +CELERY_BEAT_SCHEDULE = { + "check-overdue-borrowings-every-day": { + "task": "notifications.tasks.check_overdue_borrowings", + "schedule": crontab(hour=9, minute=0), + }, +} diff --git a/core/settings/dev.py b/core/settings/dev.py new file mode 100644 index 0000000..794a3ab --- /dev/null +++ b/core/settings/dev.py @@ -0,0 +1,19 @@ +from core.settings.base import * + + +DEBUG = True + +ALLOWED_HOSTS = ["localhost", "127.0.0.1"] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ["POSTGRES_DB"], + "USER": os.environ["POSTGRES_USER"], + "PASSWORD": os.environ["POSTGRES_PASSWORD"], + "HOST": os.environ["POSTGRES_HOST"], + "PORT": os.environ["POSTGRES_PORT"], + } +} + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/core/settings/prod.py b/core/settings/prod.py new file mode 100644 index 0000000..c255d55 --- /dev/null +++ b/core/settings/prod.py @@ -0,0 +1,17 @@ +from core.settings.base import * + + +DEBUG = False + +ALLOWED_HOSTS = [os.environ["PRODUCTION_HOST"]] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ["POSTGRES_DB"], + "USER": os.environ["POSTGRES_USER"], + "PASSWORD": os.environ["POSTGRES_PASSWORD"], + "HOST": os.environ["POSTGRES_HOST"], + "PORT": os.environ["POSTGRES_PORT"], + } +} diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..0b42b92 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,46 @@ +""" +URL configuration for core project. + +The "urlpatterns" list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path("", views.home, name="home") +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path("", Home.as_view(), name="home") +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path("blog/", include("blog.urls")) +""" + +import debug_toolbar +from django.contrib import admin +from django.urls import path, include +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + + +urlpatterns = [ + path("__debug__/", include(debug_toolbar.urls)), + path("admin/", admin.site.urls), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + path("users/", include("users.urls")), + path("books/", include("books.urls")), + path("borrowings/", include("borrowings.urls")), + path("payments/", include("payments.urls")), +] diff --git a/core/wsgi.py b/core/wsgi.py new file mode 100644 index 0000000..356176a --- /dev/null +++ b/core/wsgi.py @@ -0,0 +1,19 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + + +settings_module = os.environ.get("DJANGO_SETTINGS_MODULE", "core.settings.dev") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module) + + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8113f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + env_file: + - .env + + app: + build: . + command: > + sh -c "python manage.py wait_for_db && \ + python manage.py makemigrations && \ + python manage.py migrate && \ + python manage.py runserver 0.0.0.0:8000" + volumes: + - .:/app + - my_media:/files/media + ports: + - "8000:8000" + env_file: + - .env + environment: + - IN_DOCKER=true + depends_on: + - db + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + celery_worker: + build: . + command: celery -A core worker --loglevel=info + volumes: + - .:/app + depends_on: + - app + - redis + + celery_beat: + build: . + command: celery -A core beat --loglevel=info + volumes: + - .:/app + depends_on: + - app + - redis + +volumes: + postgres_data: + my_media: diff --git a/fixtures.json b/fixtures.json new file mode 100644 index 0000000..30068d6 --- /dev/null +++ b/fixtures.json @@ -0,0 +1,1167 @@ +[ + { + "model": "books.book", + "pk": 1, + "fields": { + "title": "The Infinite Loop", + "author": "Mark Lewis", + "cover": "HARD", + "inventory": 48, + "daily_fee": 1.63 + } + }, + { + "model": "books.book", + "pk": 2, + "fields": { + "title": "The Golden Age", + "author": "Patricia Martin", + "cover": "HARD", + "inventory": 48, + "daily_fee": 4.38 + } + }, + { + "model": "books.book", + "pk": 3, + "fields": { + "title": "Flames of Passion", + "author": "Linda Garcia", + "cover": "SOFT", + "inventory": 19, + "daily_fee": 14.5 + } + }, + { + "model": "books.book", + "pk": 4, + "fields": { + "title": "Quest for Knowledge", + "author": "Kenneth Wright", + "cover": "SOFT", + "inventory": 17, + "daily_fee": 12.12 + } + }, + { + "model": "books.book", + "pk": 5, + "fields": { + "title": "Songs of Freedom", + "author": "Michelle Turner", + "cover": "HARD", + "inventory": 45, + "daily_fee": 12.22 + } + }, + { + "model": "books.book", + "pk": 6, + "fields": { + "title": "The Great Adventure", + "author": "Emily Johnson", + "cover": "SOFT", + "inventory": 45, + "daily_fee": 11.15 + } + }, + { + "model": "books.book", + "pk": 7, + "fields": { + "title": "The Midnight Hour", + "author": "Robert Taylor", + "cover": "SOFT", + "inventory": 16, + "daily_fee": 5.57 + } + }, + { + "model": "books.book", + "pk": 8, + "fields": { + "title": "The Diamond Ring", + "author": "Mark Lewis", + "cover": "HARD", + "inventory": 35, + "daily_fee": 2.32 + } + }, + { + "model": "books.book", + "pk": 9, + "fields": { + "title": "The Silver Moon", + "author": "Emily Johnson", + "cover": "HARD", + "inventory": 48, + "daily_fee": 9.89 + } + }, + { + "model": "books.book", + "pk": 10, + "fields": { + "title": "Winds of Change", + "author": "Jennifer Thomas", + "cover": "SOFT", + "inventory": 1, + "daily_fee": 6.72 + } + }, + { + "model": "books.book", + "pk": 11, + "fields": { + "title": "The Golden Age", + "author": "Paul Allen", + "cover": "SOFT", + "inventory": 44, + "daily_fee": 1.67 + } + }, + { + "model": "books.book", + "pk": 12, + "fields": { + "title": "The Infinite Loop", + "author": "Mary White", + "cover": "SOFT", + "inventory": 19, + "daily_fee": 11.35 + } + }, + { + "model": "books.book", + "pk": 13, + "fields": { + "title": "Castles in the Air", + "author": "Sandra Scott", + "cover": "HARD", + "inventory": 10, + "daily_fee": 13.14 + } + }, + { + "model": "books.book", + "pk": 14, + "fields": { + "title": "Art of Living", + "author": "Carol Gonzalez", + "cover": "SOFT", + "inventory": 17, + "daily_fee": 7.33 + } + }, + { + "model": "books.book", + "pk": 15, + "fields": { + "title": "Path to Enlightenment", + "author": "Elizabeth Robinson", + "cover": "HARD", + "inventory": 16, + "daily_fee": 1.11 + } + }, + { + "model": "books.book", + "pk": 16, + "fields": { + "title": "Shadows of Yesterday", + "author": "Michael Brown", + "cover": "HARD", + "inventory": 2, + "daily_fee": 14.94 + } + }, + { + "model": "books.book", + "pk": 17, + "fields": { + "title": "Whispers in the Dark", + "author": "Dorothy King", + "cover": "SOFT", + "inventory": 36, + "daily_fee": 4.02 + } + }, + { + "model": "books.book", + "pk": 18, + "fields": { + "title": "Secrets of the Ancient World", + "author": "Christopher Nelson", + "cover": "HARD", + "inventory": 3, + "daily_fee": 1.75 + } + }, + { + "model": "books.book", + "pk": 19, + "fields": { + "title": "The Last Kingdom", + "author": "Jennifer Thomas", + "cover": "SOFT", + "inventory": 28, + "daily_fee": 2.68 + } + }, + { + "model": "books.book", + "pk": 20, + "fields": { + "title": "The Steel Fortress", + "author": "Sandra Scott", + "cover": "HARD", + "inventory": 35, + "daily_fee": 14.49 + } + }, + { + "model": "books.book", + "pk": 21, + "fields": { + "title": "The Glass House", + "author": "Michael Brown", + "cover": "SOFT", + "inventory": 11, + "daily_fee": 1.57 + } + }, + { + "model": "books.book", + "pk": 22, + "fields": { + "title": "The Infinite Loop", + "author": "Sarah Davis", + "cover": "SOFT", + "inventory": 16, + "daily_fee": 10.81 + } + }, + { + "model": "books.book", + "pk": 23, + "fields": { + "title": "The Purple Mountain", + "author": "Mary White", + "cover": "HARD", + "inventory": 13, + "daily_fee": 9.69 + } + }, + { + "model": "books.book", + "pk": 24, + "fields": { + "title": "Winds of Change", + "author": "Matthew Mitchell", + "cover": "SOFT", + "inventory": 20, + "daily_fee": 4.15 + } + }, + { + "model": "books.book", + "pk": 25, + "fields": { + "title": "Digital Revolution", + "author": "Christopher Nelson", + "cover": "SOFT", + "inventory": 1, + "daily_fee": 1.94 + } + }, + { + "model": "books.book", + "pk": 26, + "fields": { + "title": "The Silent Storm", + "author": "Dorothy King", + "cover": "HARD", + "inventory": 33, + "daily_fee": 6.69 + } + }, + { + "model": "books.book", + "pk": 27, + "fields": { + "title": "Tales from the Past", + "author": "Dorothy King", + "cover": "SOFT", + "inventory": 17, + "daily_fee": 0.95 + } + }, + { + "model": "books.book", + "pk": 28, + "fields": { + "title": "Dreams and Reality", + "author": "Anthony Roberts", + "cover": "HARD", + "inventory": 2, + "daily_fee": 13.58 + } + }, + { + "model": "books.book", + "pk": 29, + "fields": { + "title": "Dreams and Reality", + "author": "Christopher Nelson", + "cover": "HARD", + "inventory": 7, + "daily_fee": 5.55 + } + }, + { + "model": "books.book", + "pk": 30, + "fields": { + "title": "The Golden Key", + "author": "Anthony Roberts", + "cover": "SOFT", + "inventory": 25, + "daily_fee": 10.7 + } + }, + { + "model": "books.book", + "pk": 31, + "fields": { + "title": "The Pearl of Wisdom", + "author": "Ruth Carter", + "cover": "HARD", + "inventory": 3, + "daily_fee": 7.52 + } + }, + { + "model": "books.book", + "pk": 32, + "fields": { + "title": "Rivers of Time", + "author": "Kenneth Wright", + "cover": "HARD", + "inventory": 25, + "daily_fee": 9.41 + } + }, + { + "model": "books.book", + "pk": 33, + "fields": { + "title": "The Hidden Truth", + "author": "Charles Baker", + "cover": "SOFT", + "inventory": 17, + "daily_fee": 1.06 + } + }, + { + "model": "books.book", + "pk": 34, + "fields": { + "title": "Voices from Above", + "author": "Joseph Hill", + "cover": "HARD", + "inventory": 1, + "daily_fee": 5.18 + } + }, + { + "model": "books.book", + "pk": 35, + "fields": { + "title": "Castles in the Air", + "author": "Matthew Martinez", + "cover": "HARD", + "inventory": 22, + "daily_fee": 1.24 + } + }, + { + "model": "books.book", + "pk": 36, + "fields": { + "title": "The Midnight Hour", + "author": "Christopher Thompson", + "cover": "HARD", + "inventory": 48, + "daily_fee": 4.97 + } + }, + { + "model": "books.book", + "pk": 37, + "fields": { + "title": "Secrets of Success", + "author": "Elizabeth Robinson", + "cover": "HARD", + "inventory": 47, + "daily_fee": 1.64 + } + }, + { + "model": "books.book", + "pk": 38, + "fields": { + "title": "The Golden Age", + "author": "Paul Allen", + "cover": "HARD", + "inventory": 6, + "daily_fee": 13.78 + } + }, + { + "model": "books.book", + "pk": 39, + "fields": { + "title": "The Glass House", + "author": "Dorothy King", + "cover": "SOFT", + "inventory": 30, + "daily_fee": 11.11 + } + }, + { + "model": "books.book", + "pk": 40, + "fields": { + "title": "Flames of Passion", + "author": "Sarah Davis", + "cover": "SOFT", + "inventory": 39, + "daily_fee": 1.14 + } + }, + { + "model": "books.book", + "pk": 41, + "fields": { + "title": "Future Horizons", + "author": "Jennifer Thomas", + "cover": "SOFT", + "inventory": 25, + "daily_fee": 9.69 + } + }, + { + "model": "books.book", + "pk": 42, + "fields": { + "title": "Quest for Knowledge", + "author": "Patricia Martin", + "cover": "SOFT", + "inventory": 27, + "daily_fee": 14.95 + } + }, + { + "model": "books.book", + "pk": 43, + "fields": { + "title": "Bridges to Tomorrow", + "author": "Lisa Lopez", + "cover": "SOFT", + "inventory": 29, + "daily_fee": 0.58 + } + }, + { + "model": "books.book", + "pk": 44, + "fields": { + "title": "The Silent Storm", + "author": "Robert Taylor", + "cover": "HARD", + "inventory": 37, + "daily_fee": 11.41 + } + }, + { + "model": "books.book", + "pk": 45, + "fields": { + "title": "Mystery of the Lost City", + "author": "Thomas Green", + "cover": "HARD", + "inventory": 19, + "daily_fee": 4.82 + } + }, + { + "model": "books.book", + "pk": 46, + "fields": { + "title": "Whispers in the Dark", + "author": "Christopher Thompson", + "cover": "SOFT", + "inventory": 23, + "daily_fee": 0.92 + } + }, + { + "model": "books.book", + "pk": 47, + "fields": { + "title": "The Crimson Tide", + "author": "Nancy Lee", + "cover": "HARD", + "inventory": 48, + "daily_fee": 12.52 + } + }, + { + "model": "books.book", + "pk": 48, + "fields": { + "title": "Art of Living", + "author": "Donna Adams", + "cover": "HARD", + "inventory": 32, + "daily_fee": 0.99 + } + }, + { + "model": "books.book", + "pk": 49, + "fields": { + "title": "The Copper Crown", + "author": "Donna Adams", + "cover": "SOFT", + "inventory": 21, + "daily_fee": 9.79 + } + }, + { + "model": "books.book", + "pk": 50, + "fields": { + "title": "Shadows of the Mind", + "author": "Thomas Green", + "cover": "HARD", + "inventory": 28, + "daily_fee": 11.18 + } + }, + { + "model": "books.book", + "pk": 51, + "fields": { + "title": "The Diamond Ring", + "author": "Michelle Turner", + "cover": "SOFT", + "inventory": 22, + "daily_fee": 13.54 + } + }, + { + "model": "books.book", + "pk": 52, + "fields": { + "title": "The Golden Age", + "author": "Michelle Turner", + "cover": "SOFT", + "inventory": 6, + "daily_fee": 7.57 + } + }, + { + "model": "books.book", + "pk": 53, + "fields": { + "title": "Secrets of Success", + "author": "Michelle Turner", + "cover": "HARD", + "inventory": 17, + "daily_fee": 10.89 + } + }, + { + "model": "books.book", + "pk": 54, + "fields": { + "title": "Oceans of Wonder", + "author": "Ruth Carter", + "cover": "SOFT", + "inventory": 22, + "daily_fee": 7.73 + } + }, + { + "model": "books.book", + "pk": 55, + "fields": { + "title": "The Hidden Truth", + "author": "Patricia Martin", + "cover": "HARD", + "inventory": 26, + "daily_fee": 11.3 + } + }, + { + "model": "books.book", + "pk": 56, + "fields": { + "title": "Secrets of the Ancient World", + "author": "Michelle Turner", + "cover": "SOFT", + "inventory": 33, + "daily_fee": 13.82 + } + }, + { + "model": "books.book", + "pk": 57, + "fields": { + "title": "Science and Nature", + "author": "Anthony Roberts", + "cover": "SOFT", + "inventory": 22, + "daily_fee": 5.89 + } + }, + { + "model": "books.book", + "pk": 58, + "fields": { + "title": "Whispers in the Dark", + "author": "Betty Young", + "cover": "HARD", + "inventory": 11, + "daily_fee": 8.44 + } + }, + { + "model": "books.book", + "pk": 59, + "fields": { + "title": "Whispers in the Dark", + "author": "Ruth Carter", + "cover": "HARD", + "inventory": 19, + "daily_fee": 7.02 + } + }, + { + "model": "books.book", + "pk": 60, + "fields": { + "title": "Secrets of Success", + "author": "Christopher Nelson", + "cover": "SOFT", + "inventory": 16, + "daily_fee": 1.69 + } + }, + { + "model": "books.book", + "pk": 61, + "fields": { + "title": "The Hidden Truth", + "author": "Steven Walker", + "cover": "HARD", + "inventory": 18, + "daily_fee": 3.04 + } + }, + { + "model": "books.book", + "pk": 62, + "fields": { + "title": "Gardens of Memory", + "author": "Michael Brown", + "cover": "SOFT", + "inventory": 35, + "daily_fee": 5.97 + } + }, + { + "model": "books.book", + "pk": 63, + "fields": { + "title": "The Steel Fortress", + "author": "Robert Taylor", + "cover": "SOFT", + "inventory": 12, + "daily_fee": 8.91 + } + }, + { + "model": "books.book", + "pk": 64, + "fields": { + "title": "The Copper Crown", + "author": "Dorothy King", + "cover": "HARD", + "inventory": 23, + "daily_fee": 11.51 + } + }, + { + "model": "books.book", + "pk": 65, + "fields": { + "title": "Songs of Freedom", + "author": "Karen Hall", + "cover": "HARD", + "inventory": 34, + "daily_fee": 3.4 + } + }, + { + "model": "books.book", + "pk": 66, + "fields": { + "title": "Shadows of Yesterday", + "author": "Dorothy King", + "cover": "SOFT", + "inventory": 8, + "daily_fee": 0.82 + } + }, + { + "model": "books.book", + "pk": 67, + "fields": { + "title": "The Steel Fortress", + "author": "Elizabeth Robinson", + "cover": "HARD", + "inventory": 2, + "daily_fee": 10.79 + } + }, + { + "model": "books.book", + "pk": 68, + "fields": { + "title": "Castles in the Air", + "author": "Anthony Clark", + "cover": "SOFT", + "inventory": 30, + "daily_fee": 2.6 + } + }, + { + "model": "books.book", + "pk": 69, + "fields": { + "title": "The Glass House", + "author": "Linda Garcia", + "cover": "HARD", + "inventory": 7, + "daily_fee": 14.64 + } + }, + { + "model": "books.book", + "pk": 70, + "fields": { + "title": "The Ruby Slippers", + "author": "Carol Gonzalez", + "cover": "SOFT", + "inventory": 9, + "daily_fee": 2.08 + } + }, + { + "model": "books.book", + "pk": 71, + "fields": { + "title": "The Purple Mountain", + "author": "Carol Gonzalez", + "cover": "SOFT", + "inventory": 38, + "daily_fee": 13.86 + } + }, + { + "model": "books.book", + "pk": 72, + "fields": { + "title": "Secrets of the Ancient World", + "author": "David Wilson", + "cover": "SOFT", + "inventory": 19, + "daily_fee": 3.75 + } + }, + { + "model": "books.book", + "pk": 73, + "fields": { + "title": "Mountains of Hope", + "author": "Anthony Clark", + "cover": "HARD", + "inventory": 37, + "daily_fee": 8.87 + } + }, + { + "model": "books.book", + "pk": 74, + "fields": { + "title": "Art of Living", + "author": "Daniel Hernandez", + "cover": "HARD", + "inventory": 27, + "daily_fee": 5.87 + } + }, + { + "model": "books.book", + "pk": 75, + "fields": { + "title": "The Pearl of Wisdom", + "author": "Dorothy King", + "cover": "SOFT", + "inventory": 17, + "daily_fee": 14.14 + } + }, + { + "model": "books.book", + "pk": 76, + "fields": { + "title": "Beyond the Stars", + "author": "Paul Allen", + "cover": "HARD", + "inventory": 12, + "daily_fee": 8.92 + } + }, + { + "model": "books.book", + "pk": 77, + "fields": { + "title": "Future Horizons", + "author": "Lisa Anderson", + "cover": "HARD", + "inventory": 13, + "daily_fee": 4.16 + } + }, + { + "model": "books.book", + "pk": 78, + "fields": { + "title": "The Great Adventure", + "author": "Christopher Nelson", + "cover": "SOFT", + "inventory": 24, + "daily_fee": 5.92 + } + }, + { + "model": "books.book", + "pk": 79, + "fields": { + "title": "The Copper Crown", + "author": "Anthony Roberts", + "cover": "SOFT", + "inventory": 18, + "daily_fee": 0.74 + } + }, + { + "model": "books.book", + "pk": 80, + "fields": { + "title": "Bridges to Tomorrow", + "author": "Ruth Carter", + "cover": "HARD", + "inventory": 24, + "daily_fee": 4.99 + } + }, + { + "model": "books.book", + "pk": 81, + "fields": { + "title": "The Hidden Truth", + "author": "Mark Lewis", + "cover": "HARD", + "inventory": 42, + "daily_fee": 2.51 + } + }, + { + "model": "books.book", + "pk": 82, + "fields": { + "title": "The Midnight Hour", + "author": "Karen Hall", + "cover": "HARD", + "inventory": 4, + "daily_fee": 12.28 + } + }, + { + "model": "books.book", + "pk": 83, + "fields": { + "title": "Science and Nature", + "author": "Sharon Perez", + "cover": "SOFT", + "inventory": 7, + "daily_fee": 10.75 + } + }, + { + "model": "books.book", + "pk": 84, + "fields": { + "title": "The Infinite Loop", + "author": "Donna Adams", + "cover": "SOFT", + "inventory": 22, + "daily_fee": 13.31 + } + }, + { + "model": "books.book", + "pk": 85, + "fields": { + "title": "The Ruby Slippers", + "author": "James Harris", + "cover": "SOFT", + "inventory": 25, + "daily_fee": 1.63 + } + }, + { + "model": "books.book", + "pk": 86, + "fields": { + "title": "The Great Adventure", + "author": "Thomas Green", + "cover": "SOFT", + "inventory": 7, + "daily_fee": 6.15 + } + }, + { + "model": "books.book", + "pk": 87, + "fields": { + "title": "Winds of Change", + "author": "Steven Walker", + "cover": "SOFT", + "inventory": 40, + "daily_fee": 13.01 + } + }, + { + "model": "books.book", + "pk": 88, + "fields": { + "title": "The Purple Mountain", + "author": "Kenneth Wright", + "cover": "SOFT", + "inventory": 11, + "daily_fee": 7.09 + } + }, + { + "model": "books.book", + "pk": 89, + "fields": { + "title": "The Golden Age", + "author": "Lisa Lopez", + "cover": "SOFT", + "inventory": 38, + "daily_fee": 13.09 + } + }, + { + "model": "books.book", + "pk": 90, + "fields": { + "title": "Science and Nature", + "author": "David Wilson", + "cover": "HARD", + "inventory": 40, + "daily_fee": 9.81 + } + }, + { + "model": "books.book", + "pk": 91, + "fields": { + "title": "Whispers in the Dark", + "author": "Jennifer Thomas", + "cover": "SOFT", + "inventory": 15, + "daily_fee": 6.11 + } + }, + { + "model": "books.book", + "pk": 92, + "fields": { + "title": "Tales from the Past", + "author": "Mary White", + "cover": "SOFT", + "inventory": 39, + "daily_fee": 9.66 + } + }, + { + "model": "books.book", + "pk": 93, + "fields": { + "title": "Whispers in the Dark", + "author": "Ruth Carter", + "cover": "SOFT", + "inventory": 34, + "daily_fee": 12.3 + } + }, + { + "model": "books.book", + "pk": 94, + "fields": { + "title": "Quest for Knowledge", + "author": "Elizabeth Robinson", + "cover": "SOFT", + "inventory": 24, + "daily_fee": 12.96 + } + }, + { + "model": "books.book", + "pk": 95, + "fields": { + "title": "Mysteries Unveiled", + "author": "Thomas Green", + "cover": "SOFT", + "inventory": 3, + "daily_fee": 13.03 + } + }, + { + "model": "books.book", + "pk": 96, + "fields": { + "title": "Whispers in the Dark", + "author": "Lisa Anderson", + "cover": "SOFT", + "inventory": 47, + "daily_fee": 8.42 + } + }, + { + "model": "books.book", + "pk": 97, + "fields": { + "title": "The Silver Sword", + "author": "Helen Rodriguez", + "cover": "HARD", + "inventory": 15, + "daily_fee": 8.64 + } + }, + { + "model": "books.book", + "pk": 98, + "fields": { + "title": "The Silver Sword", + "author": "James Harris", + "cover": "SOFT", + "inventory": 47, + "daily_fee": 12.89 + } + }, + { + "model": "books.book", + "pk": 99, + "fields": { + "title": "Gardens of Memory", + "author": "Nancy Lee", + "cover": "HARD", + "inventory": 29, + "daily_fee": 12.21 + } + }, + { + "model": "books.book", + "pk": 100, + "fields": { + "title": "Secrets of Success", + "author": "Daniel Hernandez", + "cover": "SOFT", + "inventory": 39, + "daily_fee": 10.55 + } + }, + { + "model": "users.user", + "pk": 1, + "fields": { + "email": "admin_1@example.com", + "first_name": "Andrii", + "last_name": "Tkachenko", + "password": "pbkdf2_sha256$1000000$WDH8un3KYK5sV8VCOJS1vc$6DPCcVYaWamk/oneHwy+d8R80JFm0FT+nLihdlRSDlQ=", + "is_staff": true, + "is_superuser": true, + "is_active": true + } + }, + { + "model": "users.user", + "pk": 2, + "fields": { + "email": "staff_2@example.com", + "first_name": "Dmytro", + "last_name": "Shevchenko", + "password": "pbkdf2_sha256$1000000$WDH8un3KYK5sV8VCOJS1vc$6DPCcVYaWamk/oneHwy+d8R80JFm0FT+nLihdlRSDlQ=", + "is_staff": true, + "is_superuser": false, + "is_active": true + } + }, + { + "model": "users.user", + "pk": 3, + "fields": { + "email": "user_3@example.com", + "first_name": "Oleh", + "last_name": "Moroz", + "password": "pbkdf2_sha256$1000000$WDH8un3KYK5sV8VCOJS1vc$6DPCcVYaWamk/oneHwy+d8R80JFm0FT+nLihdlRSDlQ=", + "is_staff": false, + "is_superuser": false, + "is_active": true + } + }, + { + "model": "users.user", + "pk": 4, + "fields": { + "email": "user_4@example.com", + "first_name": "Olga", + "last_name": "Shevchenko", + "password": "pbkdf2_sha256$1000000$WDH8un3KYK5sV8VCOJS1vc$6DPCcVYaWamk/oneHwy+d8R80JFm0FT+nLihdlRSDlQ=", + "is_staff": false, + "is_superuser": false, + "is_active": true + } + }, + { + "model": "users.user", + "pk": 5, + "fields": { + "email": "user_5@example.com", + "first_name": "Oleh", + "last_name": "Kravchenko", + "password": "pbkdf2_sha256$1000000$WDH8un3KYK5sV8VCOJS1vc$6DPCcVYaWamk/oneHwy+d8R80JFm0FT+nLihdlRSDlQ=", + "is_staff": false, + "is_superuser": false, + "is_active": true + } + } +] diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..b70f4ab --- /dev/null +++ b/manage.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + settings_module = os.environ.get( + "DJANGO_SETTINGS_MODULE", "core.settings.dev" + ) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module) + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..6898c2f --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/tasks.py b/notifications/tasks.py new file mode 100644 index 0000000..4c4d4fb --- /dev/null +++ b/notifications/tasks.py @@ -0,0 +1,73 @@ +from celery import shared_task +from django.utils import timezone + +from borrowings.models import Borrowing +from payments.models import Payment +from notifications.telegram_helper import send_telegram_message + + +@shared_task +def notify_new_borrowing(borrowing_id: int) -> None: + try: + borrowing = Borrowing.objects.select_related("book", "user").get( + id=borrowing_id + ) + message = ( + f"๐Ÿ“š New Borrowing Created!\n" + f"๐Ÿ‘ค User: {borrowing.user.get_full_name()}\n" + f"๐Ÿ“– Book: {borrowing.book.title}\n" + f"๐Ÿ“… Borrowed: {borrowing.borrow_date}\n" + f"๐Ÿ“… Expected Return: {borrowing.expected_return_date}\n" + f"๐Ÿ’ฐ Daily Fee: ${borrowing.book.daily_fee}" + ) + send_telegram_message(message) + except Borrowing.DoesNotExist: + pass + + +@shared_task +def notify_successful_payment(payment_id: int) -> None: + try: + payment = Payment.objects.select_related( + "borrowing__book", "borrowing__user" + ).get(id=payment_id) + + borrowing = payment.borrowing + message = ( + f"๐Ÿ’ฐ Payment Completed!\n" + f"๐Ÿ‘ค User: {borrowing.user.get_full_name()}\n" + f"๐Ÿ“š Book: {borrowing.book.title}\n" + f"๐Ÿ’ต Amount: ${payment.money_to_pay}\n" + f"๐Ÿ’ณ Payment Type: {payment.payment_type}\n" + f"โœ… Status: {payment.status}\n" + f"๐Ÿ“… Borrowed: {borrowing.borrow_date}\n" + f"๐Ÿ“… Expected Return: {borrowing.expected_return_date}" + ) + send_telegram_message(message) + except Exception: + pass + + +@shared_task +def check_overdue_borrowings() -> None: + today = timezone.now().date() + overdues = Borrowing.objects.filter( + expected_return_date__lte=today, actual_return_date__isnull=True + ).select_related("book", "user") + + if overdues.exists(): + for borrowing in overdues: + days_overdue = (today - borrowing.expected_return_date).days + message = ( + f"โš ๏ธ Overdue Borrowing Alert!\n" + f"๐Ÿ‘ค User: {borrowing.user.get_full_name()}\n" + f"๐Ÿ“š Book: {borrowing.book.title}\n" + f"๐Ÿ“… Borrowed: {borrowing.borrow_date}\n" + f"๐Ÿ“… Expected Return: {borrowing.expected_return_date}\n" + f"โฐ Days Overdue: {days_overdue}\n" + f"โŒ Status: Not returned!\n" + f"๐Ÿ’ธ Daily Fee: ${borrowing.book.daily_fee}" + ) + send_telegram_message(message) + else: + send_telegram_message("โœ… No borrowings overdue today!") diff --git a/notifications/telegram_helper.py b/notifications/telegram_helper.py new file mode 100644 index 0000000..49acc09 --- /dev/null +++ b/notifications/telegram_helper.py @@ -0,0 +1,19 @@ +import requests + +from core.settings.base import ( + load_dotenv, + TELEGRAM_BOT_TOKEN, + TELEGRAM_CHAT_ID, +) + + +load_dotenv() + + +def send_telegram_message(text: str) -> bool: + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: + return False + url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" + data = {"chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "HTML"} + response = requests.post(url, data=data) + return response.ok diff --git a/notifications/tests/__init__.py b/notifications/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py new file mode 100644 index 0000000..668f555 --- /dev/null +++ b/notifications/tests/test_tasks.py @@ -0,0 +1,57 @@ +from datetime import date, timedelta + +import pytest +from django.contrib.auth import get_user_model +from unittest.mock import patch + +from books.models import Book +from borrowings.models import Borrowing +from notifications.tasks import notify_new_borrowing, check_overdue_borrowings + + +@pytest.mark.django_db +def test_notify_new_borrowing_sends_message(): + User = get_user_model() + user = User.objects.create_user(email="test@example.com", password="pass") + book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=5, + daily_fee=1.00, + ) + borrowing = Borrowing.objects.create( + user=user, + book=book, + expected_return_date=date.today() + timedelta(days=7), + ) + with patch("notifications.tasks.send_telegram_message") as mock_send: + mock_send.return_value = True + notify_new_borrowing(borrowing.id) + mock_send.assert_called_once() + + +@pytest.mark.django_db +def test_check_overdue_borrowings_sends_message(): + User = get_user_model() + user = User.objects.create_user(email="test2@example.com", password="pass") + book = Book.objects.create( + title="Test Book 2", + author="Test Author", + cover="SOFT", + inventory=3, + daily_fee=2.00, + ) + borrow_date = date.today() + expected_return_date = date.today() + timedelta(days=5) + + overdue_borrowing = Borrowing.objects.create( + user=user, + book=book, + borrow_date=borrow_date, + expected_return_date=expected_return_date, + ) + with patch("notifications.tasks.send_telegram_message") as mock_send: + mock_send.return_value = True + check_overdue_borrowings() + assert mock_send.called diff --git a/notifications/tests/test_telegram_helper.py b/notifications/tests/test_telegram_helper.py new file mode 100644 index 0000000..2895910 --- /dev/null +++ b/notifications/tests/test_telegram_helper.py @@ -0,0 +1,17 @@ +from unittest.mock import patch + +from notifications.telegram_helper import send_telegram_message + + +def test_send_telegram_message_success(): + with patch("notifications.telegram_helper.requests.post") as mock_post: + mock_post.return_value.ok = True + assert send_telegram_message("test") is True + mock_post.assert_called_once() + + +def test_send_telegram_message_fail(): + with patch("notifications.telegram_helper.requests.post") as mock_post: + mock_post.return_value.ok = False + assert send_telegram_message("test") is False + mock_post.assert_called_once() diff --git a/payments/__init__.py b/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/admin.py b/payments/admin.py new file mode 100644 index 0000000..1b1516b --- /dev/null +++ b/payments/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from payments.models import Payment + + +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "status", + "payment_type", + "borrowing", + "money_to_pay", + "session_id", + ) + list_filter = ("status", "payment_type") + search_fields = ("session_id", "borrowing__id") + readonly_fields = ("session_url", "session_id") + ordering = ("-id",) diff --git a/payments/apps.py b/payments/apps.py new file mode 100644 index 0000000..bf05bf1 --- /dev/null +++ b/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payments" diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py new file mode 100644 index 0000000..c67238e --- /dev/null +++ b/payments/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.6 on 2025-09-22 11:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + 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")], + db_index=True, + default="PENDING", + max_length=10, + ), + ), + ( + "payment_type", + models.CharField( + choices=[("PAYMENT", "PAYMENT"), ("FINE", "FINE")], + db_index=True, + default="PAYMENT", + max_length=10, + ), + ), + ("borrowing_id", models.PositiveIntegerField(db_index=True)), + ( + "session_url", + models.URLField(blank=True, max_length=512, null=True), + ), + ( + "session_id", + models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), + ), + ( + "money_to_pay", + models.DecimalField(decimal_places=2, max_digits=10), + ), + ], + options={ + "db_table": "payment", + "constraints": [ + models.CheckConstraint( + condition=models.Q(("money_to_pay__gte", 0)), + name="money_to_pay_non_negative", + ), + models.UniqueConstraint( + condition=models.Q(("status", "PENDING")), + fields=("borrowing_id", "payment_type"), + name="uniq_pending_payment_per_borrowing_type_tmp", + ), + ], + }, + ), + ] diff --git a/payments/migrations/0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more.py b/payments/migrations/0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more.py new file mode 100644 index 0000000..0e5c324 --- /dev/null +++ b/payments/migrations/0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.6 on 2025-09-22 15:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("borrowings", "0001_initial"), + ("payments", "0001_initial"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="payment", + name="uniq_pending_payment_per_borrowing_type_tmp", + ), + migrations.RemoveField( + model_name="payment", + name="borrowing_id", + ), + migrations.AddField( + model_name="payment", + name="borrowing", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="payments", + to="borrowings.borrowing", + ), + ), + migrations.AddConstraint( + model_name="payment", + constraint=models.UniqueConstraint( + condition=models.Q(("status", "PENDING")), + fields=("borrowing", "payment_type"), + name="uniq_pending_payment_per_borrowing_type", + ), + ), + ] diff --git a/payments/migrations/0003_alter_payment_borrowing_alter_payment_status.py b/payments/migrations/0003_alter_payment_borrowing_alter_payment_status.py new file mode 100644 index 0000000..3dc0797 --- /dev/null +++ b/payments/migrations/0003_alter_payment_borrowing_alter_payment_status.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.6 on 2025-09-23 09:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("borrowings", "0001_initial"), + ( + "payments", + "0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="payment", + name="borrowing", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="borrowings.borrowing", + ), + ), + migrations.AlterField( + model_name="payment", + name="status", + field=models.CharField( + choices=[ + ("PENDING", "PENDING"), + ("PAID", "PAID"), + ("CANCELLED", "CANCELLED"), + ("EXCEEDED", "EXCEEDED"), + ("FAILED", "FAILED"), + ], + db_index=True, + default="PENDING", + max_length=10, + ), + ), + ] diff --git a/payments/migrations/0004_alter_payment_status.py b/payments/migrations/0004_alter_payment_status.py new file mode 100644 index 0000000..85f3a89 --- /dev/null +++ b/payments/migrations/0004_alter_payment_status.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.6 on 2025-09-23 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0003_alter_payment_borrowing_alter_payment_status"), + ] + + operations = [ + migrations.AlterField( + model_name="payment", + name="status", + field=models.CharField( + choices=[ + ("PENDING", "PENDING"), + ("PAID", "PAID"), + ("CANCELLED", "CANCELLED"), + ("EXPIRED", "EXPIRED"), + ("FAILED", "FAILED"), + ], + db_index=True, + default="PENDING", + max_length=10, + ), + ), + ] diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/models.py b/payments/models.py new file mode 100644 index 0000000..a09039f --- /dev/null +++ b/payments/models.py @@ -0,0 +1,71 @@ +from django.db import models +from django.db.models import Q # noqa + +from borrowings.models import Borrowing + + +class PaymentStatus(models.TextChoices): + PENDING = "PENDING", "PENDING" + PAID = "PAID", "PAID" + CANCELLED = "CANCELLED", "CANCELLED" + EXPIRED = "EXPIRED", "EXPIRED" + FAILED = "FAILED", "FAILED" + + +class PaymentType(models.TextChoices): + PAYMENT = "PAYMENT", "PAYMENT" + FINE = "FINE", "FINE" + + +class Payment(models.Model): + """Payment model. Used to store payments. + The status of the payment is stored in the status field. + The type of the payment is stored in the type field.""" + + status = models.CharField( + max_length=10, + choices=PaymentStatus.choices, + default=PaymentStatus.PENDING, + db_index=True, + ) + payment_type = models.CharField( + max_length=10, + choices=PaymentType.choices, + default=PaymentType.PAYMENT, + db_index=True, + ) + + borrowing = models.ForeignKey( + Borrowing, + on_delete=models.CASCADE, + related_name="payments", + db_index=True, + null=True, + blank=True, + ) + + session_url = models.URLField(max_length=512, blank=True, null=True) + session_id = models.CharField( + max_length=255, blank=True, null=True, db_index=True + ) + + money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + db_table = "payment" + constraints = [ + models.CheckConstraint( + condition=Q(money_to_pay__gte=0), + name="money_to_pay_non_negative", + ), + models.UniqueConstraint( + fields=["borrowing", "payment_type"], + condition=Q(status=PaymentStatus.PENDING), + name="uniq_pending_payment_per_borrowing_type", + ), + ] + + def __str__(self): + return f"""Payment(id={self.pk}, status={self.status}, + type={self.payment_type}, borrowing_id={self.borrowing_id}, + amount={self.money_to_pay} USD)""" diff --git a/payments/serializers.py b/payments/serializers.py new file mode 100644 index 0000000..64962d5 --- /dev/null +++ b/payments/serializers.py @@ -0,0 +1,46 @@ +from decimal import Decimal + +from rest_framework import serializers + +from borrowings.models import Borrowing +from borrowings.serializers import BorrowingSerializer +from payments.models import Payment, PaymentStatus, PaymentType + + +class PaymentSerializer(serializers.ModelSerializer): + borrowing = serializers.PrimaryKeyRelatedField( + queryset=Borrowing.objects.all(), + ) + + class Meta: + model = Payment + fields = [ + "id", + "status", + "payment_type", + "borrowing", + "session_url", + "session_id", + "money_to_pay", + ] + read_only_fields = ["id", "session_url", "session_id"] + + +class PaymentListSerializer(serializers.ModelSerializer): + borrowing = serializers.SlugRelatedField(read_only=True, slug_field="id") + + class Meta: + model = Payment + fields = ( + "id", + "status", + "payment_type", + "borrowing", + "money_to_pay", + ) + + +class PaymentDetailSerializer(PaymentSerializer): + borrowing = BorrowingSerializer( + read_only=True, + ) diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py new file mode 100644 index 0000000..062c66d --- /dev/null +++ b/payments/stripe_helper.py @@ -0,0 +1,93 @@ +from decimal import Decimal +from typing import Any, Optional + +import stripe +from django.conf import settings +from django.http import HttpRequest +from django.urls import reverse + +from borrowings.models import Borrowing +from payments.models import PaymentType + +stripe.api_key = settings.STRIPE_SECRET_KEY + + +def create_stripe_session( + borrowing: Borrowing, + payment_type: str = PaymentType.PAYMENT, + request: Optional[HttpRequest] = None, + fine_amount: Optional[Decimal] = None, +) -> dict[str, Any]: + """ + Create a Stripe checkout session for a borrowing. + + Args: + borrowing: Borrowing instance + payment_type: PaymentType (PAYMENT or FINE) + request: Django request object for building absolute URIs + fine_amount: Decimal amount for FINE payments (required for FINE) + + Returns: + dict: Contains session_id and session_url and amount + """ + if payment_type == PaymentType.PAYMENT: + borrow_days = ( + borrowing.expected_return_date - borrowing.borrow_date + ).days + if borrow_days <= 0: + borrow_days = 1 + total_price = borrowing.book.daily_fee * Decimal(borrow_days) + description = f"Book rental for {borrow_days} days" + product_name = f"Book Rental: {borrowing.book.title}" + + elif payment_type == PaymentType.FINE: + if fine_amount is None: + raise ValueError("fine_amount is required for FINE payments") + + if not borrowing.actual_return_date: + raise ValueError( + "actual_return_date is required for FINE payments" + ) + + total_price = fine_amount + days_overdue = ( + borrowing.actual_return_date - borrowing.expected_return_date + ).days + description = f"Fine for {days_overdue} days overdue" + product_name = f"Overdue Fine: {borrowing.book.title}" + + else: + raise ValueError(f"Unsupported payment_type: {payment_type}") + + amount_in_cents = int(total_price * 100) + + try: + session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[ + { + "price_data": { + "currency": "usd", + "product_data": { + "name": product_name, + "description": description, + }, + "unit_amount": amount_in_cents, + }, + "quantity": 1, + } + ], + mode="payment", + success_url=request.build_absolute_uri(reverse("payments:success")) + + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=request.build_absolute_uri(reverse("payments:cancel")), + ) + + return { + "session_id": session.id, + "session_url": session.url, + "amount": total_price, + } + + except stripe.error.StripeError as e: + raise Exception(f"Stripe error: {str(e)}") diff --git a/payments/tests/__init__.py b/payments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py new file mode 100644 index 0000000..4b3c6e2 --- /dev/null +++ b/payments/tests/test_endpoints.py @@ -0,0 +1,72 @@ +from decimal import Decimal +from datetime import date, timedelta + +from django.urls import reverse +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model + +from payments.models import Payment, PaymentStatus, PaymentType +from books.models import Book +from borrowings.models import Borrowing + + +class PaymentEndpointsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.list_url = reverse("payments:payment-list") + self.user = get_user_model().objects.create_user( + email="user2@example.com", password="testpass123" + ) + self._book_idx = 0 + + def _create_book(self) -> Book: + self._book_idx += 1 + return Book.objects.create( + title=f"Book E{self._book_idx}", + author="Author", + cover="HARD", + inventory=10, + daily_fee=Decimal("1.25"), + ) + + def _create_borrowing(self, *, user=None) -> Borrowing: + user = user or self.user + book = self._create_book() + return Borrowing.objects.create( + user=user, + book=book, + expected_return_date=date.today() + timedelta(days=5), + ) + + def test_create_ignores_read_only_fields(self): + self.client.force_authenticate(user=self.user) + borrowing = self._create_borrowing() + payload = { + "payment_type": PaymentType.PAYMENT, + "borrowing": borrowing.id, + "money_to_pay": "12.34", + "session_url": "https://malicious.example/override", + "session_id": "fake-session", + } + resp = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + self.assertIsNone(resp.data.get("session_url")) + self.assertIsNone(resp.data.get("session_id")) + + obj = Payment.objects.get(pk=resp.data["id"]) + self.assertIsNone(obj.session_url) + self.assertIsNone(obj.session_id) + + def test_create_invalid_payment_type_returns_400(self): + self.client.force_authenticate(user=self.user) + borrowing = self._create_borrowing() + payload = { + "payment_type": "INVALID", + "borrowing": borrowing.id, + "money_to_pay": "10.00", + } + resp = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("payment_type", resp.data) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py new file mode 100644 index 0000000..bdda1d5 --- /dev/null +++ b/payments/tests/test_models.py @@ -0,0 +1,150 @@ +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase + +from payments.models import Payment, PaymentStatus, PaymentType +from django.contrib.auth import get_user_model +from books.models import Book +from borrowings.models import Borrowing +from datetime import date, timedelta + + +class PaymentModelTests(TestCase): + def _create_user(self): + return get_user_model().objects.create_user( + email="modeltests@example.com", password="testpass123" + ) + + def _create_book(self, idx: int = 1) -> Book: + return Book.objects.create( + title=f"Payment Test Book {idx}", + author="Author", + cover="HARD", + inventory=10, + daily_fee=Decimal("1.00"), + ) + + def _create_borrowing(self, idx: int = 1) -> Borrowing: + user = self._create_user() + book = self._create_book(idx) + return Borrowing.objects.create( + user=user, + book=book, + expected_return_date=date.today() + timedelta(days=7), + ) + + def create_payment( + self, + *, + borrowing: Borrowing | None = None, + status: str = PaymentStatus.PENDING, + payment_type: str = PaymentType.PAYMENT, + money_to_pay: Decimal = Decimal("10.00"), + session_url: str | None = None, + session_id: str | None = None, + ) -> Payment: + if borrowing is None: + borrowing = self._create_borrowing() + return Payment.objects.create( + borrowing=borrowing, + status=status, + payment_type=payment_type, + money_to_pay=money_to_pay, + session_url=session_url, + session_id=session_id, + ) + + def test_create_payment_success(self): + b = self._create_borrowing() + p = self.create_payment(borrowing=b) + self.assertIsNotNone(p.id) + self.assertEqual(p.status, PaymentStatus.PENDING) + self.assertEqual(p.payment_type, PaymentType.PAYMENT) + self.assertEqual(p.borrowing_id, b.id) + self.assertEqual(p.money_to_pay, Decimal("10.00")) + self.assertIsNone(p.session_url) + self.assertIsNone(p.session_id) + + def test_money_to_pay_non_negative_constraint(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + self.create_payment(money_to_pay=Decimal("-0.01")) + + # zero is allowed + p = self.create_payment(money_to_pay=Decimal("0.00")) + self.assertEqual(p.money_to_pay, Decimal("0.00")) + + def test_unique_pending_per_borrowing_and_type_constraint(self): + b = self._create_borrowing() + self.create_payment( + borrowing=b, + payment_type=PaymentType.PAYMENT, + status=PaymentStatus.PENDING, + ) + + # Second pending with same pair should fail + with self.assertRaises(IntegrityError): + with transaction.atomic(): + self.create_payment( + borrowing=b, + payment_type=PaymentType.PAYMENT, + status=PaymentStatus.PENDING, + ) + + # But a PAID for same pair is allowed + p_paid = self.create_payment( + borrowing=b, + payment_type=PaymentType.PAYMENT, + status=PaymentStatus.PAID, + ) + self.assertEqual(p_paid.status, PaymentStatus.PAID) + + # And a PENDING with a different type is allowed + p_other_type = self.create_payment( + borrowing=b, + payment_type=PaymentType.FINE, + status=PaymentStatus.PENDING, + ) + self.assertEqual(p_other_type.payment_type, PaymentType.FINE) + + def test_choices_validation_with_full_clean(self): + b = self._create_borrowing() + p = Payment( + borrowing=b, + status="INVALID", + payment_type=PaymentType.PAYMENT, + money_to_pay=Decimal("5.00"), + ) + with self.assertRaises(ValidationError): + p.full_clean() + + p2 = Payment( + borrowing=b, + status=PaymentStatus.PENDING, + payment_type="INVALID", + money_to_pay=Decimal("5.00"), + ) + with self.assertRaises(ValidationError): + p2.full_clean() + + def test_session_fields_optional(self): + b = self._create_borrowing() + p = self.create_payment(borrowing=b, session_url=None, session_id=None) + self.assertIsNone(p.session_url) + self.assertIsNone(p.session_id) + + def test_str_representation(self): + b = self._create_borrowing(idx=5) + p = self.create_payment( + borrowing=b, + status=PaymentStatus.PENDING, + payment_type=PaymentType.FINE, + money_to_pay=Decimal("12.34"), + ) + s = str(p) + self.assertIn("status=PENDING", s) + self.assertIn("type=FINE", s) + self.assertIn(f"borrowing_id={b.id}", s) + self.assertIn("amount=12.34 USD", s) diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py new file mode 100644 index 0000000..f692bd0 --- /dev/null +++ b/payments/tests/test_serializers.py @@ -0,0 +1,131 @@ +from decimal import Decimal +from django.test import TestCase +from payments.models import Payment, PaymentStatus, PaymentType +from payments.serializers import ( + PaymentSerializer, + PaymentListSerializer, +) +from borrowings.models import Borrowing +from books.models import Book +from django.contrib.auth import get_user_model +from datetime import date, timedelta + + +User = get_user_model() + + +class PaymentSerializerTests(TestCase): + def _create_user(self): + return get_user_model().objects.create_user( + email="ser@test.com", password="testpass123" + ) + + def _create_book(self): + return Book.objects.create( + title="S Book", + author="Author", + cover="HARD", + inventory=5, + daily_fee=Decimal("1.00"), + ) + + def _create_borrowing(self): + return Borrowing.objects.create( + user=self._create_user(), + book=self._create_book(), + expected_return_date=date.today() + timedelta(days=3), + ) + + def test_create_payment_valid(self): + borrowing = self._create_borrowing() + data = { + "payment_type": PaymentType.PAYMENT, + "borrowing": borrowing.id, + "money_to_pay": "15.50", + } + serializer = PaymentSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + obj = serializer.save() + + self.assertIsInstance(obj, Payment) + self.assertIsNotNone(obj.id) + self.assertEqual(obj.status, PaymentStatus.PENDING) + self.assertEqual(obj.payment_type, PaymentType.PAYMENT) + self.assertEqual(obj.borrowing_id, borrowing.id) + self.assertEqual(obj.money_to_pay, Decimal("15.50")) + self.assertIsNone(obj.session_id) + self.assertIsNone(obj.session_url) + + def test_invalid_status(self): + data = { + "status": "INVALID", + "payment_type": PaymentType.FINE, + "borrowing_id": 1, + "money_to_pay": "10.00", + } + serializer = PaymentSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("status", serializer.errors) + + def test_invalid_payment_type(self): + data = { + "payment_type": "INVALID", + "borrowing_id": 1, + "money_to_pay": "10.00", + } + serializer = PaymentSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("payment_type", serializer.errors) + + def test_missing_money_to_pay(self): + data = { + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 1, + } + serializer = PaymentSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("money_to_pay", serializer.errors) + + +class PaymentListSerializerTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="testlist@example.com", password="testpass123" + ) + self.book = Book.objects.create( + title="Test Book List", + author="Test Author", + cover="HARD", + inventory=5, + daily_fee=Decimal("2.50"), + ) + self.borrowing = Borrowing.objects.create( + expected_return_date=date.today() + timedelta(days=7), + book=self.book, + user=self.user, + ) + self.payment = Payment.objects.create( + status=PaymentStatus.PENDING, + payment_type=PaymentType.PAYMENT, + borrowing=self.borrowing, + money_to_pay=Decimal("35.00"), + ) + + def test_payment_list_serializer_fields(self): + """Test PaymentListSerializer contains only expected fields""" + serializer = PaymentListSerializer(instance=self.payment) + expected_fields = { + "id", + "status", + "payment_type", + "borrowing", + "money_to_pay", + } + self.assertEqual(set(serializer.data.keys()), expected_fields) + + def test_payment_list_serializer_borrowing_as_id(self): + """Test PaymentListSerializer returns borrowing as ID not nested object""" + serializer = PaymentListSerializer(instance=self.payment) + # borrowing should be just the ID, not nested data + self.assertEqual(serializer.data["borrowing"], self.borrowing.id) + self.assertIsInstance(serializer.data["borrowing"], int) diff --git a/payments/tests/test_stripe.py b/payments/tests/test_stripe.py new file mode 100644 index 0000000..b75feea --- /dev/null +++ b/payments/tests/test_stripe.py @@ -0,0 +1,166 @@ +import json +from decimal import Decimal +from datetime import date, timedelta + +from django.urls import reverse +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model +from unittest.mock import patch, Mock + +from payments.models import Payment, PaymentStatus, PaymentType +from books.models import Book +from borrowings.models import Borrowing + + +class StripeIntegrationTests(TestCase): + def setUp(self): + self.client = APIClient() + self.webhook_url = reverse("payments:webhook") + self.test_success_url = reverse("payments:test-success") + + self.user = get_user_model().objects.create_user( + email="user@example.com", password="testpass123" + ) + + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=10, + daily_fee=Decimal("1.50"), + ) + + self.borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=date.today() + timedelta(days=5), + ) + + self.payment = Payment.objects.create( + borrowing=self.borrowing, + session_id="cs_test_session_id_123", + session_url="https://checkout.stripe.com/pay/test", + money_to_pay=Decimal("15.00"), + payment_type=PaymentType.PAYMENT, + status="PENDING", + ) + + @patch("payments.views.notify_successful_payment.delay") + @patch("django.db.transaction.on_commit", side_effect=lambda func: func()) + def test_stripe_webhook_successful_payment( + self, mock_on_commit, mock_notify + ): + """Test Stripe webhook handles successful payment correctly""" + webhook_payload = { + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_test_session_id_123", + "payment_status": "paid", + "amount_total": 1500, # in cents + "currency": "usd", + } + }, + } + + response = self.client.post( + self.webhook_url, + data=json.dumps(webhook_payload), + content_type="application/json", + HTTP_STRIPE_SIGNATURE="fake_signature", + ) + + # Check response + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Check payment status was updated + self.payment.refresh_from_db() + self.assertEqual(self.payment.status, "PAID") + + # Check notification was triggered + mock_notify.assert_called_once_with(self.payment.id) + + def test_stripe_webhook_payment_not_found(self): + """Test webhook gracefully handles non-existent payment""" + webhook_payload = { + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_nonexistent_session_id", + "payment_status": "paid", + } + }, + } + + response = self.client.post( + self.webhook_url, + data=json.dumps(webhook_payload), + content_type="application/json", + HTTP_STRIPE_SIGNATURE="fake_signature", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch("payments.views.notify_successful_payment.delay") + @patch("django.db.transaction.on_commit", side_effect=lambda func: func()) + def test_payment_test_success_view(self, mock_on_commit, mock_notify): + """Test PaymentTestSuccessView updates payment status correctly""" + self.client.force_authenticate(user=self.user) + + payload = {"session_id": "cs_test_session_id_123"} + + response = self.client.post( + self.test_success_url, data=payload, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn( + "Payment status updated to PAID (TEST MODE)", + response.data["message"], + ) + self.assertEqual(response.data["payment_id"], self.payment.id) + self.assertEqual(response.data["status"], "PAID") + + # Check payment was updated in database + self.payment.refresh_from_db() + self.assertEqual(self.payment.status, "PAID") + + # Check notification was triggered + mock_notify.assert_called_once_with(self.payment.id) + + def test_payment_test_success_view_requires_authentication(self): + """Test that PaymentTestSuccessView requires authentication""" + payload = {"session_id": "cs_test_session_id_123"} + + response = self.client.post( + self.test_success_url, data=payload, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_payment_test_success_view_missing_session_id(self): + """Test PaymentTestSuccessView validates required session_id""" + self.client.force_authenticate(user=self.user) + + # Empty payload + response = self.client.post( + self.test_success_url, data={}, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error"], "session_id is required") + + def test_payment_test_success_view_nonexistent_payment(self): + """Test PaymentTestSuccessView handles non-existent payment""" + self.client.force_authenticate(user=self.user) + + payload = {"session_id": "cs_nonexistent_session_id"} + + response = self.client.post( + self.test_success_url, data=payload, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data["error"], "Payment not found") diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py new file mode 100644 index 0000000..a05fec2 --- /dev/null +++ b/payments/tests/test_views.py @@ -0,0 +1,69 @@ +from datetime import timedelta, date +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework import status +from payments.models import Payment, PaymentStatus, PaymentType +from borrowings.models import Borrowing +from books.models import Book + + +class PaymentViewSetTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + email="user@example.com", password="testpass123" + ) + self._book_idx = 0 + + def _create_book(self) -> Book: + self._book_idx += 1 + return Book.objects.create( + title=f"Book {self._book_idx}", + author="Author", + cover="HARD", + inventory=10, + daily_fee=Decimal("1.50"), + ) + + def _create_borrowing(self, *, user=None) -> Borrowing: + user = user or self.user + book = self._create_book() + return Borrowing.objects.create( + user=user, + book=book, + expected_return_date=date.today() + timedelta(days=7), + ) + + def test_create_payment_success(self): + self.client.force_authenticate(user=self.user) + borrowing = self._create_borrowing() + url = reverse("payments:payment-list") + payload = { + "payment_type": PaymentType.PAYMENT, + "borrowing": borrowing.id, + "money_to_pay": "12.34", + } + resp = self.client.post(url, data=payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + self.assertEqual(resp.data["payment_type"], PaymentType.PAYMENT) + self.assertEqual(resp.data["borrowing"], borrowing.id) + self.assertEqual(resp.data["money_to_pay"], "12.34") + self.assertEqual(resp.data["status"], PaymentStatus.PENDING) + + def test_create_payment_invalid_status(self): + self.client.force_authenticate(user=self.user) + borrowing = self._create_borrowing() + url = reverse("payments:payment-list") + payload = { + "status": "INVALID", + "payment_type": PaymentType.PAYMENT, + "borrowing": borrowing.id, + "money_to_pay": "12.34", + } + resp = self.client.post(url, data=payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("status", resp.data) diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 0000000..e72568c --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,24 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from payments.views import ( + PaymentViewSet, + StripeWebhookView, + PaymentTestSuccessView, +) +from payments import views + + +app_name = "payments" + +router = DefaultRouter() +router.register("", PaymentViewSet, basename="payment") +urlpatterns = [ + path("success/", views.PaymentSuccessView.as_view(), name="success"), + path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), + path("webhook/", StripeWebhookView.as_view(), name="webhook"), + path( + "test-success/", PaymentTestSuccessView.as_view(), name="test-success" + ), + path("", include(router.urls)), +] diff --git a/payments/views.py b/payments/views.py new file mode 100644 index 0000000..7dc1497 --- /dev/null +++ b/payments/views.py @@ -0,0 +1,220 @@ +from typing import Any + +import stripe +from django.conf import settings +from django.db import transaction +from django.http import HttpResponse, HttpRequest +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema_view, extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework import mixins, viewsets, status +from rest_framework.views import APIView + +from payments.models import Payment +from payments.serializers import ( + PaymentSerializer, + PaymentListSerializer, + PaymentDetailSerializer, +) +from notifications.tasks import notify_successful_payment + + +@extend_schema_view( + list=extend_schema( + summary="List payments", + description="Returns a list of payments ordered by ID descending.", + responses=PaymentSerializer, + ), + retrieve=extend_schema( + summary="Retrieve a payment", + description="Returns a single payment by its ID.", + responses=PaymentSerializer, + ), + create=extend_schema( + summary="Create a payment", + description="Creates a new payment. session_url" + "and session_id are read-only.", + request=PaymentSerializer, + responses=PaymentSerializer, + ), +) +class PaymentViewSet( + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + + permission_classes = (IsAuthenticated,) + + def get_serializer_class(self) -> type: + if self.action == "list": + return PaymentListSerializer + if self.action == "retrieve": + return PaymentDetailSerializer + return PaymentSerializer + + def get_queryset(self) -> Any: + queryset = Payment.objects.select_related("borrowing") + user = self.request.user + if not user.is_staff: + queryset = queryset.filter(borrowing__user=user) + return queryset + + +class PaymentSuccessView(APIView): + """Handle successful payment callback from Stripe""" + + def _update_payment_status( + self, session_id: str, is_test: bool = False + ) -> Response: + """Common logic for updating payment status""" + try: + with transaction.atomic(): + payment = Payment.objects.select_for_update().get( + session_id=session_id + ) + payment.status = "PAID" + payment.save() + + transaction.on_commit( + lambda: notify_successful_payment.delay(payment.id) + ) + + message = ( + "Payment status updated to PAID (TEST MODE)" + if is_test + else "Payment successful!" + ) + + return Response( + { + "message": message, + "payment_id": payment.id, + "status": payment.status, + } + ) + except Payment.DoesNotExist: + return Response( + {"error": "Payment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + return Response( + {"error": f"Database error: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def get(self, request: Request) -> Response: + session_id = request.GET.get("session_id") + + if not session_id: + return Response( + {"error": "Session ID not provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + stripe.api_key = settings.STRIPE_SECRET_KEY + session = stripe.checkout.Session.retrieve(session_id) + + if session.payment_status == "paid": + return self._update_payment_status(session_id) + else: + return Response( + {"message": "Payment not completed yet"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + except Payment.DoesNotExist: + return Response( + {"error": "Payment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except stripe.error.StripeError as e: + return Response( + {"error": f"Stripe error: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class PaymentCancelView(APIView): + """Handle cancelled payment from Stripe""" + + def get(self, request: Request) -> Response: + return Response( + { + "message": "Payment was cancelled." + " You can complete the payment later," + " but the session is available" + " for only 24 hours." + } + ) + + +class PaymentTestSuccessView(APIView): + """Test endpoint to simulate successful payment - FOR DEVELOPMENT ONLY""" + + permission_classes = (IsAuthenticated,) + + def post(self, request: Request) -> Response: + session_id = request.data.get("session_id") + + if not session_id: + return Response( + {"error": "session_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + success_view = PaymentSuccessView() + return success_view._update_payment_status(session_id, is_test=True) + + +@method_decorator(csrf_exempt, name="dispatch") +class StripeWebhookView(APIView): + """Handle Stripe webhook events""" + + def post(self, request: HttpRequest) -> HttpResponse: + payload = request.body + sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") + endpoint_secret = getattr(settings, "STRIPE_WEBHOOK_SECRET", None) + + if endpoint_secret: + try: + event = stripe.Webhook.construct_event( + payload, sig_header, endpoint_secret + ) + except ValueError: + return HttpResponse("Invalid payload", status=400) + except stripe.error.SignatureVerificationError: + return HttpResponse("Invalid signature", status=400) + else: + try: + import json + + event = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return HttpResponse("Invalid payload", status=400) + + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + session_id = session["id"] + + try: + with transaction.atomic(): + payment = Payment.objects.select_for_update().get( + session_id=session_id + ) + if session["payment_status"] == "paid": + payment.status = "PAID" + payment.save() + transaction.on_commit( + lambda: notify_successful_payment.delay(payment.id) + ) + except Payment.DoesNotExist: + pass + + return HttpResponse(status=200) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a763e03 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9353b11 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +django==5.2.6 +djangorestframework==3.16.1 +django-filter==25.1 +drf-spectacular==0.28.0 +python-dotenv==1.1.1 +psycopg2-binary==2.9.10 +django-debug-toolbar==6.0.0 +pytest==8.4.2 +pytest-django==4.11.1 +flake8==7.3.0 +black==25.9.0 +djangorestframework_simplejwt==5.5.1 +stripe==12.5.1 +requests==2.32.5 +celery==5.5.3 +redis==6.4.0 diff --git a/screenshots/Books_swagger.jpg b/screenshots/Books_swagger.jpg new file mode 100644 index 0000000..3b94eb8 Binary files /dev/null and b/screenshots/Books_swagger.jpg differ diff --git a/screenshots/Borrowings_swagger.jpg b/screenshots/Borrowings_swagger.jpg new file mode 100644 index 0000000..73341cc Binary files /dev/null and b/screenshots/Borrowings_swagger.jpg differ diff --git a/screenshots/DB_diagram.jpg b/screenshots/DB_diagram.jpg new file mode 100644 index 0000000..81e706d Binary files /dev/null and b/screenshots/DB_diagram.jpg differ diff --git a/screenshots/User_swagger.jpg b/screenshots/User_swagger.jpg new file mode 100644 index 0000000..2d4e7d1 Binary files /dev/null and b/screenshots/User_swagger.jpg differ diff --git a/screenshots/bot.png b/screenshots/bot.png new file mode 100644 index 0000000..220ae64 Binary files /dev/null and b/screenshots/bot.png differ diff --git a/screenshots/paid.jpg b/screenshots/paid.jpg new file mode 100644 index 0000000..252cfc7 Binary files /dev/null and b/screenshots/paid.jpg differ diff --git a/screenshots/pay.png b/screenshots/pay.png new file mode 100644 index 0000000..9d98ad8 Binary files /dev/null and b/screenshots/pay.png differ diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..d0693e9 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ + +from users.models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + ordering = ("email",) + list_display = ("email", "is_staff", "is_superuser") + fieldsets = ( + (None, {"fields": ("email", "password")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + (_("Important dates"), {"fields": ("last_login",)}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) + search_fields = ("email",) diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..e9121c9 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..b7ae20b --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,138 @@ +# Generated by Django 5.2.6 on 2025-09-20 14:33 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +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", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, + max_length=254, + verbose_name="email address", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="date joined", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/users/migrations/0002_alter_user_options_alter_user_managers_and_more.py b/users/migrations/0002_alter_user_options_alter_user_managers_and_more.py new file mode 100644 index 0000000..89bf17c --- /dev/null +++ b/users/migrations/0002_alter_user_options_alter_user_managers_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.6 on 2025-09-20 19:58 + +import users.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="user", + options={ + "ordering": ["-date_joined"], + "verbose_name": "user", + "verbose_name_plural": "users", + }, + ), + migrations.AlterModelManagers( + name="user", + managers=[ + ("objects", users.models.UserManager()), + ], + ), + migrations.RemoveField( + model_name="user", + name="username", + ), + migrations.AlterField( + model_name="user", + name="email", + field=models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), + ), + ] diff --git a/users/migrations/0003_alter_user_table.py b/users/migrations/0003_alter_user_table.py new file mode 100644 index 0000000..7033471 --- /dev/null +++ b/users/migrations/0003_alter_user_table.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-09-23 19:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0002_alter_user_options_alter_user_managers_and_more"), + ] + + operations = [ + migrations.AlterModelTable( + name="user", + table="users", + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..27e8766 --- /dev/null +++ b/users/models.py @@ -0,0 +1,64 @@ +from typing import Any + +from django.contrib.auth.models import AbstractUser, BaseUserManager +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class UserManager(BaseUserManager): + """Define a model manager for Usermodel with no username field.""" + + use_in_migrations = True + + def _create_user( + self, email: str, password: str, **extra_fields: Any + ) -> "User": + """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: str, password: str = None, **extra_fields: Any + ) -> "User": + """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: str, password: str, **extra_fields: Any + ) -> "User": + """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() + + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + ordering = ["-date_joined"] + db_table = "users" + + def __str__(self): + return f"Email: {self.email}" diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..1b9b003 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,88 @@ +from typing import Any + +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +User = get_user_model() + + +class RegisterSerializer(serializers.ModelSerializer): + """Serializer used only for registering a new user.""" + + password = serializers.CharField( + write_only=True, min_length=8, style={"input_type": "password"} + ) + + class Meta: + model = User + fields = ("email", "password") + + def create(self, validated_data: dict[str, Any]) -> User: + return User.objects.create_user(**validated_data) + + def validate_email(self, value: str) -> str: + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError( + "User with this email already exists." + ) + return value + + +class UserSerializer(serializers.ModelSerializer): + """Read-only serializer for retrieving user profile.""" + + class Meta: + model = User + fields = ("id", "email", "first_name", "last_name") + read_only_fields = ("id", "email") + + +class UserUpdateSerializer(serializers.ModelSerializer): + """Serializer for updating user profile.""" + + password = serializers.CharField( + write_only=True, + min_length=8, + required=False, + style={"input_type": "password"}, + ) + + class Meta: + model = User + fields = ("id", "email", "first_name", "last_name", "password") + read_only_fields = ("id",) + + def update(self, instance: User, validated_data: dict[str, Any]) -> User: + password = validated_data.pop("password", None) + user = super().update(instance, validated_data) + if password: + user.set_password(password) + user.save() + return user + + def validate_email(self, value: str) -> str: + user = self.instance + if User.objects.exclude(pk=user.pk).filter(email=value).exists(): + raise serializers.ValidationError( + "User with this email already exists." + ) + return value + + def validate_first_name(self, value: str) -> str: + if not value.strip(): + raise serializers.ValidationError("First name cannot be blank.") + if len(value) < 2: + raise serializers.ValidationError( + "First name must be at least 2 characters long." + ) + return value + + def validate_last_name(self, value: str) -> str: + if not value.strip(): + raise serializers.ValidationError("Last name cannot be blank.") + if len(value) < 2: + raise serializers.ValidationError( + "Last name must be at least 2 characters long." + ) + return value diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/tests/test_jwt.py b/users/tests/test_jwt.py new file mode 100644 index 0000000..f68db32 --- /dev/null +++ b/users/tests/test_jwt.py @@ -0,0 +1,37 @@ +from rest_framework.test import APITestCase +from rest_framework import status +from django.contrib.auth import get_user_model + + +class JWTAuthTests(APITestCase): + def test_obtain_token(self): + User = get_user_model() + User.objects.create_user( + email="obtain@token.com", password="password123" + ) + + url = "/users/token/" + response = self.client.post( + url, {"email": "obtain@token.com", "password": "password123"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) + self.assertIn("refresh", response.data) + + def test_refresh_token(self): + User = get_user_model() + User.objects.create_user( + email="refresh@token.com", password="password123" + ) + + obtain = self.client.post( + "/users/token/", + {"email": "refresh@token.com", "password": "password123"}, + ) + refresh = obtain.data["refresh"] + + response = self.client.post( + "/users/token/refresh/", {"refresh": refresh} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) diff --git a/users/tests/test_models.py b/users/tests/test_models.py new file mode 100644 index 0000000..45a245b --- /dev/null +++ b/users/tests/test_models.py @@ -0,0 +1,12 @@ +from django.test import TestCase + + +class UserModelTests(TestCase): + def test_str_representation(self): + from django.contrib.auth import get_user_model + + User = get_user_model() + user = User.objects.create_user( + email="user@example.com", password="StrongPass123" + ) + self.assertEqual(str(user), "Email: user@example.com") diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py new file mode 100644 index 0000000..146f448 --- /dev/null +++ b/users/tests/test_serializers.py @@ -0,0 +1,82 @@ +from rest_framework.test import APITestCase + +from users.serializers import RegisterSerializer + + +class UserSerializerTests(APITestCase): + def setUp(self): + from django.contrib.auth import get_user_model + + User = get_user_model() + self.user = User.objects.create_user( + email="user@example.com", password="InitialPass123" + ) + + def test_register_user(self): + from users.serializers import RegisterSerializer + + data = {"email": "newuser@example.com", "password": "StrongPass123"} + serializer = RegisterSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.email, "newuser@example.com") + self.assertTrue(user.check_password("StrongPass123")) + + def test_update_email(self): + from users.serializers import UserUpdateSerializer + + serializer = UserUpdateSerializer( + instance=self.user, data={"email": "new@example.com"}, partial=True + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + updated_user = serializer.save() + self.assertEqual(updated_user.email, "new@example.com") + + def test_update_password(self): + from users.serializers import UserUpdateSerializer + + serializer = UserUpdateSerializer( + instance=self.user, + data={"password": "NewStrongPass123"}, + partial=True, + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + updated_user = serializer.save() + self.assertTrue(updated_user.check_password("NewStrongPass123")) + + def test_email_uniqueness_validation(self): + from django.contrib.auth import get_user_model + from users.serializers import UserUpdateSerializer + + User = get_user_model() + User.objects.create_user( + email="taken@example.com", password="SomePass123" + ) + serializer = UserUpdateSerializer( + instance=self.user, + data={"email": "taken@example.com"}, + partial=True, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("email", serializer.errors) + + def test_register_user_success_returns_user_without_password(self): + serializer = RegisterSerializer( + data={"email": "user@withoutparol.com", "password": "007BondJames"} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + + self.assertEqual(user.email, "user@withoutparol.com") + self.assertTrue(user.check_password("007BondJames")) + self.assertNotIn("password", serializer.data) + + def test_user_serializer_read_only(self): + from django.contrib.auth import get_user_model + from users.serializers import UserSerializer + + User = get_user_model() + serializer = UserSerializer(instance=self.user) + self.assertEqual(serializer.data["email"], "user@example.com") + self.assertIn("id", serializer.data) + self.assertNotIn("password", serializer.data) diff --git a/users/tests/test_views.py b/users/tests/test_views.py new file mode 100644 index 0000000..c3cbecd --- /dev/null +++ b/users/tests/test_views.py @@ -0,0 +1,108 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APITestCase + + +class AuthenticatedSystemTests(APITestCase): + + def test_authenticated_user_can_view_their_profile(self): + User = get_user_model() + User.objects.create_user( + email="auth@user.com", password="ImAuthUser99" + ) + url = "/users/token/" + response = self.client.post( + url, {"email": "auth@user.com", "password": "ImAuthUser99"} + ) + + access_token = response.data["access"] + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + profile_response = self.client.get("/users/me/") + + self.assertEqual(profile_response.status_code, status.HTTP_200_OK) + self.assertEqual(profile_response.data["email"], "auth@user.com") + self.assertNotIn("password", profile_response.data) + + def test_authenticated_user_can_update_their_profile(self): + User = get_user_model() + user = User.objects.create_user( + email="try@update.com", password="111oldpassword" + ) + url = "/users/token/" + response = self.client.post( + url, {"email": "try@update.com", "password": "111oldpassword"} + ) + + access_token = response.data["access"] + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + update_url = "/users/me/" + update_response = self.client.put( + update_url, + {"email": "new@updated.com", "password": "222newpassword"}, + ) + + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + self.assertEqual(update_response.data["email"], "new@updated.com") + user.refresh_from_db() + self.assertTrue(user.check_password("222newpassword")) + + def test_unauthenticated_user_gets_401(self): + + url = "/users/me/" + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_unauthorized_user_gets_401_when_updating_profile(self): + + url = "/users/me/" + + response = self.client.patch(url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_partial_update_first_name(self): + User = get_user_model() + user = User.objects.create_user( + email="patch@test.com", password="PatchPass123" + ) + url = "/users/token/" + response = self.client.post( + url, {"email": "patch@test.com", "password": "PatchPass123"} + ) + access_token = response.data["access"] + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + response = self.client.patch("/users/me/", {"first_name": "Updated"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + user.refresh_from_db() + self.assertEqual(user.first_name, "Updated") + + def test_unauthorized_user_gets_401_when_putting_profile(self): + url = "/users/me/" + response = self.client.put( + url, {"email": "unauth@test.com", "password": "NoAccess123"} + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_email_to_existing_should_fail(self): + User = get_user_model() + User.objects.create_user( + email="taken@test.com", password="TakenPass123" + ) + user = User.objects.create_user( + email="main@test.com", password="MainPass123" + ) + token_url = "/users/token/" + response = self.client.post( + token_url, {"email": "main@test.com", "password": "MainPass123"} + ) + access_token = response.data["access"] + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + response = self.client.patch("/users/me/", {"email": "taken@test.com"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("email", response.data) diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..dd4b5ad --- /dev/null +++ b/users/urls.py @@ -0,0 +1,31 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) + +from users.views import CreateUserViewSet, ManageUserViewSet + + +app_name = "users" + +router = DefaultRouter() +router.register("register", CreateUserViewSet, basename="register") + + +urlpatterns = [ + 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/", + ManageUserViewSet.as_view( + {"get": "retrieve", "put": "update", "patch": "partial_update"} + ), + name="me", + ), +] + +urlpatterns += router.urls diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..75328c5 --- /dev/null +++ b/users/views.py @@ -0,0 +1,57 @@ +from django.contrib.auth import get_user_model +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import viewsets, mixins, permissions + +from users.serializers import ( + UserSerializer, + RegisterSerializer, + UserUpdateSerializer, +) + +User = get_user_model() + + +@extend_schema_view( + create=extend_schema( + summary="Register User", + description="Register a new user account. Available to everyone.", + tags=["Users"], + ) +) +class CreateUserViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin): + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + + +@extend_schema_view( + retrieve=extend_schema( + summary="Current User Profile", + description="Get detailed information " + "about the authenticated user (your own profile).", + tags=["Users"], + ), + update=extend_schema( + summary="Update Current User", + description="Completely update your own profile information.", + tags=["Users"], + ), + partial_update=extend_schema( + summary="Partially Update Current User", + description="Partially update your own profile information.", + tags=["Users"], + ), +) +class ManageUserViewSet( + viewsets.GenericViewSet, mixins.UpdateModelMixin, mixins.RetrieveModelMixin +): + queryset = User.objects.all() + + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + return self.request.user + + def get_serializer_class(self): + if self.action in ("update", "partial_update"): + return UserUpdateSerializer + return UserSerializer