From 3ea330b8e89e0bfda3ab94a5d32468fe43bd156d Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Sat, 20 Sep 2025 04:50:57 +0300 Subject: [PATCH 001/177] chore: project skeleton with Docker, env, and docs --- .dockerignore | 39 ++++ .env.sample | 11 + .flake8 | 12 ++ .github/workflows/ci.yml | 62 ++++++ .gitignore | 261 ++++++++++++++++++++++++ .isort.cfg | 11 + Dockerfile | 27 +++ README.md | 4 +- books/__init__.py | 0 books/admin.py | 4 + books/apps.py | 6 + books/migrations/__init__.py | 0 books/models.py | 4 + books/tests.py | 4 + books/urls.py | 6 + books/views.py | 4 + borrowings/__init__.py | 0 borrowings/admin.py | 4 + borrowings/apps.py | 6 + borrowings/migrations/__init__.py | 0 borrowings/models.py | 4 + borrowings/tests.py | 4 + borrowings/urls.py | 6 + borrowings/views.py | 4 + core/__init__.py | 0 core/asgi.py | 19 ++ core/management/__init__.py | 0 core/management/commands/__init__.py | 0 core/management/commands/wait_for_db.py | 32 +++ core/settings/__init__.py | 0 core/settings/base.py | 102 +++++++++ core/settings/dev.py | 19 ++ core/settings/prod.py | 17 ++ core/urls.py | 47 +++++ core/wsgi.py | 19 ++ docker-compose.yml | 35 ++++ manage.py | 25 +++ notifications/__init__.py | 0 notifications/admin.py | 4 + notifications/apps.py | 6 + notifications/migrations/__init__.py | 0 notifications/models.py | 4 + notifications/tests.py | 4 + notifications/urls.py | 6 + notifications/views.py | 4 + payments/__init__.py | 0 payments/admin.py | 4 + payments/apps.py | 6 + payments/migrations/__init__.py | 0 payments/models.py | 4 + payments/tests.py | 4 + payments/urls.py | 6 + payments/views.py | 4 + pyproject.toml | 2 + requirements.txt | 8 + users/__init__.py | 0 users/admin.py | 4 + users/apps.py | 6 + users/migrations/__init__.py | 0 users/models.py | 4 + users/tests.py | 4 + users/urls.py | 6 + users/views.py | 4 + 63 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.sample create mode 100644 .flake8 create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 Dockerfile create mode 100644 books/__init__.py create mode 100644 books/admin.py create mode 100644 books/apps.py create mode 100644 books/migrations/__init__.py create mode 100644 books/models.py create mode 100644 books/tests.py create mode 100644 books/urls.py create mode 100644 books/views.py create mode 100644 borrowings/__init__.py create mode 100644 borrowings/admin.py create mode 100644 borrowings/apps.py create mode 100644 borrowings/migrations/__init__.py create mode 100644 borrowings/models.py create mode 100644 borrowings/tests.py create mode 100644 borrowings/urls.py create mode 100644 borrowings/views.py create mode 100644 core/__init__.py create mode 100644 core/asgi.py create mode 100644 core/management/__init__.py create mode 100644 core/management/commands/__init__.py create mode 100644 core/management/commands/wait_for_db.py create mode 100644 core/settings/__init__.py create mode 100644 core/settings/base.py create mode 100644 core/settings/dev.py create mode 100644 core/settings/prod.py create mode 100644 core/urls.py create mode 100644 core/wsgi.py create mode 100644 docker-compose.yml create mode 100644 manage.py create mode 100644 notifications/__init__.py create mode 100644 notifications/admin.py create mode 100644 notifications/apps.py create mode 100644 notifications/migrations/__init__.py create mode 100644 notifications/models.py create mode 100644 notifications/tests.py create mode 100644 notifications/urls.py create mode 100644 notifications/views.py create mode 100644 payments/__init__.py create mode 100644 payments/admin.py create mode 100644 payments/apps.py create mode 100644 payments/migrations/__init__.py create mode 100644 payments/models.py create mode 100644 payments/tests.py create mode 100644 payments/urls.py create mode 100644 payments/views.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 users/__init__.py create mode 100644 users/admin.py create mode 100644 users/apps.py create mode 100644 users/migrations/__init__.py create mode 100644 users/models.py create mode 100644 users/tests.py create mode 100644 users/urls.py create mode 100644 users/views.py 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..7b4cab8 --- /dev/null +++ b/.env.sample @@ -0,0 +1,11 @@ +# 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= 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..4391122 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [ develop, main ] + pull_request: + branches: [ develop, main ] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'test_db' }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'test_user' }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test_pass' }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ${{ secrets.POSTGRES_USER || 'test_user' }}" \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 5 + env: + POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'test_db' }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'test_user' }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test_pass' }} + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + DJANGO_SECRET_KEY: dummysecretkey + DJANGO_SETTINGS_MODULE: core.settings.dev + + 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..91dbea5 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# drf-library-api \ No newline at end of file +# drf-library-api + + 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..922985e --- /dev/null +++ b/books/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin + + +# Register your models here. 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/__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..facd81d --- /dev/null +++ b/books/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +# Create your models here. diff --git a/books/tests.py b/books/tests.py new file mode 100644 index 0000000..8d9dff5 --- /dev/null +++ b/books/tests.py @@ -0,0 +1,4 @@ +from django.test import TestCase + + +# Create your tests here. diff --git a/books/urls.py b/books/urls.py new file mode 100644 index 0000000..0960cbb --- /dev/null +++ b/books/urls.py @@ -0,0 +1,6 @@ +from django.urls import path + + +urlpatterns = [ + # Define your URL patterns here +] diff --git a/books/views.py b/books/views.py new file mode 100644 index 0000000..f4787d4 --- /dev/null +++ b/books/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + + +# Create your views here. 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..922985e --- /dev/null +++ b/borrowings/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin + + +# Register your models here. 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/__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..facd81d --- /dev/null +++ b/borrowings/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +# Create your models here. diff --git a/borrowings/tests.py b/borrowings/tests.py new file mode 100644 index 0000000..8d9dff5 --- /dev/null +++ b/borrowings/tests.py @@ -0,0 +1,4 @@ +from django.test import TestCase + + +# Create your tests here. diff --git a/borrowings/urls.py b/borrowings/urls.py new file mode 100644 index 0000000..0960cbb --- /dev/null +++ b/borrowings/urls.py @@ -0,0 +1,6 @@ +from django.urls import path + + +urlpatterns = [ + # Define your URL patterns here +] diff --git a/borrowings/views.py b/borrowings/views.py new file mode 100644 index 0000000..f4787d4 --- /dev/null +++ b/borrowings/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + + +# Create your views here. diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 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/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/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..5e59660 --- /dev/null +++ b/core/settings/base.py @@ -0,0 +1,102 @@ +from pathlib import Path +import os +from dotenv import load_dotenv + + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "debug_toolbar", + "rest_framework", + "drf_spectacular", + "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", +} + +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, + }, +} 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..fc9db60 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,47 @@ +""" +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")), + path("notifications/", include("notifications.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..adde659 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +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 + + 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 + +volumes: + postgres_data: + my_media: 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/admin.py b/notifications/admin.py new file mode 100644 index 0000000..922985e --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin + + +# Register your models here. 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/models.py b/notifications/models.py new file mode 100644 index 0000000..facd81d --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +# Create your models here. diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..8d9dff5 --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,4 @@ +from django.test import TestCase + + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..0960cbb --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,6 @@ +from django.urls import path + + +urlpatterns = [ + # Define your URL patterns here +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..f4787d4 --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + + +# Create your views here. 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..922985e --- /dev/null +++ b/payments/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin + + +# Register your models here. 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/__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..facd81d --- /dev/null +++ b/payments/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +# Create your models here. diff --git a/payments/tests.py b/payments/tests.py new file mode 100644 index 0000000..8d9dff5 --- /dev/null +++ b/payments/tests.py @@ -0,0 +1,4 @@ +from django.test import TestCase + + +# Create your tests here. diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 0000000..0960cbb --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,6 @@ +from django.urls import path + + +urlpatterns = [ + # Define your URL patterns here +] diff --git a/payments/views.py b/payments/views.py new file mode 100644 index 0000000..f4787d4 --- /dev/null +++ b/payments/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + + +# Create your views here. 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..a06f310 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +django==5.2.6 +djangorestframework==3.16.1 +drf-spectacular==0.28.0 +python-dotenv==1.1.1 +psycopg2-binary==2.9.10 +django-debug-toolbar==6.0.0 +flake8==7.3.0 +black==25.9.0 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..922985e --- /dev/null +++ b/users/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin + + +# Register your models here. 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/__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..facd81d --- /dev/null +++ b/users/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +# Create your models here. diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..8d9dff5 --- /dev/null +++ b/users/tests.py @@ -0,0 +1,4 @@ +from django.test import TestCase + + +# Create your tests here. diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..0960cbb --- /dev/null +++ b/users/urls.py @@ -0,0 +1,6 @@ +from django.urls import path + + +urlpatterns = [ + # Define your URL patterns here +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..f4787d4 --- /dev/null +++ b/users/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + + +# Create your views here. From 23c1ea896081a4c1754ded828dc12d76721cfaf9 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sat, 20 Sep 2025 17:27:16 +0300 Subject: [PATCH 002/177] feat(users): add custom User model --- users/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/users/models.py b/users/models.py index facd81d..391526d 100644 --- a/users/models.py +++ b/users/models.py @@ -1,4 +1,6 @@ +from django.contrib.auth.models import AbstractUser from django.db import models -# Create your models here. +class User(AbstractUser): + pass \ No newline at end of file From dd3e3eee92c12c3cd3faec12b64a72194df26f55 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sat, 20 Sep 2025 17:38:16 +0300 Subject: [PATCH 003/177] feat(users): configure custom User model in settings and add initial migration --- core/settings/base.py | 2 + users/migrations/0001_initial.py | 138 +++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 users/migrations/0001_initial.py diff --git a/core/settings/base.py b/core/settings/base.py index 5e59660..914a306 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -100,3 +100,5 @@ "defaultModelExpandDepth": 2, }, } + +AUTH_USER_MODEL = "users.User" \ No newline at end of file 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()), + ], + ), + ] From 150b238a8927d0be641d900d0bb68a3e0c139e85 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sat, 20 Sep 2025 22:59:23 +0300 Subject: [PATCH 004/177] feat(users): expanded custom User model with email-based authentication and register it in admin.py --- users/admin.py | 27 ++++++++- ...er_options_alter_user_managers_and_more.py | 39 +++++++++++++ users/models.py | 56 ++++++++++++++++++- 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 users/migrations/0002_alter_user_options_alter_user_managers_and_more.py diff --git a/users/admin.py b/users/admin.py index 922985e..e2d3b7d 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,4 +1,29 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ +from .models import User -# Register your models here. + +@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/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/models.py b/users/models.py index 391526d..bfcd812 100644 --- a/users/models.py +++ b/users/models.py @@ -1,6 +1,58 @@ -from django.contrib.auth.models import AbstractUser +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 User model with no username field.""" + + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """Create and save a User with the given email and password.""" + if not email: + raise ValueError("The given email must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + """Create and save a regular User with the given email and password.""" + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + """Create and save a SuperUser with the given email and password.""" + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) class User(AbstractUser): - pass \ No newline at end of file + 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"] + + def __str__(self): + return f"Email: {self.email}" From eaaa8740420a8bde5862abd830eefc01f6840936 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sat, 20 Sep 2025 23:19:09 +0300 Subject: [PATCH 005/177] feat(users): added serializers for registration and user profile with email validation --- users/serializers.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 users/serializers.py diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..c1a3d41 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,52 @@ +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): + return User.objects.create_user(**validated_data) + + +class UserSerializer(serializers.ModelSerializer): + """Serializer for retrieving and 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", "password") + read_only_fields = ("id",) + + def update(self, instance, validated_data): + 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): + 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 From 00b9f87b7f9d96a6d0959ebe8e38c21a46563d01 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sat, 20 Sep 2025 23:33:45 +0300 Subject: [PATCH 006/177] test(users): add tests for user creation, update, email update and email uniqueness --- users/tests.py | 4 --- users/tests/__init__.py | 0 users/tests/test_serializers.py | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) delete mode 100644 users/tests.py create mode 100644 users/tests/__init__.py create mode 100644 users/tests/test_serializers.py diff --git a/users/tests.py b/users/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/users/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. 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_serializers.py b/users/tests/test_serializers.py new file mode 100644 index 0000000..9212e93 --- /dev/null +++ b/users/tests/test_serializers.py @@ -0,0 +1,54 @@ +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase +from users.serializers import RegisterSerializer, UserSerializer + +User = get_user_model() + + +class UserSerializerTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="user@example.com", + password="InitialPass123" + ) + + def test_register_user(self): + 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): + serializer = UserSerializer( + 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): + serializer = UserSerializer( + 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): + other_user = User.objects.create_user( + email="taken@example.com", + password="SomePass123" + ) + serializer = UserSerializer( + instance=self.user, + data={"email": "taken@example.com"}, + partial=True + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("email", serializer.errors) From 3c375bb310a89adeb839929e3d0234f59a4aaaf3 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sat, 20 Sep 2025 23:44:03 +0300 Subject: [PATCH 007/177] test(users): test for string representation of user model --- users/tests/test_models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 users/tests/test_models.py diff --git a/users/tests/test_models.py b/users/tests/test_models.py new file mode 100644 index 0000000..ff405ac --- /dev/null +++ b/users/tests/test_models.py @@ -0,0 +1,13 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +User = get_user_model() + + +class UserModelTests(TestCase): + def test_str_representation(self): + user = User.objects.create_user( + email="user@example.com", + password="StrongPass123" + ) + self.assertEqual(str(user), "Email: user@example.com") From c0f980dee930e8e1c0c90e587107a80770e5201e Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Sun, 21 Sep 2025 21:06:44 +0300 Subject: [PATCH 008/177] style(users): fix imports and code style requirements --- core/settings/base.py | 2 +- users/admin.py | 2 +- users/models.py | 5 +---- users/tests/test_models.py | 1 + users/tests/test_serializers.py | 3 +++ 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/core/settings/base.py b/core/settings/base.py index 914a306..1d607ce 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -101,4 +101,4 @@ }, } -AUTH_USER_MODEL = "users.User" \ No newline at end of file +AUTH_USER_MODEL = "users.User" diff --git a/users/admin.py b/users/admin.py index e2d3b7d..cd8c191 100644 --- a/users/admin.py +++ b/users/admin.py @@ -2,7 +2,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.utils.translation import gettext_lazy as _ -from .models import User +from users.models import User @admin.register(User) diff --git a/users/models.py b/users/models.py index bfcd812..0666631 100644 --- a/users/models.py +++ b/users/models.py @@ -1,7 +1,4 @@ -from django.contrib.auth.models import ( - AbstractUser, - BaseUserManager, -) +from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db import models from django.utils.translation import gettext_lazy as _ diff --git a/users/tests/test_models.py b/users/tests/test_models.py index ff405ac..322e1c2 100644 --- a/users/tests/test_models.py +++ b/users/tests/test_models.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase + User = get_user_model() diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py index 9212e93..ae427f4 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -1,7 +1,10 @@ from django.contrib.auth import get_user_model + from rest_framework.test import APITestCase + from users.serializers import RegisterSerializer, UserSerializer + User = get_user_model() From 1f54f554d5def546d97154981e65384c51effc30 Mon Sep 17 00:00:00 2001 From: viannik Date: Sun, 21 Sep 2025 22:00:54 +0300 Subject: [PATCH 009/177] created book model --- books/models.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/books/models.py b/books/models.py index facd81d..3049a7f 100644 --- a/books/models.py +++ b/books/models.py @@ -1,4 +1,26 @@ from django.db import models -# Create your models here. +class Book(models.Model): + title = models.CharField(max_length=256) + author = models.CharField(max_length=128) + cover = models.CharField( + max_length=16, + choices=[ + ("HARD", "Hardcover"), + ("SOFT", "Softcover"), + ], + ) + inventory = models.PositiveIntegerField() + daily_fee = models.DecimalField( + max_digits=5, + decimal_places=2, + ) + + def __str__(self): + return f"{self.title} by {self.author}" + + class Meta: + ordering = ["title", "author"] + verbose_name = "Book" + verbose_name_plural = "Books" From aa4f0f09799b3398b9585573426243cb6854e4fe Mon Sep 17 00:00:00 2001 From: viannik Date: Sun, 21 Sep 2025 22:23:18 +0300 Subject: [PATCH 010/177] added initial migration for Book model --- books/migrations/0001_initial.py | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 books/migrations/0001_initial.py 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"], + }, + ), + ] From a5fca6605795dea5265accde959f2decde84082a Mon Sep 17 00:00:00 2001 From: viannik Date: Sun, 21 Sep 2025 22:28:55 +0300 Subject: [PATCH 011/177] registered book model in admin panel --- books/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/books/admin.py b/books/admin.py index 922985e..321d9c0 100644 --- a/books/admin.py +++ b/books/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from .models import Book -# Register your models here. +admin.site.register(Book) From 839be3ba8c5fca28bedae27a941abdb7e92d32e3 Mon Sep 17 00:00:00 2001 From: viannik Date: Sun, 21 Sep 2025 22:31:16 +0300 Subject: [PATCH 012/177] =?UTF-8?q?validated=20daily=5Ffee=20=E2=89=A5=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- books/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/books/models.py b/books/models.py index 3049a7f..5161c95 100644 --- a/books/models.py +++ b/books/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.core.validators import MinValueValidator class Book(models.Model): @@ -15,6 +16,7 @@ class Book(models.Model): daily_fee = models.DecimalField( max_digits=5, decimal_places=2, + validators=[MinValueValidator(0)], ) def __str__(self): From 91ed424f920db1307ab64e7e1bc84fb75d839465 Mon Sep 17 00:00:00 2001 From: viannik Date: Sun, 21 Sep 2025 22:44:44 +0300 Subject: [PATCH 013/177] added tests for model constraints --- books/tests.py | 4 ---- books/tests/__init__.py | 0 books/tests/test_models.py | 39 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) delete mode 100644 books/tests.py create mode 100644 books/tests/__init__.py create mode 100644 books/tests/test_models.py diff --git a/books/tests.py b/books/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/books/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. 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..bef220d --- /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) From dc92bd664c1749839a353d2a9bbfbb643d135558 Mon Sep 17 00:00:00 2001 From: viannik Date: Sun, 21 Sep 2025 22:52:28 +0300 Subject: [PATCH 014/177] enhanced Book model --- books/models.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/books/models.py b/books/models.py index 5161c95..97d036d 100644 --- a/books/models.py +++ b/books/models.py @@ -3,20 +3,30 @@ class Book(models.Model): - title = models.CharField(max_length=256) - author = models.CharField(max_length=128) + 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.", ) - inventory = models.PositiveIntegerField() 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): @@ -26,3 +36,4 @@ class Meta: ordering = ["title", "author"] verbose_name = "Book" verbose_name_plural = "Books" + unique_together = ("title", "author", "cover") From efce2c0b9c600e58efe1431b0e2021a3f3a2c566 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 11:16:28 +0200 Subject: [PATCH 015/177] added enhanced migration for Book model --- ...r_book_author_alter_book_cover_and_more.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 books/migrations/0002_alter_book_author_alter_book_cover_and_more.py 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")}, + ), + ] From 6816846aecaf18d969f0292f266dca9d32dd8d57 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 11:16:39 +0200 Subject: [PATCH 016/177] enhanced BookAdmin model --- books/admin.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/books/admin.py b/books/admin.py index 321d9c0..899b094 100644 --- a/books/admin.py +++ b/books/admin.py @@ -1,5 +1,20 @@ from django.contrib import admin -from .models import Book +from books.models import Book -admin.site.register(Book) +@admin.register(Book) +class BookAdmin(admin.ModelAdmin): + list_display = ( + "title", + "author", + "cover", + "inventory", + "daily_fee", + ) + search_fields = ( + "title", + "author", + ) + list_filter = ( + "cover", + ) From 4ffb2ba6dacb6409ce153adeace97a31543cb89a Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 12:35:34 +0300 Subject: [PATCH 017/177] fix: update CI workflow --- .github/workflows/ci.yml | 14 +++++--------- requirements.txt | 1 + 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4391122..52a48e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,24 +12,20 @@ jobs: services: postgres: image: postgres:16 - env: - POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'test_db' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'test_user' }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test_pass' }} ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U ${{ secrets.POSTGRES_USER || 'test_user' }}" \ + --health-cmd "pg_isready -U postgres" \ --health-interval 10s \ --health-timeout 5s \ --health-retries 5 env: - POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'test_db' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'test_user' }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test_pass' }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} POSTGRES_HOST: localhost POSTGRES_PORT: 5432 - DJANGO_SECRET_KEY: dummysecretkey + DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} DJANGO_SETTINGS_MODULE: core.settings.dev steps: diff --git a/requirements.txt b/requirements.txt index a06f310..ec1e077 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ 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 flake8==7.3.0 black==25.9.0 From 03abe83bb1f93077b0e08aff2ac9df9c8054a387 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 12:43:11 +0300 Subject: [PATCH 018/177] fix: streamline health check command in CI workflow --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52a48e9..7e69b45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,7 @@ jobs: ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U postgres" \ - --health-interval 10s \ - --health-timeout 5s \ - --health-retries 5 + --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 env: POSTGRES_DB: ${{ secrets.POSTGRES_DB }} POSTGRES_USER: ${{ secrets.POSTGRES_USER }} From a79e92373385aad1e6f05f1e5015663a3b684a1c Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 12:57:09 +0300 Subject: [PATCH 019/177] fix: reorder environment variables --- .github/workflows/ci.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e69b45..b04a866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,18 +12,18 @@ jobs: services: postgres: image: postgres:16 + env: + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} + DJANGO_SETTINGS_MODULE: core.settings.dev ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 - env: - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} - DJANGO_SETTINGS_MODULE: core.settings.dev steps: - uses: actions/checkout@v4 From f55abc49a450143119fca419420beb5e14f60c0c Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 12:59:38 +0300 Subject: [PATCH 020/177] create Borrowing model in borrowings app --- borrowings/models.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/borrowings/models.py b/borrowings/models.py index facd81d..8c7e79d 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -1,4 +1,19 @@ from django.db import models +from django.conf import settings -# Create your models here. +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( + "books.Book", + on_delete=models.CASCADE, + related_name="borrowings" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="borrowings" + ) From 592d4d15cf3f2a962c462070c52c01c4a31d8556 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 13:00:22 +0300 Subject: [PATCH 021/177] fix: format code for consistency in admin, serializers, and test files --- users/admin.py | 30 +++++++++++++++++++----------- users/serializers.py | 8 ++++---- users/tests/test_models.py | 3 +-- users/tests/test_serializers.py | 14 +++++--------- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/users/admin.py b/users/admin.py index cd8c191..d0693e9 100644 --- a/users/admin.py +++ b/users/admin.py @@ -11,19 +11,27 @@ class UserAdmin(BaseUserAdmin): list_display = ("email", "is_staff", "is_superuser") fieldsets = ( (None, {"fields": ("email", "password")}), - (_("Permissions"), {"fields": ( - "is_active", - "is_staff", - "is_superuser", - "groups", - "user_permissions" - )}), + ( + _("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"), - }), + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), ) search_fields = ("email",) diff --git a/users/serializers.py b/users/serializers.py index c1a3d41..dd54e5b 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -7,10 +7,9 @@ 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"} + write_only=True, min_length=8, style={"input_type": "password"} ) class Meta: @@ -23,11 +22,12 @@ def create(self, validated_data): class UserSerializer(serializers.ModelSerializer): """Serializer for retrieving and updating user profile.""" + password = serializers.CharField( write_only=True, min_length=8, required=False, - style={"input_type": "password"} + style={"input_type": "password"}, ) class Meta: diff --git a/users/tests/test_models.py b/users/tests/test_models.py index 322e1c2..071cf66 100644 --- a/users/tests/test_models.py +++ b/users/tests/test_models.py @@ -8,7 +8,6 @@ class UserModelTests(TestCase): def test_str_representation(self): user = User.objects.create_user( - email="user@example.com", - password="StrongPass123" + 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 index ae427f4..62b2c81 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -11,8 +11,7 @@ class UserSerializerTests(APITestCase): def setUp(self): self.user = User.objects.create_user( - email="user@example.com", - password="InitialPass123" + email="user@example.com", password="InitialPass123" ) def test_register_user(self): @@ -25,9 +24,7 @@ def test_register_user(self): def test_update_email(self): serializer = UserSerializer( - instance=self.user, - data={"email": "new@example.com"}, - partial=True + instance=self.user, data={"email": "new@example.com"}, partial=True ) self.assertTrue(serializer.is_valid(), serializer.errors) updated_user = serializer.save() @@ -37,7 +34,7 @@ def test_update_password(self): serializer = UserSerializer( instance=self.user, data={"password": "NewStrongPass123"}, - partial=True + partial=True, ) self.assertTrue(serializer.is_valid(), serializer.errors) updated_user = serializer.save() @@ -45,13 +42,12 @@ def test_update_password(self): def test_email_uniqueness_validation(self): other_user = User.objects.create_user( - email="taken@example.com", - password="SomePass123" + email="taken@example.com", password="SomePass123" ) serializer = UserSerializer( instance=self.user, data={"email": "taken@example.com"}, - partial=True + partial=True, ) self.assertFalse(serializer.is_valid()) self.assertIn("email", serializer.errors) From a01efcf2b1fce6e66112c4916a05d63cbb619dc6 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 13:04:40 +0300 Subject: [PATCH 022/177] fix: set DJANGO_SETTINGS_MODULE in CI workflow --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b04a866..65a6d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Run flake8 run: flake8 . + - name: Set DJANGO_SETTINGS_MODULE + run: echo "DJANGO_SETTINGS_MODULE=core.settings.dev" >> $GITHUB_ENV + - name: Run tests with coverage run: pytest --cov=. From 9fc10947892adc573099b002b7b7b0939a8cfdf9 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 13:07:25 +0300 Subject: [PATCH 023/177] add str and class Meta with constraints in Borrowing model --- borrowings/models.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/borrowings/models.py b/borrowings/models.py index 8c7e79d..31d56ac 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -17,3 +17,25 @@ class Borrowing(models.Model): on_delete=models.CASCADE, related_name="borrowings" ) + class Meta: + constraints = [ + models.CheckConstraint( + check=models.Q(expected_return_date__gte=models.F("borrow_date")), + name="expected_after_borrow", + ), + models.CheckConstraint( + check=( + 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" + ) + ] + + def __str__(self): + return f"{self.user} borrowed {self.book} on {self.borrow_date}" \ No newline at end of file From e6aff72242dee2b1241cb05837d0656c45202132 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 13:07:39 +0300 Subject: [PATCH 024/177] fix: add environment variables for Django and PostgreSQL --- .github/workflows/ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65a6d02..fa8e0a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,14 @@ on: 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 services: postgres: image: postgres:16 @@ -16,10 +24,6 @@ jobs: POSTGRES_DB: ${{ secrets.POSTGRES_DB }} POSTGRES_USER: ${{ secrets.POSTGRES_USER }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} - DJANGO_SETTINGS_MODULE: core.settings.dev ports: - 5432:5432 options: >- @@ -45,9 +49,6 @@ jobs: - name: Run flake8 run: flake8 . - - name: Set DJANGO_SETTINGS_MODULE - run: echo "DJANGO_SETTINGS_MODULE=core.settings.dev" >> $GITHUB_ENV - - name: Run tests with coverage run: pytest --cov=. From d78975ce6a2f61aafd0454af1846e279e09ea997 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 13:10:38 +0300 Subject: [PATCH 025/177] register Borrowing model in admin.py --- borrowings/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/borrowings/admin.py b/borrowings/admin.py index 922985e..57f4b53 100644 --- a/borrowings/admin.py +++ b/borrowings/admin.py @@ -1,4 +1,4 @@ from django.contrib import admin +from borrowings.models import Borrowing - -# Register your models here. +admin.site.register(Borrowing) From 656038c16f0220520dac451740da5b65a04f209a Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 12:18:26 +0200 Subject: [PATCH 026/177] implemented BookSerializer --- books/serializers.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 books/serializers.py 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", + ] From 5557dadd24e01ea4dba6008e2dc6ae1cf9a7e5eb Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 13:21:27 +0300 Subject: [PATCH 027/177] add migration --- borrowings/migrations/0001_initial.py | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 borrowings/migrations/0001_initial.py 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", + ), + ], + }, + ), + ] From 1c6c4e856ba9ce13cd00191301be514e3f58c5f5 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Mon, 22 Sep 2025 13:33:40 +0300 Subject: [PATCH 028/177] fix: add pytest-django to requirements and optimize imports in test files --- requirements.txt | 1 + users/tests/test_models.py | 7 +++---- users/tests/test_serializers.py | 20 +++++++++++++------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/requirements.txt b/requirements.txt index ec1e077..7e7efec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ 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 diff --git a/users/tests/test_models.py b/users/tests/test_models.py index 071cf66..45a245b 100644 --- a/users/tests/test_models.py +++ b/users/tests/test_models.py @@ -1,12 +1,11 @@ -from django.contrib.auth import get_user_model from django.test import TestCase -User = get_user_model() - - 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" ) diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py index 62b2c81..efb15dc 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -1,20 +1,18 @@ -from django.contrib.auth import get_user_model - from rest_framework.test import APITestCase -from users.serializers import RegisterSerializer, UserSerializer - - -User = get_user_model() - 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) @@ -23,6 +21,8 @@ def test_register_user(self): self.assertTrue(user.check_password("StrongPass123")) def test_update_email(self): + from users.serializers import UserSerializer + serializer = UserSerializer( instance=self.user, data={"email": "new@example.com"}, partial=True ) @@ -31,6 +31,8 @@ def test_update_email(self): self.assertEqual(updated_user.email, "new@example.com") def test_update_password(self): + from users.serializers import UserSerializer + serializer = UserSerializer( instance=self.user, data={"password": "NewStrongPass123"}, @@ -41,6 +43,10 @@ def test_update_password(self): 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 UserSerializer + + User = get_user_model() other_user = User.objects.create_user( email="taken@example.com", password="SomePass123" ) From 7a47245d29d660d6a13123298308a801f19d49e5 Mon Sep 17 00:00:00 2001 From: Arch0998 <98530620+Arch0998@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:36:43 +0300 Subject: [PATCH 029/177] fix: update CI workflow (#4) * fix: update CI workflow * fix: streamline health check command in CI workflow * fix: reorder environment variables * fix: format code for consistency in admin, serializers, and test files * fix: set DJANGO_SETTINGS_MODULE in CI workflow * fix: add environment variables for Django and PostgreSQL * fix: add pytest-django to requirements and optimize imports in test files --- .github/workflows/ci.yml | 27 ++++++++++++-------------- requirements.txt | 2 ++ users/admin.py | 30 ++++++++++++++++++----------- users/serializers.py | 8 ++++---- users/tests/test_models.py | 10 ++++------ users/tests/test_serializers.py | 34 +++++++++++++++++---------------- 6 files changed, 59 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4391122..fa8e0a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,28 +9,25 @@ on: 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 services: postgres: image: postgres:16 env: - POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'test_db' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'test_user' }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test_pass' }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U ${{ secrets.POSTGRES_USER || 'test_user' }}" \ - --health-interval 10s \ - --health-timeout 5s \ - --health-retries 5 - env: - POSTGRES_DB: ${{ secrets.POSTGRES_DB || 'test_db' }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'test_user' }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'test_pass' }} - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - DJANGO_SECRET_KEY: dummysecretkey - DJANGO_SETTINGS_MODULE: core.settings.dev + --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 diff --git a/requirements.txt b/requirements.txt index a06f310..7e7efec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,7 @@ 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 diff --git a/users/admin.py b/users/admin.py index cd8c191..d0693e9 100644 --- a/users/admin.py +++ b/users/admin.py @@ -11,19 +11,27 @@ class UserAdmin(BaseUserAdmin): list_display = ("email", "is_staff", "is_superuser") fieldsets = ( (None, {"fields": ("email", "password")}), - (_("Permissions"), {"fields": ( - "is_active", - "is_staff", - "is_superuser", - "groups", - "user_permissions" - )}), + ( + _("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"), - }), + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), ) search_fields = ("email",) diff --git a/users/serializers.py b/users/serializers.py index c1a3d41..dd54e5b 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -7,10 +7,9 @@ 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"} + write_only=True, min_length=8, style={"input_type": "password"} ) class Meta: @@ -23,11 +22,12 @@ def create(self, validated_data): class UserSerializer(serializers.ModelSerializer): """Serializer for retrieving and updating user profile.""" + password = serializers.CharField( write_only=True, min_length=8, required=False, - style={"input_type": "password"} + style={"input_type": "password"}, ) class Meta: diff --git a/users/tests/test_models.py b/users/tests/test_models.py index 322e1c2..45a245b 100644 --- a/users/tests/test_models.py +++ b/users/tests/test_models.py @@ -1,14 +1,12 @@ -from django.contrib.auth import get_user_model from django.test import TestCase -User = get_user_model() - - 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" + 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 index ae427f4..efb15dc 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -1,21 +1,18 @@ -from django.contrib.auth import get_user_model - from rest_framework.test import APITestCase -from users.serializers import RegisterSerializer, UserSerializer - - -User = get_user_model() - 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" + 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) @@ -24,34 +21,39 @@ def test_register_user(self): self.assertTrue(user.check_password("StrongPass123")) def test_update_email(self): + from users.serializers import UserSerializer + serializer = UserSerializer( - instance=self.user, - data={"email": "new@example.com"}, - partial=True + 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 UserSerializer + serializer = UserSerializer( instance=self.user, data={"password": "NewStrongPass123"}, - partial=True + 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 UserSerializer + + User = get_user_model() other_user = User.objects.create_user( - email="taken@example.com", - password="SomePass123" + email="taken@example.com", password="SomePass123" ) serializer = UserSerializer( instance=self.user, data={"email": "taken@example.com"}, - partial=True + partial=True, ) self.assertFalse(serializer.is_valid()) self.assertIn("email", serializer.errors) From d60c93d8fee1ad91e08cc9e88303128ff2731349 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 14:03:25 +0300 Subject: [PATCH 030/177] flake8 fix models.py in borrowing app --- borrowings/models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/borrowings/models.py b/borrowings/models.py index 31d56ac..a886293 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -17,16 +17,19 @@ class Borrowing(models.Model): on_delete=models.CASCADE, related_name="borrowings" ) + class Meta: constraints = [ models.CheckConstraint( - check=models.Q(expected_return_date__gte=models.F("borrow_date")), + check=models.Q( + expected_return_date__gte=models.F("borrow_date") + ), name="expected_after_borrow", ), models.CheckConstraint( check=( - models.Q(actual_return_date__isnull=True) | - models.Q(actual_return_date__gte=models.F("borrow_date")) + models.Q(actual_return_date__isnull=True) + | models.Q(actual_return_date__gte=models.F("borrow_date")) ), name="actual_after_borrow", ), @@ -38,4 +41,4 @@ class Meta: ] def __str__(self): - return f"{self.user} borrowed {self.book} on {self.borrow_date}" \ No newline at end of file + return f"{self.user} borrowed {self.book} on {self.borrow_date}" From 0a183f1b12d0796e4762d95285ab97ae8c3d14be Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 14:09:58 +0300 Subject: [PATCH 031/177] feat(users): add CreateUserViewSet and ManageUserViewSet with permission classes (AllowAny for register, IsAuthenticated for profile) --- users/views.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/users/views.py b/users/views.py index f4787d4..24c0863 100644 --- a/users/views.py +++ b/users/views.py @@ -1,4 +1,23 @@ -from django.shortcuts import render +from django.contrib.auth import get_user_model +from rest_framework import viewsets, mixins, permissions +from users.serializers import UserSerializer, RegisterSerializer -# Create your views here. + +User = get_user_model() + + +class CreateUserViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin): + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + + +class ManageUserViewSet( + viewsets.GenericViewSet, mixins.UpdateModelMixin, mixins.RetrieveModelMixin +): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + return self.request.user From e80d8697770e9e72c47fd82b304ac52a712b6529 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 14:11:35 +0300 Subject: [PATCH 032/177] Add initial migration for `Payment` model with constraints and enum fields --- users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users/models.py b/users/models.py index 0666631..3d1ecd1 100644 --- a/users/models.py +++ b/users/models.py @@ -4,7 +4,7 @@ class UserManager(BaseUserManager): - """Define a model manager for User model with no username field.""" + """Define a model manager for Usermodel with no username field.""" use_in_migrations = True From 5aedb205ea131ffd2d7fc3f6957b486f4c1bf960 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 14:12:23 +0300 Subject: [PATCH 033/177] black fix models.py in borrowing app --- borrowings/models.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/borrowings/models.py b/borrowings/models.py index a886293..5911473 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -8,14 +8,12 @@ class Borrowing(models.Model): actual_return_date = models.DateField(null=True, blank=True) book = models.ForeignKey( - "books.Book", - on_delete=models.CASCADE, - related_name="borrowings" + "books.Book", on_delete=models.CASCADE, related_name="borrowings" ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="borrowings" + related_name="borrowings", ) class Meta: @@ -36,8 +34,8 @@ class Meta: models.UniqueConstraint( fields=["book", "user"], condition=models.Q(actual_return_date__isnull=True), - name="unique_active_borrowing" - ) + name="unique_active_borrowing", + ), ] def __str__(self): From 8c7d8ec96528479029d49e600804afe919a795ea Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 14:17:27 +0300 Subject: [PATCH 034/177] feat(users): add unique email validation for registration --- users/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/users/serializers.py b/users/serializers.py index dd54e5b..7bb154f 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -19,6 +19,13 @@ class Meta: def create(self, validated_data): return User.objects.create_user(**validated_data) + def validate_email(self, value): + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError( + "User with this email already exists." + ) + return value + class UserSerializer(serializers.ModelSerializer): """Serializer for retrieving and updating user profile.""" From 545e6342be5457563ded6a9b45a46b4e6d7e46e1 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 13:26:33 +0300 Subject: [PATCH 035/177] Integrate `PaymentViewSet` with URL routing using `SimpleRouter` --- payments/urls.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 0960cbb..49ce030 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,6 +1,10 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import SimpleRouter +from payments.api import PaymentViewSet +router = SimpleRouter() +router.register(r"payments", PaymentViewSet, basename="payments") urlpatterns = [ - # Define your URL patterns here + path("", include(router.urls)) ] From 462b932f567df5a44f84232deecd01167e0367a3 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 14:25:05 +0300 Subject: [PATCH 036/177] test(users): test for success user register that returns user without a password --- users/tests/test_serializers.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py index efb15dc..7b4a773 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -1,5 +1,7 @@ from rest_framework.test import APITestCase +from users.serializers import RegisterSerializer + class UserSerializerTests(APITestCase): def setUp(self): @@ -57,3 +59,14 @@ def test_email_uniqueness_validation(self): ) 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) From 5445f029ed35d68db164c71210c376b4c66d5649 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 14:33:50 +0300 Subject: [PATCH 037/177] Add `Payment` model with validations, admin support, and tests --- payments/admin.py | 29 ++++++- payments/migrations/0001_initial.py | 74 ++++++++++++++++++ payments/models.py | 72 ++++++++++++++++- payments/tests.py | 4 - payments/tests/__init__.py | 0 payments/tests/test_models.py | 117 ++++++++++++++++++++++++++++ 6 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 payments/migrations/0001_initial.py delete mode 100644 payments/tests.py create mode 100644 payments/tests/__init__.py create mode 100644 payments/tests/test_models.py diff --git a/payments/admin.py b/payments/admin.py index 922985e..5d50970 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -1,4 +1,31 @@ from django.contrib import admin +from payments.models import Payment +""" +Use this to register Payment model in admin panel +after Borrowing model implementation +""" +# @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",) -# Register your models here. + +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "status", + "payment_type", + "money_to_pay", + "session_id", + ) + list_filter = ("status", "payment_type") + search_fields = ("session_id",) + readonly_fields = ("session_url", "session_id") + ordering = ("-id",) 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/models.py b/payments/models.py index facd81d..f1513eb 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,4 +1,74 @@ from django.db import models +from django.db.models import Q +class PaymentStatus(models.TextChoices): + PENDING = "PENDING", "PENDING" + PAID = "PAID", "PAID" -# Create your models here. + +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, + ) + + """Implemented as ForeignKey to Borrowing model. + After Borrowing model implementation, + this field will be used to store the borrowing + that is being paid for.""" + # borrowing = models.ForeignKey( + # "borrowings.Borrowing", + # on_delete=models.PROTECT, + # related_name="payments", + # db_index=True, + # ) + """Temporary field to store borrowing id. + Before Borrowing model implementation use this field + to store the borrowing that is being paid for.""" + borrowing_id = models.PositiveIntegerField(db_index=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) + + """Constraints and indexes. + Can be used only after Borrowing model implementation.""" + + class Meta: + db_table = "payment" + constraints = [ + models.CheckConstraint( + check=Q(money_to_pay__gte=0), + name="money_to_pay_non_negative", + ), + models.UniqueConstraint( + fields=["borrowing_id", "payment_type"], + condition=Q(status=PaymentStatus.PENDING), + name="uniq_pending_payment_per_borrowing_type_tmp", + ), + ] + + 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/tests.py b/payments/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/payments/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. 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_models.py b/payments/tests/test_models.py new file mode 100644 index 0000000..2f2f565 --- /dev/null +++ b/payments/tests/test_models.py @@ -0,0 +1,117 @@ +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 + + +class PaymentModelTests(TestCase): + def create_payment( + self, + *, + borrowing_id: int = 1, + 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: + return Payment.objects.create( + borrowing_id=borrowing_id, + 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): + p = self.create_payment() + self.assertIsNotNone(p.id) + self.assertEqual(p.status, PaymentStatus.PENDING) + self.assertEqual(p.payment_type, PaymentType.PAYMENT) + self.assertEqual(p.borrowing_id, 1) + 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): + # First pending for (borrowing_id=1, PAYMENT) + self.create_payment( + borrowing_id=1, + 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_id=1, + payment_type=PaymentType.PAYMENT, + status=PaymentStatus.PENDING, + ) + + # But a PAID for same pair is allowed + p_paid = self.create_payment( + borrowing_id=1, + 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_id=1, + payment_type=PaymentType.FINE, + status=PaymentStatus.PENDING, + ) + self.assertEqual(p_other_type.payment_type, PaymentType.FINE) + + def test_choices_validation_with_full_clean(self): + p = Payment( + borrowing_id=2, + status="INVALID", + payment_type=PaymentType.PAYMENT, + money_to_pay=Decimal("5.00"), + ) + with self.assertRaises(ValidationError): + p.full_clean() + + p2 = Payment( + borrowing_id=2, + 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): + p = self.create_payment(session_url=None, session_id=None) + self.assertIsNone(p.session_url) + self.assertIsNone(p.session_id) + + def test_str_representation(self): + p = self.create_payment( + borrowing_id=5, + 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("borrowing_id=5", s) + self.assertIn("amount=12.34 USD", s) \ No newline at end of file From 591a198b74901714fdfcf6d8b05fa9ad58e1e806 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 14:35:31 +0300 Subject: [PATCH 038/177] Format code in `PaymentStatus` and fix newline issue in tests --- payments/models.py | 1 + payments/tests/test_models.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index f1513eb..65d0191 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,6 +1,7 @@ from django.db import models from django.db.models import Q + class PaymentStatus(models.TextChoices): PENDING = "PENDING", "PENDING" PAID = "PAID", "PAID" diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 2f2f565..5a964f2 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -114,4 +114,4 @@ def test_str_representation(self): self.assertIn("status=PENDING", s) self.assertIn("type=FINE", s) self.assertIn("borrowing_id=5", s) - self.assertIn("amount=12.34 USD", s) \ No newline at end of file + self.assertIn("amount=12.34 USD", s) From e0931a1862429f95b025774289818554580aaa53 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 14:38:15 +0300 Subject: [PATCH 039/177] fix: url to default --- payments/urls.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 49ce030..0960cbb 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,10 +1,6 @@ -from django.urls import path, include -from rest_framework.routers import SimpleRouter -from payments.api import PaymentViewSet +from django.urls import path -router = SimpleRouter() -router.register(r"payments", PaymentViewSet, basename="payments") urlpatterns = [ - path("", include(router.urls)) + # Define your URL patterns here ] From 8ecb16466bf7e748fef71d2c781a70d491486680 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 14:59:40 +0300 Subject: [PATCH 040/177] Add PaymentSerializer for Payment model validation --- payments/serializers.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 payments/serializers.py diff --git a/payments/serializers.py b/payments/serializers.py new file mode 100644 index 0000000..7bba0ff --- /dev/null +++ b/payments/serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers +from payments.models import Payment, PaymentStatus, PaymentType + + +class PaymentSerializer(serializers.ModelSerializer): + class Meta: + model = Payment + fields = [ + "id", + "status", + "payment_type", + "borrowing_id", + "session_url", + "session_id", + "money_to_pay", + ] + read_only_fields = ["id"] + + def validate_status(self, value): + if value not in dict(PaymentStatus.choices): + raise serializers.ValidationError("Invalid status") + return value + + def validate_payment_type(self, value): + if value not in dict(PaymentType.choices): + raise serializers.ValidationError("Invalid payment_type") + return value + + def validate_money_to_pay(self, value): + if value is None: + raise serializers.ValidationError("money_to_pay is required") + if value < 0: + raise serializers.ValidationError( + "money_to_pay must be non-negative" + ) + return value \ No newline at end of file From 38754bc3167566ccb4fec8ce8bff299cd2b32e6c Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 14:59:55 +0300 Subject: [PATCH 041/177] Add PaymentSerializer for Payment model validation --- books/tests.py | 4 ---- payments/serializers.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 books/tests.py diff --git a/books/tests.py b/books/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/books/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. diff --git a/payments/serializers.py b/payments/serializers.py index 7bba0ff..7c65deb 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -33,4 +33,4 @@ def validate_money_to_pay(self, value): raise serializers.ValidationError( "money_to_pay must be non-negative" ) - return value \ No newline at end of file + return value From 59501fe86a3806b0db09083305c51c0e5c07413d Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 15:02:24 +0300 Subject: [PATCH 042/177] Introduce PaymentViewSet for Payment API functionality --- payments/views.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/payments/views.py b/payments/views.py index f4787d4..76ac28a 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,4 +1,19 @@ -from django.shortcuts import render +from rest_framework import mixins, viewsets +from payments.models import Payment +from payments.serializers import PaymentSerializer -# Create your views here. +class PaymentViewSet( + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ + ViewSet for Payment: + - list: GET /payments/ + - create: POST /payments/ + - retrieve: GET /payments/{id}/ + """ + queryset = Payment.objects.all().order_by("-id") + serializer_class = PaymentSerializer From bed0d1b2e9c58223c3b8450e3448a0d3f275637d Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 15:03:40 +0300 Subject: [PATCH 043/177] Integrate SimpleRouter for PaymentViewSet in payments URLs --- payments/urls.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 0960cbb..8bce667 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,6 +1,10 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import SimpleRouter +from payments.views import PaymentViewSet +router = SimpleRouter() +router.register(r"payments", PaymentViewSet, basename="payments") urlpatterns = [ - # Define your URL patterns here -] + path("", include(router.urls)) +] \ No newline at end of file From bdca6f8e15bbcf3e431424767faf0b542fdbc3b9 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 14:12:41 +0200 Subject: [PATCH 044/177] implemented BookViewSet --- books/views.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/books/views.py b/books/views.py index f4787d4..58a60bd 100644 --- a/books/views.py +++ b/books/views.py @@ -1,4 +1,13 @@ -from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import AllowAny +from books.models import Book +from books.serializers import BookSerializer -# Create your views here. +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"] From 10f4fb5e87927306c804973439204e9755b9e863 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 14:13:34 +0200 Subject: [PATCH 045/177] added books router endpoint --- books/urls.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/books/urls.py b/books/urls.py index 0960cbb..4030a7e 100644 --- a/books/urls.py +++ b/books/urls.py @@ -1,6 +1,11 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from books.views import BookViewSet + +router = DefaultRouter() +router.register(r"books", BookViewSet) urlpatterns = [ - # Define your URL patterns here + path("", include(router.urls)), ] From d00094fc3f616392bf3078e49f20c073ea104536 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 14:14:21 +0200 Subject: [PATCH 046/177] configured books pagination --- books/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/books/views.py b/books/views.py index 58a60bd..6fb69dc 100644 --- a/books/views.py +++ b/books/views.py @@ -7,6 +7,7 @@ class BookViewSet(viewsets.ModelViewSet): queryset = Book.objects.all() serializer_class = BookSerializer + permission_classes = [AllowAny] search_fields = ["title", "author"] filterset_fields = ["cover", "inventory"] ordering_fields = ["title", "author", "inventory", "daily_fee"] From 8381580d02967e460ecafbd4e22bf966486c5119 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 14:15:21 +0200 Subject: [PATCH 047/177] added tests for unauthenticated access to book list --- books/tests/test_views.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 books/tests/test_views.py diff --git a/books/tests/test_views.py b/books/tests/test_views.py new file mode 100644 index 0000000..31f716b --- /dev/null +++ b/books/tests/test_views.py @@ -0,0 +1,14 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +class UnauthenticatedBookApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.list_url = reverse("book-list") + + def test_books_list_unauthorized(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) From e330820bcb3b2508db290db41638c4ce78f3e7f5 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 14:16:43 +0200 Subject: [PATCH 048/177] added default filters and pagination settings --- core/settings/base.py | 8 ++++++++ requirements.txt | 1 + 2 files changed, 9 insertions(+) diff --git a/core/settings/base.py b/core/settings/base.py index 1d607ce..bd52061 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -18,6 +18,7 @@ "django.contrib.staticfiles", "debug_toolbar", "rest_framework", + "django_filters", "drf_spectacular", "core", "users", @@ -86,6 +87,13 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "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 = { diff --git a/requirements.txt b/requirements.txt index 7e7efec..ffe74dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ django==5.2.6 djangorestframework==3.16.1 +django-filter==24.3 drf-spectacular==0.28.0 python-dotenv==1.1.1 psycopg2-binary==2.9.10 From f22f2e4b647d7f98be86cf26674d020620a97651 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 14:19:37 +0200 Subject: [PATCH 049/177] fix formatting --- books/admin.py | 4 +--- books/tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/books/admin.py b/books/admin.py index 899b094..7266287 100644 --- a/books/admin.py +++ b/books/admin.py @@ -15,6 +15,4 @@ class BookAdmin(admin.ModelAdmin): "title", "author", ) - list_filter = ( - "cover", - ) + list_filter = ("cover",) diff --git a/books/tests/test_models.py b/books/tests/test_models.py index bef220d..d18946b 100644 --- a/books/tests/test_models.py +++ b/books/tests/test_models.py @@ -22,7 +22,7 @@ def test_inventory_cannot_be_negative(self): cover="HARD", inventory=-5, daily_fee=10, - ) + ) with self.assertRaises(ValidationError): book.full_clean() @@ -33,7 +33,7 @@ def test_valid_book_can_be_saved(self): cover="HARD", inventory=5, daily_fee=2.5, - ) + ) book.full_clean() book.save() self.assertEqual(Book.objects.count(), 1) From 98b8be0f2e228aaee9152d8c039bf1f45e946e21 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 15:41:26 +0300 Subject: [PATCH 050/177] fix: resolve formatting issues in `payments` app URLs and views --- payments/urls.py | 4 +--- payments/views.py | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 8bce667..d9266c0 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -5,6 +5,4 @@ router = SimpleRouter() router.register(r"payments", PaymentViewSet, basename="payments") -urlpatterns = [ - path("", include(router.urls)) -] \ No newline at end of file +urlpatterns = [path("", include(router.urls))] diff --git a/payments/views.py b/payments/views.py index 76ac28a..69acae0 100644 --- a/payments/views.py +++ b/payments/views.py @@ -15,5 +15,6 @@ class PaymentViewSet( - create: POST /payments/ - retrieve: GET /payments/{id}/ """ + queryset = Payment.objects.all().order_by("-id") serializer_class = PaymentSerializer From f9828f57071928fafd41d2a77956b407f7598d8a Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 15:51:42 +0300 Subject: [PATCH 051/177] add Borrowing admin with list display, filters, search --- borrowings/admin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/borrowings/admin.py b/borrowings/admin.py index 57f4b53..55b5816 100644 --- a/borrowings/admin.py +++ b/borrowings/admin.py @@ -1,4 +1,22 @@ from django.contrib import admin + from borrowings.models import Borrowing -admin.site.register(Borrowing) + +@admin.register(Borrowing) +class BorrowingAdmin(admin.ModelAdmin): + list_display = ( + "user", + "book", + "borrow_date", + "expected_return_date", + "actual_return_date", + ) + + list_filter = ("borrow_date", "expected_return_date", "actual_return_date") + + search_fields = ( + "user__username", + "user__email", + "book__title", + ) From 3cfd3bbd54af644f126a7036c6b7513a3905a097 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 15:59:21 +0300 Subject: [PATCH 052/177] feat(users): configured SimpleJWT in settings.py --- core/settings/base.py | 4 ++++ requirements.txt | Bin 213 -> 504 bytes 2 files changed, 4 insertions(+) diff --git a/core/settings/base.py b/core/settings/base.py index 1d607ce..b79cb84 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -25,6 +25,7 @@ "borrowings", "payments", "notifications", + "rest_framework_simplejwt", ] MIDDLEWARE = [ @@ -86,6 +87,9 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), } SPECTACULAR_SETTINGS = { diff --git a/requirements.txt b/requirements.txt index 7e7efec6a2d036275dcc46c3bce464826b8895f4..ebdcc97c7773d240dcdfb0740eb87ef71ab2ee3b 100644 GIT binary patch literal 504 zcmZ{h>#D*)420*ma2};%>-GHbS;R}d9JMWLRr>Pkm#l>zM8fW}Br};wo{x?Gv{a#; zO3jI}`pP&H{ivsIHYnFcwYCZ%&iw9Rf{u literal 213 zcmXYr%?^Vg5QOh0J_;MbpW4Gb3?SN83I?%_Z{Jm$J=(Gch z?j-sADiMP-+az*~-W;%wsy~a{fu0&tCz_?WB0xj(WRG6Yt_N;ZA0gwl Date: Mon, 22 Sep 2025 15:01:56 +0200 Subject: [PATCH 053/177] extended permissions --- books/views.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/books/views.py b/books/views.py index 6fb69dc..85e617a 100644 --- a/books/views.py +++ b/books/views.py @@ -1,5 +1,5 @@ from rest_framework import viewsets -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAdminUser from books.models import Book from books.serializers import BookSerializer @@ -7,8 +7,14 @@ class BookViewSet(viewsets.ModelViewSet): queryset = Book.objects.all() serializer_class = BookSerializer - permission_classes = [AllowAny] 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 = [IsAdminUser] + return [permission() for permission in permission_classes] From 78f64bbf124af5373336b256911c2efa58a56be3 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 16:05:18 +0300 Subject: [PATCH 054/177] feat(users): configured Simple JWT additional settings (token lifetime, rotation, blacklist) --- core/settings/base.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/settings/base.py b/core/settings/base.py index b79cb84..b2d9bbd 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -1,3 +1,4 @@ +from datetime import timedelta from pathlib import Path import os from dotenv import load_dotenv @@ -106,3 +107,11 @@ } 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, +} From 90a65222f984d046b47893464df6ac8c1a4a7d02 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 16:15:38 +0300 Subject: [PATCH 055/177] test: add endpoint tests for Payment API and update serializer read-only fields --- payments/serializers.py | 2 +- payments/tests/test_endpoints.py | 114 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 payments/tests/test_endpoints.py diff --git a/payments/serializers.py b/payments/serializers.py index 7c65deb..79ce037 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -14,7 +14,7 @@ class Meta: "session_id", "money_to_pay", ] - read_only_fields = ["id"] + read_only_fields = ["id", "session_url", "session_id"] def validate_status(self, value): if value not in dict(PaymentStatus.choices): diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py new file mode 100644 index 0000000..92fe5d8 --- /dev/null +++ b/payments/tests/test_endpoints.py @@ -0,0 +1,114 @@ +from decimal import Decimal + +from django.urls import reverse +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from payments.models import Payment, PaymentStatus, PaymentType + + +class PaymentEndpointsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.list_url = reverse("payments-list") + + def test_list_orders_by_id_desc(self): + p1 = Payment.objects.create( + status=PaymentStatus.PENDING, + payment_type=PaymentType.PAYMENT, + borrowing_id=1, + money_to_pay=Decimal("10.00"), + ) + p2 = Payment.objects.create( + status=PaymentStatus.PAID, + payment_type=PaymentType.FINE, + borrowing_id=2, + money_to_pay=Decimal("5.00"), + ) + + resp = self.client.get(self.list_url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + ids = [item["id"] for item in resp.data] + self.assertGreaterEqual(len(ids), 2) + self.assertGreater(ids[0], ids[1]) + self.assertIn(p1.id, ids) + self.assertIn(p2.id, ids) + + def test_create_ignores_read_only_fields(self): + payload = { + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 10, + "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): + payload = { + "payment_type": "INVALID", + "borrowing_id": 1, + "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) + + def test_create_negative_amount_returns_400(self): + payload = { + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 1, + "money_to_pay": "-0.01", + } + resp = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("money_to_pay", resp.data) + + def test_retrieve_existing(self): + p = Payment.objects.create( + status=PaymentStatus.PENDING, + payment_type=PaymentType.FINE, + borrowing_id=77, + money_to_pay=Decimal("0.00"), + ) + url = reverse("payments-detail", args=[p.id]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["id"], p.id) + self.assertEqual(resp.data["payment_type"], PaymentType.FINE) + self.assertEqual(resp.data["borrowing_id"], 77) + self.assertEqual(resp.data["money_to_pay"], "0.00") + + def test_retrieve_not_found_returns_404(self): + url = reverse("payments-detail", args=[999999]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_method_not_allowed_on_collection(self): + resp = self.client.put(self.list_url, data={}, format="json") + self.assertEqual(resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_method_not_allowed_on_detail(self): + p = Payment.objects.create( + status=PaymentStatus.PENDING, + payment_type=PaymentType.PAYMENT, + borrowing_id=1, + money_to_pay=Decimal("10.00"), + ) + detail_url = reverse("payments-detail", args=[p.id]) + + resp_patch = self.client.patch(detail_url, data={"status": PaymentStatus.PAID}, format="json") + resp_put = self.client.put(detail_url, data={"status": PaymentStatus.PAID}, format="json") + resp_delete = self.client.delete(detail_url) + + self.assertEqual(resp_patch.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + self.assertEqual(resp_put.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + self.assertEqual(resp_delete.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) From 8fa11489b192607b63a7dd032c87cc837469c993 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 16:15:50 +0300 Subject: [PATCH 056/177] test: add serializer tests for PaymentSerializer --- payments/tests/test_serializers.py | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 payments/tests/test_serializers.py diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py new file mode 100644 index 0000000..d83c465 --- /dev/null +++ b/payments/tests/test_serializers.py @@ -0,0 +1,65 @@ +from decimal import Decimal +from django.test import TestCase +from payments.models import Payment, PaymentStatus, PaymentType +from payments.serializers import PaymentSerializer + + +class PaymentSerializerTests(TestCase): + def test_create_payment_valid(self): + data = { + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 123, + "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, 123) + 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_negative_amount(self): + data = { + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 1, + "money_to_pay": "-0.01", + } + serializer = PaymentSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("money_to_pay", 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) From 944cd915289a4cd4b4c859022fba8ed04166cb76 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 16:16:01 +0300 Subject: [PATCH 057/177] test: add view tests for PaymentViewSet --- payments/tests/test_views.py | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 payments/tests/test_views.py diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py new file mode 100644 index 0000000..ca66b43 --- /dev/null +++ b/payments/tests/test_views.py @@ -0,0 +1,78 @@ +from decimal import Decimal +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 + + +class PaymentViewSetTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_list_payments(self): + Payment.objects.create( + status=PaymentStatus.PENDING, + payment_type=PaymentType.PAYMENT, + borrowing_id=1, + money_to_pay=Decimal("10.00"), + ) + Payment.objects.create( + status=PaymentStatus.PAID, + payment_type=PaymentType.FINE, + borrowing_id=2, + money_to_pay=Decimal("5.00"), + ) + + url = reverse("payments-list") + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertIsInstance(resp.data, list) + self.assertGreaterEqual(len(resp.data), 2) + first = resp.data[0] + self.assertIn("id", first) + self.assertIn("status", first) + self.assertIn("payment_type", first) + self.assertIn("money_to_pay", first) + + def test_create_payment_success(self): + url = reverse("payments-list") + payload = { + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 10, + "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_id"], 10) + self.assertEqual(resp.data["money_to_pay"], "12.34") + self.assertEqual(resp.data["status"], PaymentStatus.PENDING) + + def test_create_payment_invalid_status(self): + url = reverse("payments-list") + payload = { + "status": "INVALID", + "payment_type": PaymentType.PAYMENT, + "borrowing_id": 10, + "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) + + def test_retrieve_payment(self): + p = Payment.objects.create( + status=PaymentStatus.PENDING, + payment_type=PaymentType.FINE, + borrowing_id=77, + money_to_pay=Decimal("0.00"), + ) + url = reverse("payments-detail", args=[p.id]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["id"], p.id) + self.assertEqual(resp.data["payment_type"], PaymentType.FINE) + self.assertEqual(resp.data["borrowing_id"], 77) + self.assertEqual(resp.data["money_to_pay"], "0.00") From 129bc9a5dead27933d2a0e77e2088ebbc51cbd76 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 16:25:41 +0300 Subject: [PATCH 058/177] docs: add schema annotations to PaymentViewSet endpoints --- payments/views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/payments/views.py b/payments/views.py index 69acae0..d3d3339 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,8 +1,27 @@ +from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import mixins, viewsets from payments.models import Payment from payments.serializers import PaymentSerializer +@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, From 1be33c5917e7e41b0e9ad27409c2f69bd261e733 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 16:28:44 +0300 Subject: [PATCH 059/177] feat(users): Add register and me endpoints for User --- users/urls.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/users/urls.py b/users/urls.py index 0960cbb..faf2601 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,6 +1,23 @@ 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") +router.register("me", ManageUserViewSet, basename="me") + urlpatterns = [ - # Define your URL patterns here + 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"), ] + +urlpatterns += router.urls From 709549f7a290d8fa3b11cfcab4f12b20bae14df9 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 16:40:41 +0300 Subject: [PATCH 060/177] test(users): add tests for JWT obtain and refresh token endpoints --- users/tests/test_jwt.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 users/tests/test_jwt.py 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) From d12a9fa03aefc9be50c337e68e1480e311c5a2e0 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 16:47:00 +0300 Subject: [PATCH 061/177] add tests for borrowings model --- borrowings/tests/__init__.py | 0 borrowings/tests/test_models.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 borrowings/tests/__init__.py create mode 100644 borrowings/tests/test_models.py 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..2a1625a --- /dev/null +++ b/borrowings/tests/test_models.py @@ -0,0 +1,60 @@ +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(username="denis", password="12345") + self.book = Book.objects.create(title="Test Book") + + 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.assertEqual(borrowing.actual_return_date, None) + self.assertEqual(borrowing.expected_return_date, expected_return) + self.assertEqual(str(borrowing), f"{self.user} borrowed {self.book} on {borrowing.borrow_date}") + + def test_expected_return_date_constraint(self): + expected_return = date.today() - timedelta(days=1) + with self.assertRaises(Exception): + Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=expected_return + ) + + 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_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() \ No newline at end of file From 55800f98b7007e11238126a98f6e1343bc924b06 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 16:55:44 +0300 Subject: [PATCH 062/177] docs: add schema annotations to PaymentViewSet endpoints --- payments/tests/test_endpoints.py | 20 +++++++++++++++----- payments/views.py | 3 ++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index 92fe5d8..cc93179 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -105,10 +105,20 @@ def test_method_not_allowed_on_detail(self): ) detail_url = reverse("payments-detail", args=[p.id]) - resp_patch = self.client.patch(detail_url, data={"status": PaymentStatus.PAID}, format="json") - resp_put = self.client.put(detail_url, data={"status": PaymentStatus.PAID}, format="json") + resp_patch = self.client.patch( + detail_url, data={"status": PaymentStatus.PAID}, format="json" + ) + resp_put = self.client.put( + detail_url, data={"status": PaymentStatus.PAID}, format="json" + ) resp_delete = self.client.delete(detail_url) - self.assertEqual(resp_patch.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - self.assertEqual(resp_put.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - self.assertEqual(resp_delete.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + self.assertEqual( + resp_patch.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + self.assertEqual( + resp_put.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + self.assertEqual( + resp_delete.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) diff --git a/payments/views.py b/payments/views.py index d3d3339..0c93cd5 100644 --- a/payments/views.py +++ b/payments/views.py @@ -17,7 +17,8 @@ ), create=extend_schema( summary="Create a payment", - description="Creates a new payment. session_url and session_id are read-only.", + description="Creates a new payment. session_url" + "and session_id are read-only.", request=PaymentSerializer, responses=PaymentSerializer, ), From 0a50f914b9dd4d0af9a903778b94b917ff095e4b Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 17:03:14 +0300 Subject: [PATCH 063/177] docs: fix description formatting in PaymentViewSet schema --- payments/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/views.py b/payments/views.py index 0c93cd5..a237748 100644 --- a/payments/views.py +++ b/payments/views.py @@ -18,7 +18,7 @@ create=extend_schema( summary="Create a payment", description="Creates a new payment. session_url" - "and session_id are read-only.", + "and session_id are read-only.", request=PaymentSerializer, responses=PaymentSerializer, ), From 3535c5fbfb8bec5789c7982878f26cdcddc86154 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 17:01:08 +0300 Subject: [PATCH 064/177] fix requirements.txt --- requirements.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/requirements.txt b/requirements.txt index e69de29..f560ee8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,12 @@ +django==5.2.6 +djangorestframework==3.16.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 + From 85a273429eaebe11d315e449911c205ab39c9624 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 17:07:44 +0300 Subject: [PATCH 065/177] fix requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f560ee8..1644ef8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,3 @@ pytest-django==4.11.1 flake8==7.3.0 black==25.9.0 djangorestframework_simplejwt==5.5.1 - From da0d6301d3dd26db350548013864141154dd62b9 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 17:17:33 +0300 Subject: [PATCH 066/177] fix tests --- borrowings/tests/test_models.py | 55 ++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/borrowings/tests/test_models.py b/borrowings/tests/test_models.py index 2a1625a..31e0ada 100644 --- a/borrowings/tests/test_models.py +++ b/borrowings/tests/test_models.py @@ -8,8 +8,21 @@ class BorrowingModelTest(TestCase): def setUp(self): - self.user = User.objects.create_user(username="denis", password="12345") - self.book = Book.objects.create(title="Test Book") + 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) @@ -20,19 +33,32 @@ def test_create_borrowing(self): ) self.assertEqual(borrowing.user, self.user) self.assertEqual(borrowing.book, self.book) - self.assertEqual(borrowing.actual_return_date, None) - self.assertEqual(borrowing.expected_return_date, expected_return) - self.assertEqual(str(borrowing), f"{self.user} borrowed {self.book} on {borrowing.borrow_date}") + self.assertIsNone(borrowing.actual_return_date) + self.assertEqual( + str(borrowing), + f"{self.user} borrowed {self.book} on {borrowing.borrow_date}" + ) def test_expected_return_date_constraint(self): - expected_return = date.today() - timedelta(days=1) + past_date = date.today() - timedelta(days=1) with self.assertRaises(Exception): Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=expected_return + 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( @@ -40,7 +66,6 @@ def test_unique_active_borrowing_constraint(self): book=self.book, expected_return_date=expected_return ) - with self.assertRaises(Exception): Borrowing.objects.create( user=self.user, @@ -48,13 +73,19 @@ def test_unique_active_borrowing_constraint(self): expected_return_date=expected_return ) - def test_actual_return_date_constraint(self): + 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 = borrowing.borrow_date - timedelta(days=1) - with self.assertRaises(Exception): - borrowing.save() \ No newline at end of file + 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) \ No newline at end of file From 8d0a59ea5181b7727cb7c6e5c2d7b310a2ac466a Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 17:01:08 +0300 Subject: [PATCH 067/177] fix requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 1644ef8..f560ee8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ pytest-django==4.11.1 flake8==7.3.0 black==25.9.0 djangorestframework_simplejwt==5.5.1 + From 05563e4197e379c249e1828442866ef7f12255f6 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 17:21:22 +0300 Subject: [PATCH 068/177] fixed requirements.txt --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f560ee8..9cfd3a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ django==5.2.6 djangorestframework==3.16.1 +django-filter==24.3 drf-spectacular==0.28.0 python-dotenv==1.1.1 psycopg2-binary==2.9.10 @@ -8,5 +9,3 @@ pytest==8.4.2 pytest-django==4.11.1 flake8==7.3.0 black==25.9.0 -djangorestframework_simplejwt==5.5.1 - From 58535c05bf166b68bd5566d964c3902c9f7dc709 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 17:22:30 +0300 Subject: [PATCH 069/177] black fix --- borrowings/tests/test_models.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/borrowings/tests/test_models.py b/borrowings/tests/test_models.py index 31e0ada..364ff93 100644 --- a/borrowings/tests/test_models.py +++ b/borrowings/tests/test_models.py @@ -1,16 +1,17 @@ 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" + email="denis@example.com", password="12345" ) self.book = Book.objects.create( @@ -18,7 +19,7 @@ def setUp(self): author="Test Author", cover="HARD", inventory=5, - daily_fee=2.5 + daily_fee=2.5, ) self.borrow_date = date.today() @@ -29,23 +30,21 @@ def test_create_borrowing(self): borrowing = Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=expected_return + 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"{self.user} borrowed {self.book} on {borrowing.borrow_date}" + f"{self.user} borrowed {self.book} on {borrowing.borrow_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 + user=self.user, book=self.book, expected_return_date=past_date ) def test_actual_return_date_constraint(self): @@ -53,9 +52,11 @@ def test_actual_return_date_constraint(self): borrowing = Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=expected_return + expected_return_date=expected_return, + ) + borrowing.actual_return_date = borrowing.borrow_date - timedelta( + days=1 ) - borrowing.actual_return_date = borrowing.borrow_date - timedelta(days=1) with self.assertRaises(Exception): borrowing.save() @@ -64,13 +65,13 @@ def test_unique_active_borrowing_constraint(self): Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=expected_return + expected_return_date=expected_return, ) with self.assertRaises(Exception): Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=expected_return + expected_return_date=expected_return, ) def test_allow_new_borrow_after_return(self): @@ -78,7 +79,7 @@ def test_allow_new_borrow_after_return(self): borrowing = Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=expected_return + expected_return_date=expected_return, ) borrowing.actual_return_date = date.today() borrowing.save() @@ -86,6 +87,6 @@ def test_allow_new_borrow_after_return(self): new_borrowing = Borrowing.objects.create( user=self.user, book=self.book, - expected_return_date=date.today() + timedelta(days=7) + expected_return_date=date.today() + timedelta(days=7), ) - self.assertIsNotNone(new_borrowing) \ No newline at end of file + self.assertIsNotNone(new_borrowing) From e76435c949bc69402fab8cacc8e532bec3bea8df Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 17:30:39 +0300 Subject: [PATCH 070/177] test: update tests to handle paginated response data in payments APIs --- payments/tests/test_endpoints.py | 7 ++++++- payments/tests/test_views.py | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index cc93179..5447f59 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -29,7 +29,12 @@ def test_list_orders_by_id_desc(self): resp = self.client.get(self.list_url) self.assertEqual(resp.status_code, status.HTTP_200_OK) - ids = [item["id"] for item in resp.data] + items = ( + resp.data["results"] + if isinstance(resp.data, dict) and "results" in resp.data + else resp.data + ) + ids = [item["id"] for item in items] self.assertGreaterEqual(len(ids), 2) self.assertGreater(ids[0], ids[1]) self.assertIn(p1.id, ids) diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index ca66b43..79a6dc7 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -28,9 +28,14 @@ def test_list_payments(self): url = reverse("payments-list") resp = self.client.get(url) self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertIsInstance(resp.data, list) - self.assertGreaterEqual(len(resp.data), 2) - first = resp.data[0] + data = ( + resp.data["results"] + if isinstance(resp.data, dict) and "results" in resp.data + else resp.data + ) + self.assertIsInstance(data, list) + self.assertGreaterEqual(len(data), 2) + first = data[0] self.assertIn("id", first) self.assertIn("status", first) self.assertIn("payment_type", first) From d1a5fc6f5956c9545dc90cb3d1d963f3c42fab39 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Mon, 22 Sep 2025 17:31:54 +0300 Subject: [PATCH 071/177] fixed requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 9cfd3a8..a2e09ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ pytest==8.4.2 pytest-django==4.11.1 flake8==7.3.0 black==25.9.0 +djangorestframework_simplejwt==5.5.1 From e1ced2e4cb184da28a0698cf530be2406477f3e2 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 19:21:49 +0300 Subject: [PATCH 072/177] refactor: replace `borrowing_id` with `borrowing` relationship across `payments` app and update related tests and serializers --- borrowings/tests.py | 4 -- payments/admin.py | 3 +- payments/models.py | 32 ++++------ payments/serializers.py | 10 +++- payments/tests/test_endpoints.py | 96 ++++++++++++++---------------- payments/tests/test_models.py | 77 ++++++++++++++---------- payments/tests/test_serializers.py | 31 +++++++++- payments/tests/test_views.py | 49 ++++++++++++--- 8 files changed, 181 insertions(+), 121 deletions(-) delete mode 100644 borrowings/tests.py diff --git a/borrowings/tests.py b/borrowings/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/borrowings/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. diff --git a/payments/admin.py b/payments/admin.py index 5d50970..6e366f7 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -22,10 +22,11 @@ class PaymentAdmin(admin.ModelAdmin): "id", "status", "payment_type", + "borrowing", "money_to_pay", "session_id", ) list_filter = ("status", "payment_type") - search_fields = ("session_id",) + search_fields = ("session_id", "borrowing__id") readonly_fields = ("session_url", "session_id") ordering = ("-id",) diff --git a/payments/models.py b/payments/models.py index 65d0191..5aaba8e 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,5 +1,5 @@ from django.db import models -from django.db.models import Q +from django.db.models import Q # noqa class PaymentStatus(models.TextChoices): @@ -30,20 +30,14 @@ class Payment(models.Model): db_index=True, ) - """Implemented as ForeignKey to Borrowing model. - After Borrowing model implementation, - this field will be used to store the borrowing - that is being paid for.""" - # borrowing = models.ForeignKey( - # "borrowings.Borrowing", - # on_delete=models.PROTECT, - # related_name="payments", - # db_index=True, - # ) - """Temporary field to store borrowing id. - Before Borrowing model implementation use this field - to store the borrowing that is being paid for.""" - borrowing_id = models.PositiveIntegerField(db_index=True) + borrowing = models.ForeignKey( + "borrowings.Borrowing", + on_delete=models.PROTECT, + 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( @@ -52,9 +46,7 @@ class Payment(models.Model): money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) - """Constraints and indexes. - Can be used only after Borrowing model implementation.""" - + """Constraints and indexes. Can be used only after Borrowing model implementation.""" class Meta: db_table = "payment" constraints = [ @@ -63,9 +55,9 @@ class Meta: name="money_to_pay_non_negative", ), models.UniqueConstraint( - fields=["borrowing_id", "payment_type"], + fields=["borrowing", "payment_type"], condition=Q(status=PaymentStatus.PENDING), - name="uniq_pending_payment_per_borrowing_type_tmp", + name="uniq_pending_payment_per_borrowing_type", ), ] diff --git a/payments/serializers.py b/payments/serializers.py index 79ce037..179d831 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -1,8 +1,14 @@ from rest_framework import serializers from payments.models import Payment, PaymentStatus, PaymentType +from borrowings.models import Borrowing class PaymentSerializer(serializers.ModelSerializer): + borrowing_id = serializers.PrimaryKeyRelatedField( + source="borrowing", + queryset=Borrowing.objects.all(), + ) + class Meta: model = Payment fields = [ @@ -30,7 +36,5 @@ def validate_money_to_pay(self, value): if value is None: raise serializers.ValidationError("money_to_pay is required") if value < 0: - raise serializers.ValidationError( - "money_to_pay must be non-negative" - ) + raise serializers.ValidationError("money_to_pay must be non-negative") return value diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index 5447f59..5109b13 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -1,49 +1,74 @@ 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-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_list_orders_by_id_desc(self): - p1 = Payment.objects.create( + b1 = self._create_borrowing() + b2 = self._create_borrowing() + + Payment.objects.create( status=PaymentStatus.PENDING, payment_type=PaymentType.PAYMENT, - borrowing_id=1, + borrowing=b1, money_to_pay=Decimal("10.00"), ) - p2 = Payment.objects.create( + Payment.objects.create( status=PaymentStatus.PAID, payment_type=PaymentType.FINE, - borrowing_id=2, + borrowing=b2, money_to_pay=Decimal("5.00"), ) resp = self.client.get(self.list_url) self.assertEqual(resp.status_code, status.HTTP_200_OK) - items = ( - resp.data["results"] - if isinstance(resp.data, dict) and "results" in resp.data - else resp.data - ) + items = resp.data["results"] if isinstance(resp.data, dict) and "results" in resp.data else resp.data ids = [item["id"] for item in items] self.assertGreaterEqual(len(ids), 2) self.assertGreater(ids[0], ids[1]) - self.assertIn(p1.id, ids) - self.assertIn(p2.id, ids) def test_create_ignores_read_only_fields(self): + borrowing = self._create_borrowing() payload = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": 10, + "borrowing_id": borrowing.id, "money_to_pay": "12.34", "session_url": "https://malicious.example/override", "session_id": "fake-session", @@ -58,9 +83,10 @@ def test_create_ignores_read_only_fields(self): self.assertIsNone(obj.session_id) def test_create_invalid_payment_type_returns_400(self): + borrowing = self._create_borrowing() payload = { "payment_type": "INVALID", - "borrowing_id": 1, + "borrowing_id": borrowing.id, "money_to_pay": "10.00", } resp = self.client.post(self.list_url, data=payload, format="json") @@ -68,9 +94,10 @@ def test_create_invalid_payment_type_returns_400(self): self.assertIn("payment_type", resp.data) def test_create_negative_amount_returns_400(self): + borrowing = self._create_borrowing() payload = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": 1, + "borrowing_id": borrowing.id, "money_to_pay": "-0.01", } resp = self.client.post(self.list_url, data=payload, format="json") @@ -78,10 +105,11 @@ def test_create_negative_amount_returns_400(self): self.assertIn("money_to_pay", resp.data) def test_retrieve_existing(self): + borrowing = self._create_borrowing() p = Payment.objects.create( status=PaymentStatus.PENDING, payment_type=PaymentType.FINE, - borrowing_id=77, + borrowing=borrowing, money_to_pay=Decimal("0.00"), ) url = reverse("payments-detail", args=[p.id]) @@ -89,41 +117,5 @@ def test_retrieve_existing(self): self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.data["id"], p.id) self.assertEqual(resp.data["payment_type"], PaymentType.FINE) - self.assertEqual(resp.data["borrowing_id"], 77) + self.assertEqual(resp.data["borrowing_id"], borrowing.id) self.assertEqual(resp.data["money_to_pay"], "0.00") - - def test_retrieve_not_found_returns_404(self): - url = reverse("payments-detail", args=[999999]) - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) - - def test_method_not_allowed_on_collection(self): - resp = self.client.put(self.list_url, data={}, format="json") - self.assertEqual(resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - - def test_method_not_allowed_on_detail(self): - p = Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.PAYMENT, - borrowing_id=1, - money_to_pay=Decimal("10.00"), - ) - detail_url = reverse("payments-detail", args=[p.id]) - - resp_patch = self.client.patch( - detail_url, data={"status": PaymentStatus.PAID}, format="json" - ) - resp_put = self.client.put( - detail_url, data={"status": PaymentStatus.PAID}, format="json" - ) - resp_delete = self.client.delete(detail_url) - - self.assertEqual( - resp_patch.status_code, status.HTTP_405_METHOD_NOT_ALLOWED - ) - self.assertEqual( - resp_put.status_code, status.HTTP_405_METHOD_NOT_ALLOWED - ) - self.assertEqual( - resp_delete.status_code, status.HTTP_405_METHOD_NOT_ALLOWED - ) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 5a964f2..aa044d3 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -5,21 +5,50 @@ 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_id: int = 1, + 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_id=borrowing_id, + borrowing=borrowing, status=status, payment_type=payment_type, money_to_pay=money_to_pay, @@ -28,11 +57,12 @@ def create_payment( ) def test_create_payment_success(self): - p = self.create_payment() + 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, 1) + 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) @@ -47,41 +77,26 @@ def test_money_to_pay_non_negative_constraint(self): self.assertEqual(p.money_to_pay, Decimal("0.00")) def test_unique_pending_per_borrowing_and_type_constraint(self): - # First pending for (borrowing_id=1, PAYMENT) - self.create_payment( - borrowing_id=1, - payment_type=PaymentType.PAYMENT, - status=PaymentStatus.PENDING, - ) + 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_id=1, - payment_type=PaymentType.PAYMENT, - status=PaymentStatus.PENDING, - ) + 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_id=1, - payment_type=PaymentType.PAYMENT, - status=PaymentStatus.PAID, - ) + 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_id=1, - payment_type=PaymentType.FINE, - status=PaymentStatus.PENDING, - ) + 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_id=2, + borrowing=b, status="INVALID", payment_type=PaymentType.PAYMENT, money_to_pay=Decimal("5.00"), @@ -90,7 +105,7 @@ def test_choices_validation_with_full_clean(self): p.full_clean() p2 = Payment( - borrowing_id=2, + borrowing=b, status=PaymentStatus.PENDING, payment_type="INVALID", money_to_pay=Decimal("5.00"), @@ -99,13 +114,15 @@ def test_choices_validation_with_full_clean(self): p2.full_clean() def test_session_fields_optional(self): - p = self.create_payment(session_url=None, session_id=None) + 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_id=5, + borrowing=b, status=PaymentStatus.PENDING, payment_type=PaymentType.FINE, money_to_pay=Decimal("12.34"), @@ -113,5 +130,5 @@ def test_str_representation(self): s = str(p) self.assertIn("status=PENDING", s) self.assertIn("type=FINE", s) - self.assertIn("borrowing_id=5", 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 index d83c465..e8af062 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -2,13 +2,38 @@ from django.test import TestCase from payments.models import Payment, PaymentStatus, PaymentType from payments.serializers import PaymentSerializer - +from borrowings.models import Borrowing +from books.models import Book +from django.contrib.auth import get_user_model +from datetime import date, timedelta 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_id": 123, + "borrowing_id": borrowing.id, "money_to_pay": "15.50", } serializer = PaymentSerializer(data=data) @@ -19,7 +44,7 @@ def test_create_payment_valid(self): self.assertIsNotNone(obj.id) self.assertEqual(obj.status, PaymentStatus.PENDING) self.assertEqual(obj.payment_type, PaymentType.PAYMENT) - self.assertEqual(obj.borrowing_id, 123) + 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) diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index 79a6dc7..5fc9cfc 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -1,27 +1,57 @@ +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_list_payments(self): + b1 = self._create_borrowing() + b2 = self._create_borrowing() + Payment.objects.create( status=PaymentStatus.PENDING, payment_type=PaymentType.PAYMENT, - borrowing_id=1, + borrowing=b1, money_to_pay=Decimal("10.00"), ) Payment.objects.create( status=PaymentStatus.PAID, payment_type=PaymentType.FINE, - borrowing_id=2, + borrowing=b2, money_to_pay=Decimal("5.00"), ) @@ -42,25 +72,27 @@ def test_list_payments(self): self.assertIn("money_to_pay", first) def test_create_payment_success(self): + borrowing = self._create_borrowing() url = reverse("payments-list") payload = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": 10, + "borrowing_id": 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_id"], 10) + self.assertEqual(resp.data["borrowing_id"], 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): + borrowing = self._create_borrowing() url = reverse("payments-list") payload = { "status": "INVALID", "payment_type": PaymentType.PAYMENT, - "borrowing_id": 10, + "borrowing_id": borrowing.id, "money_to_pay": "12.34", } resp = self.client.post(url, data=payload, format="json") @@ -68,10 +100,11 @@ def test_create_payment_invalid_status(self): self.assertIn("status", resp.data) def test_retrieve_payment(self): + borrowing = self._create_borrowing() p = Payment.objects.create( status=PaymentStatus.PENDING, payment_type=PaymentType.FINE, - borrowing_id=77, + borrowing=borrowing, money_to_pay=Decimal("0.00"), ) url = reverse("payments-detail", args=[p.id]) @@ -79,5 +112,5 @@ def test_retrieve_payment(self): self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.data["id"], p.id) self.assertEqual(resp.data["payment_type"], PaymentType.FINE) - self.assertEqual(resp.data["borrowing_id"], 77) + self.assertEqual(resp.data["borrowing_id"], borrowing.id) self.assertEqual(resp.data["money_to_pay"], "0.00") From e35194ce6a9f45b24214af20aabc9e3aebc07df9 Mon Sep 17 00:00:00 2001 From: benfict Date: Mon, 22 Sep 2025 19:27:04 +0300 Subject: [PATCH 073/177] delete tests.py in borrowings app --- borrowings/tests.py | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 borrowings/tests.py diff --git a/borrowings/tests.py b/borrowings/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/borrowings/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. From f2f4fb32293c014a865b54db0603ade74089130a Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 19:32:20 +0300 Subject: [PATCH 074/177] refactor: apply consistent formatting across tests, models, and serializers in `payments` app --- payments/models.py | 6 +++++- payments/serializers.py | 4 +++- payments/tests/test_endpoints.py | 6 +++++- payments/tests/test_models.py | 24 ++++++++++++++++++++---- payments/tests/test_serializers.py | 1 + 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/payments/models.py b/payments/models.py index 5aaba8e..9f37902 100644 --- a/payments/models.py +++ b/payments/models.py @@ -46,7 +46,11 @@ class Payment(models.Model): money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) - """Constraints and indexes. Can be used only after Borrowing model implementation.""" + """ + Constraints and indexes. + Can be used only after Borrowing model implementation. + """ + class Meta: db_table = "payment" constraints = [ diff --git a/payments/serializers.py b/payments/serializers.py index 179d831..237f8dc 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -36,5 +36,7 @@ def validate_money_to_pay(self, value): if value is None: raise serializers.ValidationError("money_to_pay is required") if value < 0: - raise serializers.ValidationError("money_to_pay must be non-negative") + raise serializers.ValidationError( + "money_to_pay must be non-negative" + ) return value diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index 5109b13..fcf6244 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -59,7 +59,11 @@ def test_list_orders_by_id_desc(self): resp = self.client.get(self.list_url) self.assertEqual(resp.status_code, status.HTTP_200_OK) - items = resp.data["results"] if isinstance(resp.data, dict) and "results" in resp.data else resp.data + items = ( + resp.data["results"] + if isinstance(resp.data, dict) and "results" in resp.data + else resp.data + ) ids = [item["id"] for item in items] self.assertGreaterEqual(len(ids), 2) self.assertGreater(ids[0], ids[1]) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index aa044d3..bdda1d5 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -78,19 +78,35 @@ def test_money_to_pay_non_negative_constraint(self): 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) + 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) + 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) + 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) + 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): diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py index e8af062..1f412fe 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from datetime import date, timedelta + class PaymentSerializerTests(TestCase): def _create_user(self): return get_user_model().objects.create_user( From 96d2eb670ba160200b9924d3331160b06d249942 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 19:33:55 +0300 Subject: [PATCH 075/177] docs: fix formatting in comments within `payments` models --- payments/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index 9f37902..636b85b 100644 --- a/payments/models.py +++ b/payments/models.py @@ -47,7 +47,7 @@ class Payment(models.Model): money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) """ - Constraints and indexes. + Constraints and indexes. Can be used only after Borrowing model implementation. """ From 2c47459035851d9e475217805e7c6a978cccbaf6 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 18:52:49 +0200 Subject: [PATCH 076/177] added book detail tests --- books/tests/test_views.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/books/tests/test_views.py b/books/tests/test_views.py index 31f716b..ef92034 100644 --- a/books/tests/test_views.py +++ b/books/tests/test_views.py @@ -3,12 +3,46 @@ from rest_framework import status from rest_framework.test import APIClient +from books.models import Book +from books.serializers import BookSerializer + class UnauthenticatedBookApiTests(TestCase): def setUp(self): self.client = APIClient() + + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="hardcover", + inventory=5, + daily_fee=9.99, + ) + self.list_url = reverse("book-list") + self.detail_url = reverse( + "book-detail", + kwargs={"pk": self.book.pk}, + ) def test_books_list_unauthorized(self): response = self.client.get(self.list_url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + def test_get_valid_book_detail(self): + response = self.client.get(self.detail_url) + + serializer = BookSerializer(self.book) self.assertEqual(response.status_code, status.HTTP_200_OK) + 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, + ) From 1312e1928f1d6f47ed4480f16d716fc0f4b6d6e2 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 20:07:12 +0300 Subject: [PATCH 077/177] fix: filter payments queryset by borrowing user for authenticated non-staff users --- payments/views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/payments/views.py b/payments/views.py index a237748..69c35d0 100644 --- a/payments/views.py +++ b/payments/views.py @@ -36,5 +36,12 @@ class PaymentViewSet( - retrieve: GET /payments/{id}/ """ - queryset = Payment.objects.all().order_by("-id") + queryset = Payment.objects.select_related("borrowing").order_by("-id") serializer_class = PaymentSerializer + + def get_queryset(self): + qs = super().get_queryset() + user = self.request.user + if not user.is_authenticated or user.is_staff: + return qs + return qs.filter(borrowing__user=user) From 42552a9c241fb0928dc5d3106ea02348c6efbec6 Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 23:57:48 +0200 Subject: [PATCH 078/177] added IsStaff permission --- books/views.py | 5 +++-- core/permissions.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 core/permissions.py diff --git a/books/views.py b/books/views.py index 85e617a..d340714 100644 --- a/books/views.py +++ b/books/views.py @@ -1,5 +1,6 @@ from rest_framework import viewsets -from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.permissions import AllowAny +from core.permissions import IsStaffUser from books.models import Book from books.serializers import BookSerializer @@ -16,5 +17,5 @@ def get_permissions(self): if self.action in ["list", "retrieve"]: permission_classes = [AllowAny] else: - permission_classes = [IsAdminUser] + permission_classes = [IsStaffUser] return [permission() for permission in permission_classes] diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..0f86d81 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + + +class IsStaffUser(permissions.BasePermission): + def has_permission(self, request, view): + return bool( + request.user + and request.user.is_authenticated + and request.user.is_staff + ) From 890bdb13aa34988c8ec74a3d8b5ce14801fb52df Mon Sep 17 00:00:00 2001 From: viannik Date: Mon, 22 Sep 2025 23:58:56 +0200 Subject: [PATCH 079/177] added tests for different user roles --- books/tests/test_views.py | 102 ++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 22 deletions(-) diff --git a/books/tests/test_views.py b/books/tests/test_views.py index ef92034..5ffcd1c 100644 --- a/books/tests/test_views.py +++ b/books/tests/test_views.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from rest_framework import status @@ -7,42 +8,99 @@ 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() - - self.book = Book.objects.create( - title="Test Book", - author="Test Author", - cover="hardcover", - inventory=5, - daily_fee=9.99, - ) - - self.list_url = reverse("book-list") - self.detail_url = reverse( - "book-detail", - kwargs={"pk": self.book.pk}, - ) + 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, - ) + 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.status_code, status.HTTP_200_OK) 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, + 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) + + +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) From 2bb5fbff99d45c795d5f3ea49ee83fa8048b1ee7 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 03:53:44 +0300 Subject: [PATCH 080/177] test(users): add tests for profile view and update (authorized and unauthorized cases) --- users/tests/test_views.py | 65 +++++++++++++++++++++++++++++++++++++++ users/urls.py | 5 ++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 users/tests/test_views.py diff --git a/users/tests/test_views.py b/users/tests/test_views.py new file mode 100644 index 0000000..1765176 --- /dev/null +++ b/users/tests/test_views.py @@ -0,0 +1,65 @@ +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) diff --git a/users/urls.py b/users/urls.py index faf2601..4acdb11 100644 --- a/users/urls.py +++ b/users/urls.py @@ -12,12 +12,15 @@ router = DefaultRouter() router.register("register", CreateUserViewSet, basename="register") -router.register("me", ManageUserViewSet, basename="me") + 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"}) + ), ] urlpatterns += router.urls From 6d9cc2b119c8ff7d0419bf667b5e515494a9d149 Mon Sep 17 00:00:00 2001 From: Arch0998 <98530620+Arch0998@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:14:19 +0300 Subject: [PATCH 081/177] refactor: reorder installed apps in base.py (#20) refactor: reorder installed apps in base.py --- core/settings/base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/settings/base.py b/core/settings/base.py index 98a7521..49b98ef 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -11,23 +11,26 @@ 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", - "rest_framework", "django_filters", "drf_spectacular", + "rest_framework", + "rest_framework_simplejwt", + # Local apps "core", "users", "books", "borrowings", "payments", "notifications", - "rest_framework_simplejwt", ] MIDDLEWARE = [ From caf158bccd36f0d389485ba5f4fd4c7bc13bf49b Mon Sep 17 00:00:00 2001 From: viannik Date: Tue, 23 Sep 2025 11:12:30 +0200 Subject: [PATCH 082/177] added tests for book updates --- books/tests/test_views.py | 87 +++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/books/tests/test_views.py b/books/tests/test_views.py index 4faccb1..b6fbd47 100644 --- a/books/tests/test_views.py +++ b/books/tests/test_views.py @@ -31,15 +31,6 @@ def setUp(self): def test_books_list_unauthorized(self): response = self.client.get(self.list_url) - self.assertEqual( - response.status_code, - status.HTTP_200_OK, - ) - - def test_get_valid_book_detail(self): - response = self.client.get(self.detail_url) - - serializer = BookSerializer(self.book) self.assertEqual(response.status_code, status.HTTP_200_OK) serializer = BookSerializer(self.book) @@ -86,6 +77,22 @@ def test_cannot_create_book(self): 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) + class StaffBookApiTests(TestCase): def setUp(self): @@ -113,3 +120,65 @@ def test_staff_can_create_book(self): 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) From 73da75f44115e78d0e05beb9a4ce368430242e32 Mon Sep 17 00:00:00 2001 From: viannik Date: Tue, 23 Sep 2025 11:12:53 +0200 Subject: [PATCH 083/177] updated book router path --- books/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/books/urls.py b/books/urls.py index 4030a7e..c714b0a 100644 --- a/books/urls.py +++ b/books/urls.py @@ -4,7 +4,7 @@ from books.views import BookViewSet router = DefaultRouter() -router.register(r"books", BookViewSet) +router.register(r"", BookViewSet) urlpatterns = [ path("", include(router.urls)), From 1056cc79a4799e0be058fff319103a0ca094ee0a Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:19:08 +0300 Subject: [PATCH 084/177] migration --- ...payment_per_borrowing_type_tmp_and_more.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 payments/migrations/0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more.py 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..020aedf --- /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-23 09:16 + +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", + ), + ), + ] From 40c84e942baa8e60f1b636756a27f8ff68dbb2d7 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 12:29:04 +0300 Subject: [PATCH 085/177] fix: filter payments queryset by borrowing user for authenticated non-staff users --- ...payment_per_borrowing_type_tmp_and_more.py | 42 ++++++++++++++++ ..._payment_borrowing_alter_payment_status.py | 45 +++++++++++++++++ payments/models.py | 9 +++- payments/tests/test_endpoints.py | 44 ----------------- payments/tests/test_views.py | 49 ------------------- payments/views.py | 10 ++-- 6 files changed, 99 insertions(+), 100 deletions(-) create mode 100644 payments/migrations/0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more.py create mode 100644 payments/migrations/0003_alter_payment_borrowing_alter_payment_status.py 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/models.py b/payments/models.py index 636b85b..3d8b7d9 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,10 +1,15 @@ 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" + EXCEEDED = "EXCEEDED", "EXCEEDED" + FAILED = "FAILED", "FAILED" class PaymentType(models.TextChoices): @@ -31,8 +36,8 @@ class Payment(models.Model): ) borrowing = models.ForeignKey( - "borrowings.Borrowing", - on_delete=models.PROTECT, + Borrowing, + on_delete=models.CASCADE, related_name="payments", db_index=True, null=True, diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index fcf6244..fa7c424 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -40,34 +40,6 @@ def _create_borrowing(self, *, user=None) -> Borrowing: expected_return_date=date.today() + timedelta(days=5), ) - def test_list_orders_by_id_desc(self): - b1 = self._create_borrowing() - b2 = self._create_borrowing() - - Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.PAYMENT, - borrowing=b1, - money_to_pay=Decimal("10.00"), - ) - Payment.objects.create( - status=PaymentStatus.PAID, - payment_type=PaymentType.FINE, - borrowing=b2, - money_to_pay=Decimal("5.00"), - ) - - resp = self.client.get(self.list_url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - items = ( - resp.data["results"] - if isinstance(resp.data, dict) and "results" in resp.data - else resp.data - ) - ids = [item["id"] for item in items] - self.assertGreaterEqual(len(ids), 2) - self.assertGreater(ids[0], ids[1]) - def test_create_ignores_read_only_fields(self): borrowing = self._create_borrowing() payload = { @@ -107,19 +79,3 @@ def test_create_negative_amount_returns_400(self): resp = self.client.post(self.list_url, data=payload, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("money_to_pay", resp.data) - - def test_retrieve_existing(self): - borrowing = self._create_borrowing() - p = Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.FINE, - borrowing=borrowing, - money_to_pay=Decimal("0.00"), - ) - url = reverse("payments-detail", args=[p.id]) - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(resp.data["id"], p.id) - self.assertEqual(resp.data["payment_type"], PaymentType.FINE) - self.assertEqual(resp.data["borrowing_id"], borrowing.id) - self.assertEqual(resp.data["money_to_pay"], "0.00") diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index 5fc9cfc..03155a0 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -38,39 +38,6 @@ def _create_borrowing(self, *, user=None) -> Borrowing: expected_return_date=date.today() + timedelta(days=7), ) - def test_list_payments(self): - b1 = self._create_borrowing() - b2 = self._create_borrowing() - - Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.PAYMENT, - borrowing=b1, - money_to_pay=Decimal("10.00"), - ) - Payment.objects.create( - status=PaymentStatus.PAID, - payment_type=PaymentType.FINE, - borrowing=b2, - money_to_pay=Decimal("5.00"), - ) - - url = reverse("payments-list") - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - data = ( - resp.data["results"] - if isinstance(resp.data, dict) and "results" in resp.data - else resp.data - ) - self.assertIsInstance(data, list) - self.assertGreaterEqual(len(data), 2) - first = data[0] - self.assertIn("id", first) - self.assertIn("status", first) - self.assertIn("payment_type", first) - self.assertIn("money_to_pay", first) - def test_create_payment_success(self): borrowing = self._create_borrowing() url = reverse("payments-list") @@ -98,19 +65,3 @@ def test_create_payment_invalid_status(self): resp = self.client.post(url, data=payload, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("status", resp.data) - - def test_retrieve_payment(self): - borrowing = self._create_borrowing() - p = Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.FINE, - borrowing=borrowing, - money_to_pay=Decimal("0.00"), - ) - url = reverse("payments-detail", args=[p.id]) - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(resp.data["id"], p.id) - self.assertEqual(resp.data["payment_type"], PaymentType.FINE) - self.assertEqual(resp.data["borrowing_id"], borrowing.id) - self.assertEqual(resp.data["money_to_pay"], "0.00") diff --git a/payments/views.py b/payments/views.py index 69c35d0..b3eef24 100644 --- a/payments/views.py +++ b/payments/views.py @@ -36,12 +36,12 @@ class PaymentViewSet( - retrieve: GET /payments/{id}/ """ - queryset = Payment.objects.select_related("borrowing").order_by("-id") + queryset = Payment.objects.select_related("borrowing") serializer_class = PaymentSerializer def get_queryset(self): - qs = super().get_queryset() + queryset = Payment.objects.select_related("borrowing") user = self.request.user - if not user.is_authenticated or user.is_staff: - return qs - return qs.filter(borrowing__user=user) + if not user.is_staff: + queryset = queryset.filter(borrowing__user=user) + return queryset From 439c92626c344f9dcd0b1cc09313c249356494c1 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:33:49 +0300 Subject: [PATCH 086/177] feat(users): separated serializers for reading and updating --- users/serializers.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/users/serializers.py b/users/serializers.py index 7bb154f..781cadc 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -28,7 +28,16 @@ def validate_email(self, value): class UserSerializer(serializers.ModelSerializer): - """Serializer for retrieving and updating user profile.""" + """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, @@ -39,7 +48,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ("id", "email", "password") + fields = ("id", "email", "first_name", "last_name", "password") read_only_fields = ("id",) def update(self, instance, validated_data): From 794ec4594ee57c93b5a2c8ff874aebe455269472 Mon Sep 17 00:00:00 2001 From: viannik Date: Tue, 23 Sep 2025 11:34:55 +0200 Subject: [PATCH 087/177] added tests for book delete --- books/tests/test_views.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/books/tests/test_views.py b/books/tests/test_views.py index b6fbd47..b4a36af 100644 --- a/books/tests/test_views.py +++ b/books/tests/test_views.py @@ -93,6 +93,10 @@ def test_cannot_patch_update_book(self): 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): @@ -182,3 +186,8 @@ def test_staff_cannot_update_with_duplicate_title_author_cover(self): 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) From 6389a839beca4949c16c2cf710d696cb7a55665b Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:37:08 +0300 Subject: [PATCH 088/177] feat(users): add get_serializer_class to use different serializers per action --- users/views.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/users/views.py b/users/views.py index 24c0863..4b064b1 100644 --- a/users/views.py +++ b/users/views.py @@ -1,8 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import viewsets, mixins, permissions -from users.serializers import UserSerializer, RegisterSerializer - +from users.serializers import UserSerializer, RegisterSerializer, UserUpdateSerializer User = get_user_model() @@ -16,8 +15,13 @@ class ManageUserViewSet( viewsets.GenericViewSet, mixins.UpdateModelMixin, mixins.RetrieveModelMixin ): queryset = User.objects.all() - serializer_class = UserSerializer + 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 From f3a44d12c28bd0a1b67fb7b41b6d9c08016bea8c Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 12:37:44 +0300 Subject: [PATCH 089/177] fix: filter payments queryset by borrowing user for authenticated non-staff users --- payments/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index 3d8b7d9..10169ba 100644 --- a/payments/models.py +++ b/payments/models.py @@ -8,8 +8,9 @@ class PaymentStatus(models.TextChoices): PENDING = "PENDING", "PENDING" PAID = "PAID", "PAID" CANCELLED = "CANCELLED", "CANCELLED" - EXCEEDED = "EXCEEDED", "EXCEEDED" + EXPIRED = "EXPIRED", "EXPIRED" FAILED = "FAILED", "FAILED" + CANCELLED = "CANCELLED", "CANCELLED" class PaymentType(models.TextChoices): From 3f996ec298a765a51a6db458ac5bcc920ddaa6bb Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:38:07 +0300 Subject: [PATCH 090/177] code style changes --- users/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/users/views.py b/users/views.py index 4b064b1..b696b20 100644 --- a/users/views.py +++ b/users/views.py @@ -1,7 +1,11 @@ from django.contrib.auth import get_user_model from rest_framework import viewsets, mixins, permissions -from users.serializers import UserSerializer, RegisterSerializer, UserUpdateSerializer +from users.serializers import ( + UserSerializer, + RegisterSerializer, + UserUpdateSerializer, +) User = get_user_model() From c161836b1d8cf0446cff7a699eaea9216779fcb4 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:39:23 +0300 Subject: [PATCH 091/177] feat(users): added action patch in router --- users/urls.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/users/urls.py b/users/urls.py index 4acdb11..89c38c1 100644 --- a/users/urls.py +++ b/users/urls.py @@ -19,7 +19,11 @@ 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"}) + "me/", + ManageUserViewSet.as_view( + {"get": "retrieve", "put": "update", "patch": "partial_update"} + ), + name="me", ), ] From dfecf30a06cc235fc35dbafc07ffe1f08f536440 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 12:41:32 +0300 Subject: [PATCH 092/177] fix: remove duplicate CANCELLED status in PaymentStatus choices --- payments/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index 10169ba..4891cd7 100644 --- a/payments/models.py +++ b/payments/models.py @@ -10,7 +10,6 @@ class PaymentStatus(models.TextChoices): CANCELLED = "CANCELLED", "CANCELLED" EXPIRED = "EXPIRED", "EXPIRED" FAILED = "FAILED", "FAILED" - CANCELLED = "CANCELLED", "CANCELLED" class PaymentType(models.TextChoices): From 7f8400fb1fee6fe3067695524feb2523a9054b47 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:48:01 +0300 Subject: [PATCH 093/177] feat(users): custom basic first and last name validation --- users/serializers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/users/serializers.py b/users/serializers.py index 781cadc..c0124d1 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -66,3 +66,21 @@ def validate_email(self, value): "User with this email already exists." ) return value + + def validate_first_name(self, value): + 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): + 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 From be2d44aea5ad1ad5700d8c1f1563f7c794f674aa Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:12:30 +0300 Subject: [PATCH 094/177] feat(users): added a test to check the read-only profile view and replaced serializers in the tests --- users/tests/test_serializers.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py index 7b4a773..146f448 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -23,9 +23,9 @@ def test_register_user(self): self.assertTrue(user.check_password("StrongPass123")) def test_update_email(self): - from users.serializers import UserSerializer + from users.serializers import UserUpdateSerializer - serializer = UserSerializer( + serializer = UserUpdateSerializer( instance=self.user, data={"email": "new@example.com"}, partial=True ) self.assertTrue(serializer.is_valid(), serializer.errors) @@ -33,9 +33,9 @@ def test_update_email(self): self.assertEqual(updated_user.email, "new@example.com") def test_update_password(self): - from users.serializers import UserSerializer + from users.serializers import UserUpdateSerializer - serializer = UserSerializer( + serializer = UserUpdateSerializer( instance=self.user, data={"password": "NewStrongPass123"}, partial=True, @@ -46,13 +46,13 @@ def test_update_password(self): def test_email_uniqueness_validation(self): from django.contrib.auth import get_user_model - from users.serializers import UserSerializer + from users.serializers import UserUpdateSerializer User = get_user_model() - other_user = User.objects.create_user( + User.objects.create_user( email="taken@example.com", password="SomePass123" ) - serializer = UserSerializer( + serializer = UserUpdateSerializer( instance=self.user, data={"email": "taken@example.com"}, partial=True, @@ -70,3 +70,13 @@ def test_register_user_success_returns_user_without_password(self): 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) From 2c6c7687cfe657836c161679e33e31a93a8412a3 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:16:27 +0300 Subject: [PATCH 095/177] test(users): add check for partial profile name update --- users/tests/test_views.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/users/tests/test_views.py b/users/tests/test_views.py index 1765176..dcb09d2 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -63,3 +63,20 @@ def test_unauthorized_user_gets_401_when_updating_profile(self): 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") From 5bcf8b5ef44417c9f04c88e0b74931d0f9d51d58 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:20:22 +0300 Subject: [PATCH 096/177] test(users): add PUT unauthorized test for /users/me/ endpoint --- users/tests/test_views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/users/tests/test_views.py b/users/tests/test_views.py index dcb09d2..636ca29 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -80,3 +80,10 @@ def test_partial_update_first_name(self): 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) From 0825bcc61c191dae993f84d4a1e8e075c78965e6 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:25:50 +0300 Subject: [PATCH 097/177] test(users): add validation test for updating email to an existing one --- users/tests/test_views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/users/tests/test_views.py b/users/tests/test_views.py index 636ca29..c3cbecd 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -87,3 +87,22 @@ def test_unauthorized_user_gets_401_when_putting_profile(self): 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) From f1cf1514b5d7d881bee1546f2fcd146b5a8a081a Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 14:10:02 +0300 Subject: [PATCH 098/177] chore: update `.env.sample` with Stripe keys and add `stripe` to dependencies --- .env.sample | 4 ++++ payments/views.py | 1 + requirements.txt | 1 + 3 files changed, 6 insertions(+) diff --git a/.env.sample b/.env.sample index 7b4cab8..70c4fd6 100644 --- a/.env.sample +++ b/.env.sample @@ -9,3 +9,7 @@ POSTGRES_USER= POSTGRES_PASSWORD= POSTGRES_HOST= POSTGRES_PORT= + +# Stripe settings +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= diff --git a/payments/views.py b/payments/views.py index a237748..b8dca32 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,5 +1,6 @@ from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import mixins, viewsets + from payments.models import Payment from payments.serializers import PaymentSerializer diff --git a/requirements.txt b/requirements.txt index a2e09ec..2d4644e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ pytest-django==4.11.1 flake8==7.3.0 black==25.9.0 djangorestframework_simplejwt==5.5.1 +stripe==12.5.1 From 3cc7124a22675a2c13e2b38526c1473a6a46d771 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 14:32:58 +0300 Subject: [PATCH 099/177] feat: add success and cancel views for Stripe payments and update settings with Stripe keys --- core/settings/base.py | 4 +++ payments/urls.py | 7 ++++- payments/views.py | 66 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/settings/base.py b/core/settings/base.py index 49b98ef..9343a6f 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -126,3 +126,7 @@ "BLACKLIST_AFTER_ROTATION": False, "UPDATE_LAST_LOGIN": False, } + +STRIPE_PUBLISHABLE_KEY = os.environ["STRIPE_PUBLISHABLE_KEY"] +STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"] + diff --git a/payments/urls.py b/payments/urls.py index d9266c0..6e101e2 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,8 +1,13 @@ from django.urls import path, include from rest_framework.routers import SimpleRouter + from payments.views import PaymentViewSet +from payments import views router = SimpleRouter() router.register(r"payments", PaymentViewSet, basename="payments") -urlpatterns = [path("", include(router.urls))] +urlpatterns = [path("", include(router.urls)), + path('success/', views.PaymentSuccessView.as_view(), name='success'), + path('cancel/', views.PaymentCancelView.as_view(), name='cancel'), +] diff --git a/payments/views.py b/payments/views.py index b8dca32..2ae9823 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,10 +1,15 @@ +import stripe +from django.conf import settings from drf_spectacular.utils import extend_schema_view, extend_schema -from rest_framework import mixins, viewsets +from requests import Response +from rest_framework import mixins, viewsets, status +from rest_framework.views import APIView -from payments.models import Payment +from payments.models import Payment, PaymentStatus from payments.serializers import PaymentSerializer +stripe.api_key = settings.STRIPE_SECRET_KEY @extend_schema_view( list=extend_schema( summary="List payments", @@ -39,3 +44,60 @@ class PaymentViewSet( queryset = Payment.objects.all().order_by("-id") serializer_class = PaymentSerializer + + +class PaymentSuccessView(APIView): + """Handle successful Stripe payment""" + + def get(self, request): + session_id = request.GET.get('session_id') + + if not session_id: + return Response( + {'error': 'Session ID is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Retrieve session from Stripe + session = stripe.checkout.Session.retrieve(session_id) + + # Find payment in database + payment = Payment.objects.get(session_id=session_id) + + # Check if payment was successful + if session.payment_status == 'paid': + payment.status = PaymentStatus.PAID + payment.save() + + return Response({ + 'message': 'Payment successful!', + 'payment_id': payment.id, + 'amount': str(payment.money_to_pay) + }) + else: + return Response( + {'error': 'Payment was not completed'}, + 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 Stripe payment""" + + def get(self, request): + return Response({ + 'message': 'Payment was cancelled. You can complete the payment later.', + 'note': 'The payment session is available for 24 hours.' + }) From 43d4488c15643e70ef74b3820005a5e1070d1223 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 14:36:51 +0300 Subject: [PATCH 100/177] refactor: apply consistent formatting across `payments` views and URLs --- core/settings/base.py | 1 - payments/urls.py | 7 ++++--- payments/views.py | 45 +++++++++++++++++++++++++------------------ 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/core/settings/base.py b/core/settings/base.py index 9343a6f..be02c3f 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -129,4 +129,3 @@ STRIPE_PUBLISHABLE_KEY = os.environ["STRIPE_PUBLISHABLE_KEY"] STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"] - diff --git a/payments/urls.py b/payments/urls.py index 6e101e2..a7c1bfb 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -7,7 +7,8 @@ router = SimpleRouter() router.register(r"payments", PaymentViewSet, basename="payments") -urlpatterns = [path("", include(router.urls)), - path('success/', views.PaymentSuccessView.as_view(), name='success'), - path('cancel/', views.PaymentCancelView.as_view(), name='cancel'), +urlpatterns = [ + path("", include(router.urls)), + path("success/", views.PaymentSuccessView.as_view(), name="success"), + path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), ] diff --git a/payments/views.py b/payments/views.py index 2ae9823..69024a5 100644 --- a/payments/views.py +++ b/payments/views.py @@ -10,6 +10,8 @@ stripe.api_key = settings.STRIPE_SECRET_KEY + + @extend_schema_view( list=extend_schema( summary="List payments", @@ -50,12 +52,12 @@ class PaymentSuccessView(APIView): """Handle successful Stripe payment""" def get(self, request): - session_id = request.GET.get('session_id') + session_id = request.GET.get("session_id") if not session_id: return Response( - {'error': 'Session ID is required'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Session ID is required"}, + status=status.HTTP_400_BAD_REQUEST, ) try: @@ -66,30 +68,32 @@ def get(self, request): payment = Payment.objects.get(session_id=session_id) # Check if payment was successful - if session.payment_status == 'paid': + if session.payment_status == "paid": payment.status = PaymentStatus.PAID payment.save() - return Response({ - 'message': 'Payment successful!', - 'payment_id': payment.id, - 'amount': str(payment.money_to_pay) - }) + return Response( + { + "message": "Payment successful!", + "payment_id": payment.id, + "amount": str(payment.money_to_pay), + } + ) else: return Response( - {'error': 'Payment was not completed'}, - status=status.HTTP_400_BAD_REQUEST + {"error": "Payment was not completed"}, + status=status.HTTP_400_BAD_REQUEST, ) except Payment.DoesNotExist: return Response( - {'error': 'Payment not found'}, - status=status.HTTP_404_NOT_FOUND + {"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 + {"error": f"Stripe error: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, ) @@ -97,7 +101,10 @@ class PaymentCancelView(APIView): """Handle cancelled Stripe payment""" def get(self, request): - return Response({ - 'message': 'Payment was cancelled. You can complete the payment later.', - 'note': 'The payment session is available for 24 hours.' - }) + return Response( + { + "message": "Payment was cancelled." + "You can complete the payment later.", + "note": "The payment session is available for 24 hours.", + } + ) From a3726d314fefeb912379a02215cda823dc87ec3d Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 14:45:08 +0300 Subject: [PATCH 101/177] feat: implement Stripe checkout session creation helper in `payments` module --- payments/stripe_helper.py | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 payments/stripe_helper.py diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py new file mode 100644 index 0000000..0754c70 --- /dev/null +++ b/payments/stripe_helper.py @@ -0,0 +1,67 @@ +import stripe +from django.conf import settings +from django.urls import reverse +from decimal import Decimal +from payments.models import PaymentType + +stripe.api_key = settings.STRIPE_SECRET_KEY + + +def create_stripe_session( + borrowing, payment_type=PaymentType.PAYMENT, request=None +): + """ + 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 + + Returns: + dict: Contains session_id and session_url + """ + 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" + elif payment_type == PaymentType.FINE: + description = "Fine for overdue book" + + 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": f"{payment_type}: {borrowing.book.title}", + "description": description, + }, + "unit_amount": amount_in_cents, + }, + "quantity": 1, + } + ], + mode="payment", + success_url=request.build_absolute_uri( + reverse("payments:success") + ), + 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)}") From 6d00d9e1c538fb4070ad2657c287101049c6bc4a Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Tue, 23 Sep 2025 14:53:16 +0300 Subject: [PATCH 102/177] feat: implement borrowing functionality with serializers, views, and admin integration --- borrowings/admin.py | 14 ++++++- borrowings/models.py | 39 ++++++++++++++---- borrowings/serializers.py | 85 +++++++++++++++++++++++++++++++++++++++ borrowings/urls.py | 11 ++++- borrowings/views.py | 46 ++++++++++++++++++++- 5 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 borrowings/serializers.py diff --git a/borrowings/admin.py b/borrowings/admin.py index 55b5816..ba11e09 100644 --- a/borrowings/admin.py +++ b/borrowings/admin.py @@ -6,17 +6,29 @@ @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__username", "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/models.py b/borrowings/models.py index 5911473..4434be8 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -1,21 +1,49 @@ +from django.contrib.auth import get_user_model from django.db import models -from django.conf import settings + +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( - "books.Book", on_delete=models.CASCADE, related_name="borrowings" + Book, + on_delete=models.CASCADE, + related_name="borrowings", ) user = models.ForeignKey( - settings.AUTH_USER_MODEL, + 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: constraints = [ models.CheckConstraint( @@ -37,6 +65,3 @@ class Meta: name="unique_active_borrowing", ), ] - - def __str__(self): - return f"{self.user} borrowed {self.book} on {self.borrow_date}" diff --git a/borrowings/serializers.py b/borrowings/serializers.py new file mode 100644 index 0000000..9739b47 --- /dev/null +++ b/borrowings/serializers.py @@ -0,0 +1,85 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from books.serializers import BookSerializer +from borrowings.models import 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): + book = validated_data.pop("book") + book.inventory -= 1 + book.save() + borrowing = Borrowing.objects.create(book=book, **validated_data) + return borrowing + + def validate_book(self, value): + if value.inventory <= 0: + raise serializers.ValidationError("Not enough books") + return value + + def validate(self, attrs): + 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/urls.py b/borrowings/urls.py index 0960cbb..d47cf8d 100644 --- a/borrowings/urls.py +++ b/borrowings/urls.py @@ -1,6 +1,13 @@ -from django.urls import path +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) urlpatterns = [ - # Define your URL patterns here + path("", include(router.urls)), ] diff --git a/borrowings/views.py b/borrowings/views.py index f4787d4..cf21c95 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -1,4 +1,46 @@ -from django.shortcuts import render +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticated +from borrowings.models import Borrowing +from borrowings.serializers import ( + BorrowingCreateSerializer, + BorrowingDetailSerializer, + BorrowingListSerializer, + BorrowingSerializer, +) -# Create your views here. + +class BorrowingViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + Borrowing.objects.all() + .select_related("book", "user") + .order_by("-borrow_date") + ) + permission_classes = (IsAuthenticated,) + filter_backends = [DjangoFilterBackend] + filterset_fields = ["user", "book", "borrow_date", "actual_return_date"] + + def get_serializer_class(self): + 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): + serializer.save(user=self.request.user) + + def get_queryset(self): + queryset = super().get_queryset() + user = self.request.user + if not user.is_staff: + queryset = queryset.filter(user=user) + return queryset From 982628c804f9ead4b4a45d9cc165c2b29b4f78bc Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Tue, 23 Sep 2025 14:53:40 +0300 Subject: [PATCH 103/177] test: add tests for borrowing serializers and views --- borrowings/tests/test_models.py | 6 ++- borrowings/tests/test_serializers.py | 55 +++++++++++++++++++++++++++ borrowings/tests/test_views.py | 57 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 borrowings/tests/test_serializers.py create mode 100644 borrowings/tests/test_views.py diff --git a/borrowings/tests/test_models.py b/borrowings/tests/test_models.py index 364ff93..5b86381 100644 --- a/borrowings/tests/test_models.py +++ b/borrowings/tests/test_models.py @@ -2,9 +2,11 @@ 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() @@ -37,7 +39,9 @@ def test_create_borrowing(self): self.assertIsNone(borrowing.actual_return_date) self.assertEqual( str(borrowing), - f"{self.user} borrowed {self.book} on {borrowing.borrow_date}", + f"From: {borrowing.borrow_date} " + f"till: {borrowing.expected_return_date} " + f"returned: {borrowing.actual_return_date}", ) def test_expected_return_date_constraint(self): 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..e459d59 --- /dev/null +++ b/borrowings/tests/test_views.py @@ -0,0 +1,57 @@ +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status +from django.contrib.auth import get_user_model + +from books.models import Book +from borrowings.models import Borrowing + + +User = get_user_model() + + +class BorrowingViewSetTest(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="testuser@example.com", password="12345" + ) + 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) + + def test_create_borrowing(self): + 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) From cec18d6101f9da877dcdd51871b8ffe0f1636509 Mon Sep 17 00:00:00 2001 From: viannik Date: Tue, 23 Sep 2025 14:58:33 +0200 Subject: [PATCH 104/177] extended schema view --- books/views.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/books/views.py b/books/views.py index d340714..a4c227f 100644 --- a/books/views.py +++ b/books/views.py @@ -1,10 +1,47 @@ 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 From 47ee46b285c360ff0ac819ece9a43132a4662b43 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:19:08 +0300 Subject: [PATCH 105/177] migration --- ...payment_per_borrowing_type_tmp_and_more.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 payments/migrations/0002_remove_payment_uniq_pending_payment_per_borrowing_type_tmp_and_more.py 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..020aedf --- /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-23 09:16 + +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", + ), + ), + ] From 075ea9ffdbb1854a35b9508e0b424e2d5f1fef23 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:33:49 +0300 Subject: [PATCH 106/177] feat(users): separated serializers for reading and updating --- users/serializers.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/users/serializers.py b/users/serializers.py index 7bb154f..781cadc 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -28,7 +28,16 @@ def validate_email(self, value): class UserSerializer(serializers.ModelSerializer): - """Serializer for retrieving and updating user profile.""" + """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, @@ -39,7 +48,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ("id", "email", "password") + fields = ("id", "email", "first_name", "last_name", "password") read_only_fields = ("id",) def update(self, instance, validated_data): From b2566e02c7c4cc7f7925be60701187763c11f8b2 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:37:08 +0300 Subject: [PATCH 107/177] feat(users): add get_serializer_class to use different serializers per action --- users/views.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/users/views.py b/users/views.py index 24c0863..4b064b1 100644 --- a/users/views.py +++ b/users/views.py @@ -1,8 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import viewsets, mixins, permissions -from users.serializers import UserSerializer, RegisterSerializer - +from users.serializers import UserSerializer, RegisterSerializer, UserUpdateSerializer User = get_user_model() @@ -16,8 +15,13 @@ class ManageUserViewSet( viewsets.GenericViewSet, mixins.UpdateModelMixin, mixins.RetrieveModelMixin ): queryset = User.objects.all() - serializer_class = UserSerializer + 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 From ca6fb60ac512b8a8e7a83bd5426d456129cc5d9e Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:38:07 +0300 Subject: [PATCH 108/177] code style changes --- users/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/users/views.py b/users/views.py index 4b064b1..b696b20 100644 --- a/users/views.py +++ b/users/views.py @@ -1,7 +1,11 @@ from django.contrib.auth import get_user_model from rest_framework import viewsets, mixins, permissions -from users.serializers import UserSerializer, RegisterSerializer, UserUpdateSerializer +from users.serializers import ( + UserSerializer, + RegisterSerializer, + UserUpdateSerializer, +) User = get_user_model() From 2a297d16c94999e848577e879f315c464557bb42 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:39:23 +0300 Subject: [PATCH 109/177] feat(users): added action patch in router --- users/urls.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/users/urls.py b/users/urls.py index 4acdb11..89c38c1 100644 --- a/users/urls.py +++ b/users/urls.py @@ -19,7 +19,11 @@ 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"}) + "me/", + ManageUserViewSet.as_view( + {"get": "retrieve", "put": "update", "patch": "partial_update"} + ), + name="me", ), ] From 66be623034c7a0ff164db2d670be4b839ae49253 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 12:48:01 +0300 Subject: [PATCH 110/177] feat(users): custom basic first and last name validation --- users/serializers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/users/serializers.py b/users/serializers.py index 781cadc..c0124d1 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -66,3 +66,21 @@ def validate_email(self, value): "User with this email already exists." ) return value + + def validate_first_name(self, value): + 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): + 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 From e7ca783b9d2842a494622d8190f132d10722a113 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:12:30 +0300 Subject: [PATCH 111/177] feat(users): added a test to check the read-only profile view and replaced serializers in the tests --- users/tests/test_serializers.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py index 7b4a773..146f448 100644 --- a/users/tests/test_serializers.py +++ b/users/tests/test_serializers.py @@ -23,9 +23,9 @@ def test_register_user(self): self.assertTrue(user.check_password("StrongPass123")) def test_update_email(self): - from users.serializers import UserSerializer + from users.serializers import UserUpdateSerializer - serializer = UserSerializer( + serializer = UserUpdateSerializer( instance=self.user, data={"email": "new@example.com"}, partial=True ) self.assertTrue(serializer.is_valid(), serializer.errors) @@ -33,9 +33,9 @@ def test_update_email(self): self.assertEqual(updated_user.email, "new@example.com") def test_update_password(self): - from users.serializers import UserSerializer + from users.serializers import UserUpdateSerializer - serializer = UserSerializer( + serializer = UserUpdateSerializer( instance=self.user, data={"password": "NewStrongPass123"}, partial=True, @@ -46,13 +46,13 @@ def test_update_password(self): def test_email_uniqueness_validation(self): from django.contrib.auth import get_user_model - from users.serializers import UserSerializer + from users.serializers import UserUpdateSerializer User = get_user_model() - other_user = User.objects.create_user( + User.objects.create_user( email="taken@example.com", password="SomePass123" ) - serializer = UserSerializer( + serializer = UserUpdateSerializer( instance=self.user, data={"email": "taken@example.com"}, partial=True, @@ -70,3 +70,13 @@ def test_register_user_success_returns_user_without_password(self): 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) From 77a3c4e3081f0336725c03c029eb4fcc62292040 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:16:27 +0300 Subject: [PATCH 112/177] test(users): add check for partial profile name update --- users/tests/test_views.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/users/tests/test_views.py b/users/tests/test_views.py index 1765176..dcb09d2 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -63,3 +63,20 @@ def test_unauthorized_user_gets_401_when_updating_profile(self): 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") From 848614449443545d4a330195b4d337861603831c Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:20:22 +0300 Subject: [PATCH 113/177] test(users): add PUT unauthorized test for /users/me/ endpoint --- users/tests/test_views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/users/tests/test_views.py b/users/tests/test_views.py index dcb09d2..636ca29 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -80,3 +80,10 @@ def test_partial_update_first_name(self): 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) From 64f3e5e970f5f298ab62b72b00e1270e58814dcf Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 13:25:50 +0300 Subject: [PATCH 114/177] test(users): add validation test for updating email to an existing one --- users/tests/test_views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/users/tests/test_views.py b/users/tests/test_views.py index 636ca29..c3cbecd 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -87,3 +87,22 @@ def test_unauthorized_user_gets_401_when_putting_profile(self): 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) From 79e3852e8b174baded04378c8d25d696939250c2 Mon Sep 17 00:00:00 2001 From: viannik Date: Tue, 23 Sep 2025 11:34:55 +0200 Subject: [PATCH 115/177] added tests for book delete --- books/tests/test_views.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/books/tests/test_views.py b/books/tests/test_views.py index b6fbd47..b4a36af 100644 --- a/books/tests/test_views.py +++ b/books/tests/test_views.py @@ -93,6 +93,10 @@ def test_cannot_patch_update_book(self): 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): @@ -182,3 +186,8 @@ def test_staff_cannot_update_with_duplicate_title_author_cover(self): 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) From 5b7743588fbb40c45d99695ee7799578eb837cce Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Mon, 22 Sep 2025 20:07:12 +0300 Subject: [PATCH 116/177] fix: filter payments queryset by borrowing user for authenticated non-staff users # Conflicts: # payments/views.py --- payments/views.py | 74 ++--------------------------------------------- 1 file changed, 2 insertions(+), 72 deletions(-) diff --git a/payments/views.py b/payments/views.py index 69024a5..a237748 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,17 +1,9 @@ -import stripe -from django.conf import settings from drf_spectacular.utils import extend_schema_view, extend_schema -from requests import Response -from rest_framework import mixins, viewsets, status -from rest_framework.views import APIView - -from payments.models import Payment, PaymentStatus +from rest_framework import mixins, viewsets +from payments.models import Payment from payments.serializers import PaymentSerializer -stripe.api_key = settings.STRIPE_SECRET_KEY - - @extend_schema_view( list=extend_schema( summary="List payments", @@ -46,65 +38,3 @@ class PaymentViewSet( queryset = Payment.objects.all().order_by("-id") serializer_class = PaymentSerializer - - -class PaymentSuccessView(APIView): - """Handle successful Stripe payment""" - - def get(self, request): - session_id = request.GET.get("session_id") - - if not session_id: - return Response( - {"error": "Session ID is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - # Retrieve session from Stripe - session = stripe.checkout.Session.retrieve(session_id) - - # Find payment in database - payment = Payment.objects.get(session_id=session_id) - - # Check if payment was successful - if session.payment_status == "paid": - payment.status = PaymentStatus.PAID - payment.save() - - return Response( - { - "message": "Payment successful!", - "payment_id": payment.id, - "amount": str(payment.money_to_pay), - } - ) - else: - return Response( - {"error": "Payment was not completed"}, - 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 Stripe payment""" - - def get(self, request): - return Response( - { - "message": "Payment was cancelled." - "You can complete the payment later.", - "note": "The payment session is available for 24 hours.", - } - ) From 52b8e41e6f090f46465592dea09b1a0cbb10da18 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 12:29:04 +0300 Subject: [PATCH 117/177] fix: filter payments queryset by borrowing user for authenticated non-staff users --- ...payment_per_borrowing_type_tmp_and_more.py | 2 +- ..._payment_borrowing_alter_payment_status.py | 45 +++++++++++++++++ payments/models.py | 9 +++- payments/tests/test_endpoints.py | 44 ----------------- payments/tests/test_views.py | 49 ------------------- payments/views.py | 9 +++- 6 files changed, 61 insertions(+), 97 deletions(-) create mode 100644 payments/migrations/0003_alter_payment_borrowing_alter_payment_status.py 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 index 020aedf..0e5c324 100644 --- 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 @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-09-23 09:16 +# Generated by Django 5.2.6 on 2025-09-22 15:14 import django.db.models.deletion from django.db import migrations, models 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/models.py b/payments/models.py index 636b85b..3d8b7d9 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,10 +1,15 @@ 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" + EXCEEDED = "EXCEEDED", "EXCEEDED" + FAILED = "FAILED", "FAILED" class PaymentType(models.TextChoices): @@ -31,8 +36,8 @@ class Payment(models.Model): ) borrowing = models.ForeignKey( - "borrowings.Borrowing", - on_delete=models.PROTECT, + Borrowing, + on_delete=models.CASCADE, related_name="payments", db_index=True, null=True, diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index fcf6244..fa7c424 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -40,34 +40,6 @@ def _create_borrowing(self, *, user=None) -> Borrowing: expected_return_date=date.today() + timedelta(days=5), ) - def test_list_orders_by_id_desc(self): - b1 = self._create_borrowing() - b2 = self._create_borrowing() - - Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.PAYMENT, - borrowing=b1, - money_to_pay=Decimal("10.00"), - ) - Payment.objects.create( - status=PaymentStatus.PAID, - payment_type=PaymentType.FINE, - borrowing=b2, - money_to_pay=Decimal("5.00"), - ) - - resp = self.client.get(self.list_url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - items = ( - resp.data["results"] - if isinstance(resp.data, dict) and "results" in resp.data - else resp.data - ) - ids = [item["id"] for item in items] - self.assertGreaterEqual(len(ids), 2) - self.assertGreater(ids[0], ids[1]) - def test_create_ignores_read_only_fields(self): borrowing = self._create_borrowing() payload = { @@ -107,19 +79,3 @@ def test_create_negative_amount_returns_400(self): resp = self.client.post(self.list_url, data=payload, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("money_to_pay", resp.data) - - def test_retrieve_existing(self): - borrowing = self._create_borrowing() - p = Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.FINE, - borrowing=borrowing, - money_to_pay=Decimal("0.00"), - ) - url = reverse("payments-detail", args=[p.id]) - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(resp.data["id"], p.id) - self.assertEqual(resp.data["payment_type"], PaymentType.FINE) - self.assertEqual(resp.data["borrowing_id"], borrowing.id) - self.assertEqual(resp.data["money_to_pay"], "0.00") diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index 5fc9cfc..03155a0 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -38,39 +38,6 @@ def _create_borrowing(self, *, user=None) -> Borrowing: expected_return_date=date.today() + timedelta(days=7), ) - def test_list_payments(self): - b1 = self._create_borrowing() - b2 = self._create_borrowing() - - Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.PAYMENT, - borrowing=b1, - money_to_pay=Decimal("10.00"), - ) - Payment.objects.create( - status=PaymentStatus.PAID, - payment_type=PaymentType.FINE, - borrowing=b2, - money_to_pay=Decimal("5.00"), - ) - - url = reverse("payments-list") - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - data = ( - resp.data["results"] - if isinstance(resp.data, dict) and "results" in resp.data - else resp.data - ) - self.assertIsInstance(data, list) - self.assertGreaterEqual(len(data), 2) - first = data[0] - self.assertIn("id", first) - self.assertIn("status", first) - self.assertIn("payment_type", first) - self.assertIn("money_to_pay", first) - def test_create_payment_success(self): borrowing = self._create_borrowing() url = reverse("payments-list") @@ -98,19 +65,3 @@ def test_create_payment_invalid_status(self): resp = self.client.post(url, data=payload, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("status", resp.data) - - def test_retrieve_payment(self): - borrowing = self._create_borrowing() - p = Payment.objects.create( - status=PaymentStatus.PENDING, - payment_type=PaymentType.FINE, - borrowing=borrowing, - money_to_pay=Decimal("0.00"), - ) - url = reverse("payments-detail", args=[p.id]) - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(resp.data["id"], p.id) - self.assertEqual(resp.data["payment_type"], PaymentType.FINE) - self.assertEqual(resp.data["borrowing_id"], borrowing.id) - self.assertEqual(resp.data["money_to_pay"], "0.00") diff --git a/payments/views.py b/payments/views.py index a237748..69c35d0 100644 --- a/payments/views.py +++ b/payments/views.py @@ -36,5 +36,12 @@ class PaymentViewSet( - retrieve: GET /payments/{id}/ """ - queryset = Payment.objects.all().order_by("-id") + queryset = Payment.objects.select_related("borrowing").order_by("-id") serializer_class = PaymentSerializer + + def get_queryset(self): + qs = super().get_queryset() + user = self.request.user + if not user.is_authenticated or user.is_staff: + return qs + return qs.filter(borrowing__user=user) From b513d8f28127cd27b07316b20ff4fc9264cc9aed Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 12:37:44 +0300 Subject: [PATCH 118/177] fix: filter payments queryset by borrowing user for authenticated non-staff users --- payments/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index 3d8b7d9..10169ba 100644 --- a/payments/models.py +++ b/payments/models.py @@ -8,8 +8,9 @@ class PaymentStatus(models.TextChoices): PENDING = "PENDING", "PENDING" PAID = "PAID", "PAID" CANCELLED = "CANCELLED", "CANCELLED" - EXCEEDED = "EXCEEDED", "EXCEEDED" + EXPIRED = "EXPIRED", "EXPIRED" FAILED = "FAILED", "FAILED" + CANCELLED = "CANCELLED", "CANCELLED" class PaymentType(models.TextChoices): From f8b9024bfacbff5d7cfe4b814acf7ef799e680bc Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 12:41:32 +0300 Subject: [PATCH 119/177] fix: remove duplicate CANCELLED status in PaymentStatus choices --- payments/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index 10169ba..4891cd7 100644 --- a/payments/models.py +++ b/payments/models.py @@ -10,7 +10,6 @@ class PaymentStatus(models.TextChoices): CANCELLED = "CANCELLED", "CANCELLED" EXPIRED = "EXPIRED", "EXPIRED" FAILED = "FAILED", "FAILED" - CANCELLED = "CANCELLED", "CANCELLED" class PaymentType(models.TextChoices): From a91b0198f2d91e2f50142965c258881a8c2db9e1 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Tue, 23 Sep 2025 14:53:16 +0300 Subject: [PATCH 120/177] feat: implement borrowing functionality with serializers, views, and admin integration --- borrowings/admin.py | 14 ++++++- borrowings/models.py | 39 ++++++++++++++---- borrowings/serializers.py | 85 +++++++++++++++++++++++++++++++++++++++ borrowings/urls.py | 11 ++++- borrowings/views.py | 46 ++++++++++++++++++++- 5 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 borrowings/serializers.py diff --git a/borrowings/admin.py b/borrowings/admin.py index 55b5816..ba11e09 100644 --- a/borrowings/admin.py +++ b/borrowings/admin.py @@ -6,17 +6,29 @@ @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__username", "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/models.py b/borrowings/models.py index 5911473..4434be8 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -1,21 +1,49 @@ +from django.contrib.auth import get_user_model from django.db import models -from django.conf import settings + +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( - "books.Book", on_delete=models.CASCADE, related_name="borrowings" + Book, + on_delete=models.CASCADE, + related_name="borrowings", ) user = models.ForeignKey( - settings.AUTH_USER_MODEL, + 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: constraints = [ models.CheckConstraint( @@ -37,6 +65,3 @@ class Meta: name="unique_active_borrowing", ), ] - - def __str__(self): - return f"{self.user} borrowed {self.book} on {self.borrow_date}" diff --git a/borrowings/serializers.py b/borrowings/serializers.py new file mode 100644 index 0000000..9739b47 --- /dev/null +++ b/borrowings/serializers.py @@ -0,0 +1,85 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from books.serializers import BookSerializer +from borrowings.models import 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): + book = validated_data.pop("book") + book.inventory -= 1 + book.save() + borrowing = Borrowing.objects.create(book=book, **validated_data) + return borrowing + + def validate_book(self, value): + if value.inventory <= 0: + raise serializers.ValidationError("Not enough books") + return value + + def validate(self, attrs): + 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/urls.py b/borrowings/urls.py index 0960cbb..d47cf8d 100644 --- a/borrowings/urls.py +++ b/borrowings/urls.py @@ -1,6 +1,13 @@ -from django.urls import path +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) urlpatterns = [ - # Define your URL patterns here + path("", include(router.urls)), ] diff --git a/borrowings/views.py b/borrowings/views.py index f4787d4..cf21c95 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -1,4 +1,46 @@ -from django.shortcuts import render +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticated +from borrowings.models import Borrowing +from borrowings.serializers import ( + BorrowingCreateSerializer, + BorrowingDetailSerializer, + BorrowingListSerializer, + BorrowingSerializer, +) -# Create your views here. + +class BorrowingViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + Borrowing.objects.all() + .select_related("book", "user") + .order_by("-borrow_date") + ) + permission_classes = (IsAuthenticated,) + filter_backends = [DjangoFilterBackend] + filterset_fields = ["user", "book", "borrow_date", "actual_return_date"] + + def get_serializer_class(self): + 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): + serializer.save(user=self.request.user) + + def get_queryset(self): + queryset = super().get_queryset() + user = self.request.user + if not user.is_staff: + queryset = queryset.filter(user=user) + return queryset From ccff74545246fb1270284f2112814c8a3d54bacc Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Tue, 23 Sep 2025 14:53:40 +0300 Subject: [PATCH 121/177] test: add tests for borrowing serializers and views --- borrowings/tests/test_models.py | 6 ++- borrowings/tests/test_serializers.py | 55 +++++++++++++++++++++++++++ borrowings/tests/test_views.py | 57 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 borrowings/tests/test_serializers.py create mode 100644 borrowings/tests/test_views.py diff --git a/borrowings/tests/test_models.py b/borrowings/tests/test_models.py index 364ff93..5b86381 100644 --- a/borrowings/tests/test_models.py +++ b/borrowings/tests/test_models.py @@ -2,9 +2,11 @@ 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() @@ -37,7 +39,9 @@ def test_create_borrowing(self): self.assertIsNone(borrowing.actual_return_date) self.assertEqual( str(borrowing), - f"{self.user} borrowed {self.book} on {borrowing.borrow_date}", + f"From: {borrowing.borrow_date} " + f"till: {borrowing.expected_return_date} " + f"returned: {borrowing.actual_return_date}", ) def test_expected_return_date_constraint(self): 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..e459d59 --- /dev/null +++ b/borrowings/tests/test_views.py @@ -0,0 +1,57 @@ +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status +from django.contrib.auth import get_user_model + +from books.models import Book +from borrowings.models import Borrowing + + +User = get_user_model() + + +class BorrowingViewSetTest(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="testuser@example.com", password="12345" + ) + 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) + + def test_create_borrowing(self): + 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) From f6dc0c4ed65ee437c775c3dded0947b4733ef3ad Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 16:04:30 +0300 Subject: [PATCH 122/177] feat: add distinct serializers for Payment list and detail views --- payments/serializers.py | 22 ++++++++++++++++++++++ payments/views.py | 26 +++++++++++++------------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/payments/serializers.py b/payments/serializers.py index 237f8dc..67bab98 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -1,4 +1,6 @@ from rest_framework import serializers + +from borrowings.serializers import BorrowingSerializer from payments.models import Payment, PaymentStatus, PaymentType from borrowings.models import Borrowing @@ -40,3 +42,23 @@ def validate_money_to_pay(self, value): "money_to_pay must be non-negative" ) return value + + +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, + ) \ No newline at end of file diff --git a/payments/views.py b/payments/views.py index 69c35d0..7197366 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,7 +1,7 @@ from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import mixins, viewsets from payments.models import Payment -from payments.serializers import PaymentSerializer +from payments.serializers import PaymentSerializer, PaymentListSerializer, PaymentDetailSerializer @extend_schema_view( @@ -29,19 +29,19 @@ class PaymentViewSet( mixins.RetrieveModelMixin, viewsets.GenericViewSet, ): - """ - ViewSet for Payment: - - list: GET /payments/ - - create: POST /payments/ - - retrieve: GET /payments/{id}/ - """ - queryset = Payment.objects.select_related("borrowing").order_by("-id") - serializer_class = PaymentSerializer + queryset = Payment.objects.select_related("borrowing") + + def get_serializer_class(self): + if self.action == "list": + return PaymentListSerializer + if self.action == "retrieve": + return PaymentDetailSerializer + return PaymentSerializer def get_queryset(self): - qs = super().get_queryset() + queryset = Payment.objects.select_related("borrowing") user = self.request.user - if not user.is_authenticated or user.is_staff: - return qs - return qs.filter(borrowing__user=user) + if not user.is_staff: + queryset = queryset.filter(borrowing__user=user) + return queryset From d370e3adf05b1b47e4b67974ed48d8a7a76f680c Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 16:29:16 +0300 Subject: [PATCH 123/177] test: add unit tests for PaymentListSerializer --- payments/tests/test_serializers.py | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py index 1f412fe..187108a 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -1,13 +1,16 @@ from decimal import Decimal from django.test import TestCase from payments.models import Payment, PaymentStatus, PaymentType -from payments.serializers import PaymentSerializer +from payments.serializers import PaymentSerializer, PaymentDetailSerializer, 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( @@ -89,3 +92,42 @@ def test_missing_money_to_pay(self): 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) From c4da624638c3d5f203f14dbd8e648f26f72f55c4 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 16:33:25 +0300 Subject: [PATCH 124/177] feat: add Stripe environment variables to .env.sample --- .env.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 70c4fd6..d46acc5 100644 --- a/.env.sample +++ b/.env.sample @@ -12,4 +12,4 @@ POSTGRES_PORT= # Stripe settings STRIPE_SECRET_KEY= -STRIPE_PUBLISHABLE_KEY= +STRIPE_PUBLISHABLE_KEY= \ No newline at end of file From d262584c8c66a9b19faeb9fd2bf02ea8069630ce Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 16:37:42 +0300 Subject: [PATCH 125/177] addet migration --- .../migrations/0004_alter_payment_status.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 payments/migrations/0004_alter_payment_status.py 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, + ), + ), + ] From 266b78748d83755b0dd2a8848211dc5e137aecdc Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 16:50:09 +0300 Subject: [PATCH 126/177] refactor: comment out unused payment success and cancel URL paths --- payments/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index a7c1bfb..9914de4 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -9,6 +9,6 @@ router.register(r"payments", PaymentViewSet, basename="payments") urlpatterns = [ path("", include(router.urls)), - path("success/", views.PaymentSuccessView.as_view(), name="success"), - path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), + # path("success/", views.PaymentSuccessView.as_view(), name="success"), + # path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), ] From 5b28637d178bfd1b5fe762968548597eafb22bc4 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 17:03:46 +0300 Subject: [PATCH 127/177] refactor: reformat imports and improve code readability in payments module and tests --- payments/models.py | 5 ----- payments/serializers.py | 2 +- payments/tests/test_serializers.py | 23 ++++++++++++++++------- payments/views.py | 6 +++++- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/payments/models.py b/payments/models.py index 4891cd7..de565d6 100644 --- a/payments/models.py +++ b/payments/models.py @@ -51,11 +51,6 @@ class Payment(models.Model): money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) - """ - Constraints and indexes. - Can be used only after Borrowing model implementation. - """ - class Meta: db_table = "payment" constraints = [ diff --git a/payments/serializers.py b/payments/serializers.py index 67bab98..bff317e 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -61,4 +61,4 @@ class Meta: class PaymentDetailSerializer(PaymentSerializer): borrowing = BorrowingSerializer( read_only=True, - ) \ No newline at end of file + ) diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py index 187108a..b1b1ece 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -1,7 +1,11 @@ from decimal import Decimal from django.test import TestCase from payments.models import Payment, PaymentStatus, PaymentType -from payments.serializers import PaymentSerializer, PaymentDetailSerializer, PaymentListSerializer +from payments.serializers import ( + PaymentSerializer, + PaymentDetailSerializer, + PaymentListSerializer, +) from borrowings.models import Borrowing from books.models import Book from django.contrib.auth import get_user_model @@ -97,32 +101,37 @@ def test_missing_money_to_pay(self): class PaymentListSerializerTest(TestCase): def setUp(self): self.user = User.objects.create_user( - email="testlist@example.com", - password="testpass123" + 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") + daily_fee=Decimal("2.50"), ) self.borrowing = Borrowing.objects.create( expected_return_date=date.today() + timedelta(days=7), book=self.book, - user=self.user + user=self.user, ) self.payment = Payment.objects.create( status=PaymentStatus.PENDING, payment_type=PaymentType.PAYMENT, borrowing=self.borrowing, - money_to_pay=Decimal("35.00") + 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"} + 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): diff --git a/payments/views.py b/payments/views.py index 7197366..300791b 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,7 +1,11 @@ from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import mixins, viewsets from payments.models import Payment -from payments.serializers import PaymentSerializer, PaymentListSerializer, PaymentDetailSerializer +from payments.serializers import ( + PaymentSerializer, + PaymentListSerializer, + PaymentDetailSerializer, +) @extend_schema_view( From eac6ec5b2c62baebe995a5235722a80ab208cd6c Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 17:06:14 +0300 Subject: [PATCH 128/177] feat: add PaymentSuccessView and PaymentCancelView to handle Stripe callbacks --- payments/views.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/payments/views.py b/payments/views.py index 300791b..3b3934e 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,5 +1,10 @@ +import stripe +from django.conf import settings from drf_spectacular.utils import extend_schema_view, extend_schema -from rest_framework import mixins, viewsets +from requests 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, @@ -49,3 +54,56 @@ def get_queryset(self): if not user.is_staff: queryset = queryset.filter(borrowing__user=user) return queryset + + +class PaymentSuccessView(APIView): + """Handle successful payment callback from Stripe""" + + def get(self, request): + 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) + + payment = Payment.objects.get(session_id=session_id) + + if session.payment_status == 'paid': + payment.status = 'PAID' + payment.save() + + return Response({ + "message": "Payment successful!", + "payment_id": payment.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): + return Response({ + "message": "Payment was cancelled. You can complete the payment later, but the session is available for only 24 hours." + }) + From 4f531b5fb32084a4bf0c13ef0942b3a58f2647d3 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 17:06:26 +0300 Subject: [PATCH 129/177] feat: enable PaymentSuccessView and PaymentCancelView URL paths --- payments/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 9914de4..a7c1bfb 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -9,6 +9,6 @@ router.register(r"payments", PaymentViewSet, basename="payments") urlpatterns = [ path("", include(router.urls)), - # path("success/", views.PaymentSuccessView.as_view(), name="success"), - # path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), + path("success/", views.PaymentSuccessView.as_view(), name="success"), + path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), ] From f388333e5a3267eaa39f879085ee949b4a4ea5f1 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 17:42:00 +0300 Subject: [PATCH 130/177] refactor: switch to DefaultRouter, enhance readability, and update response formatting in payments module --- payments/urls.py | 8 +++++--- payments/views.py | 41 ++++++++++++++++++++++++----------------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index a7c1bfb..4838cba 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,12 +1,14 @@ from django.urls import path, include -from rest_framework.routers import SimpleRouter +from rest_framework.routers import DefaultRouter from payments.views import PaymentViewSet from payments import views -router = SimpleRouter() -router.register(r"payments", PaymentViewSet, basename="payments") +app_name = "payments" + +router = DefaultRouter() +router.register("", PaymentViewSet) urlpatterns = [ path("", include(router.urls)), path("success/", views.PaymentSuccessView.as_view(), name="success"), diff --git a/payments/views.py b/payments/views.py index 3b3934e..3e6620a 100644 --- a/payments/views.py +++ b/payments/views.py @@ -60,12 +60,12 @@ class PaymentSuccessView(APIView): """Handle successful payment callback from Stripe""" def get(self, request): - session_id = request.GET.get('session_id') + session_id = request.GET.get("session_id") if not session_id: return Response( {"error": "Session ID not provided"}, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) try: @@ -74,28 +74,31 @@ def get(self, request): payment = Payment.objects.get(session_id=session_id) - if session.payment_status == 'paid': - payment.status = 'PAID' + if session.payment_status == "paid": + payment.status = "PAID" payment.save() - return Response({ - "message": "Payment successful!", - "payment_id": payment.id - }) + return Response( + { + "message": "Payment successful!", + "payment_id": payment.id, + } + ) else: - return Response({ - "message": "Payment not completed yet" - }, status=status.HTTP_400_BAD_REQUEST) + 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 + 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 + status=status.HTTP_400_BAD_REQUEST, ) @@ -103,7 +106,11 @@ class PaymentCancelView(APIView): """Handle cancelled payment from Stripe""" def get(self, request): - return Response({ - "message": "Payment was cancelled. You can complete the payment later, but the session is available for only 24 hours." - }) - + return Response( + { + "message": "Payment was cancelled." + " You can complete the payment later," + " but the session is available" + " for only 24 hours." + } + ) From 5c9cf8968f1c5b1743e65fbbc6f18ec2e250ec6a Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 17:42:05 +0300 Subject: [PATCH 131/177] refactor: switch to DefaultRouter, enhance readability, and update response formatting in payments module --- payments/tests/test_endpoints.py | 2 +- payments/tests/test_views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index fa7c424..d20ccdd 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -15,7 +15,7 @@ class PaymentEndpointsTests(TestCase): def setUp(self): self.client = APIClient() - self.list_url = reverse("payments-list") + self.list_url = reverse("payments:payment-list") self.user = get_user_model().objects.create_user( email="user2@example.com", password="testpass123" ) diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index 03155a0..0554032 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -40,7 +40,7 @@ def _create_borrowing(self, *, user=None) -> Borrowing: def test_create_payment_success(self): borrowing = self._create_borrowing() - url = reverse("payments-list") + url = reverse("payments:payment-list") payload = { "payment_type": PaymentType.PAYMENT, "borrowing_id": borrowing.id, @@ -55,7 +55,7 @@ def test_create_payment_success(self): def test_create_payment_invalid_status(self): borrowing = self._create_borrowing() - url = reverse("payments-list") + url = reverse("payments:payment-list") payload = { "status": "INVALID", "payment_type": PaymentType.PAYMENT, From 2ffc6b9dde79e3f155e41afe8bbbf7471db2ae98 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 18:20:26 +0300 Subject: [PATCH 132/177] fix: correct indentation in payment cancellation response message --- payments/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/payments/views.py b/payments/views.py index 3e6620a..2f71fdc 100644 --- a/payments/views.py +++ b/payments/views.py @@ -109,8 +109,8 @@ def get(self, request): return Response( { "message": "Payment was cancelled." - " You can complete the payment later," - " but the session is available" - " for only 24 hours." + " You can complete the payment later," + " but the session is available" + " for only 24 hours." } ) From 7d23755dcc112a288a71ed392c9d33a2927bc589 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 18:45:39 +0300 Subject: [PATCH 133/177] feat: add Stripe environment variables to CI workflow and fix incorrect Response import path --- .github/workflows/ci.yml | 2 ++ payments/views.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa8e0a8..ef48b3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: 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 }} services: postgres: image: postgres:16 diff --git a/payments/views.py b/payments/views.py index 2f71fdc..e33dee1 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,7 +1,7 @@ import stripe from django.conf import settings from drf_spectacular.utils import extend_schema_view, extend_schema -from requests import Response +from rest_framework.response import Response from rest_framework import mixins, viewsets, status from rest_framework.views import APIView From 21392a88dd00c3c593fbe6220e3b5bc0a68c269c Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 20:01:07 +0300 Subject: [PATCH 134/177] fix: added call create_stripe_session on creating new borrowing --- borrowings/views.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/borrowings/views.py b/borrowings/views.py index cf21c95..d62f907 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -9,6 +9,8 @@ BorrowingListSerializer, BorrowingSerializer, ) +from payments.models import Payment, PaymentType +from payments.stripe_helper import create_stripe_session class BorrowingViewSet( @@ -36,7 +38,23 @@ def get_serializer_class(self): return BorrowingSerializer def perform_create(self, serializer): - serializer.save(user=self.request.user) + 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): queryset = super().get_queryset() From 364305b114f8c0b4e64abf13d85a259a2523b2a8 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 20:35:23 +0300 Subject: [PATCH 135/177] refactor: standardize string quotes and formatting in borrowing payment creation --- borrowings/views.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/borrowings/views.py b/borrowings/views.py index d62f907..596e3bc 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -43,17 +43,16 @@ def perform_create(self, serializer): session_data = create_stripe_session( borrowing=borrowing, payment_type=PaymentType.PAYMENT, - request=self.request + 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'], + session_id=session_data["session_id"], + session_url=session_data["session_url"], + money_to_pay=session_data["amount"], payment_type=PaymentType.PAYMENT, - status='PENDING' - + status="PENDING", ) def get_queryset(self): From 97c3566039c815b1f17e51e9879ffac82bdbc4b0 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 21:54:00 +0300 Subject: [PATCH 136/177] feat: add `IsAuthenticated` permission to PaymentsView for access control --- payments/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/payments/views.py b/payments/views.py index e33dee1..79e39dc 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,6 +1,7 @@ import stripe from django.conf import settings from drf_spectacular.utils import extend_schema_view, extend_schema +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import mixins, viewsets, status from rest_framework.views import APIView @@ -40,6 +41,7 @@ class PaymentViewSet( ): queryset = Payment.objects.select_related("borrowing") + permission_classes = (IsAuthenticated,) def get_serializer_class(self): if self.action == "list": From b03ac940863f5a7ae98ddfd1a236c1578c8e93d6 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 22:12:48 +0300 Subject: [PATCH 137/177] test(users): swagger docs for users views.py --- users/views.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/users/views.py b/users/views.py index b696b20..75328c5 100644 --- a/users/views.py +++ b/users/views.py @@ -1,4 +1,5 @@ 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 ( @@ -10,11 +11,36 @@ 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 ): From b68e1b1d3e666da43a6b7fe977a44e2307ba8bc0 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Tue, 23 Sep 2025 22:20:06 +0300 Subject: [PATCH 138/177] feat: add `IsAuthenticated` permission to PaymentsView for access control --- payments/tests/test_endpoints.py | 3 +++ payments/tests/test_views.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index d20ccdd..5eb22f0 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -41,6 +41,7 @@ def _create_borrowing(self, *, user=None) -> Borrowing: ) def test_create_ignores_read_only_fields(self): + self.client.force_authenticate(user=self.user) borrowing = self._create_borrowing() payload = { "payment_type": PaymentType.PAYMENT, @@ -59,6 +60,7 @@ def test_create_ignores_read_only_fields(self): 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", @@ -70,6 +72,7 @@ def test_create_invalid_payment_type_returns_400(self): self.assertIn("payment_type", resp.data) def test_create_negative_amount_returns_400(self): + self.client.force_authenticate(user=self.user) borrowing = self._create_borrowing() payload = { "payment_type": PaymentType.PAYMENT, diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index 0554032..e5208d3 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -39,6 +39,7 @@ def _create_borrowing(self, *, user=None) -> Borrowing: ) def test_create_payment_success(self): + self.client.force_authenticate(user=self.user) borrowing = self._create_borrowing() url = reverse("payments:payment-list") payload = { @@ -54,6 +55,7 @@ def test_create_payment_success(self): 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 = { From 6c6854f0c70b638decc85fb37f968991e071142e Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 22:28:09 +0300 Subject: [PATCH 139/177] docs(borrowings): swagger docs for borrowings views.py --- borrowings/views.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/borrowings/views.py b/borrowings/views.py index 596e3bc..641b176 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -1,4 +1,5 @@ from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import mixins, viewsets from rest_framework.permissions import IsAuthenticated @@ -12,7 +13,23 @@ from payments.models import Payment, PaymentType from payments.stripe_helper import create_stripe_session - +@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"], + ), +) class BorrowingViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, From f3d9453b5d9c9221e19a15904a65b85648816fb6 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Tue, 23 Sep 2025 22:33:55 +0300 Subject: [PATCH 140/177] code style fix --- borrowings/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/borrowings/views.py b/borrowings/views.py index 641b176..aae1557 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -13,10 +13,12 @@ from payments.models import Payment, PaymentType from payments.stripe_helper import create_stripe_session + @extend_schema_view( list=extend_schema( summary="List Borrowings", - description="Staff see all borrowings, users see only their own. Supports filters.", + description="Staff see all borrowings," + " users see only their own. Supports filters.", tags=["Borrowings"], ), retrieve=extend_schema( @@ -26,7 +28,8 @@ ), create=extend_schema( summary="Create Borrowing", - description="Create a borrowing. A Stripe payment session is generated.", + description="Create a borrowing." + " A Stripe payment session is generated.", tags=["Borrowings"], ), ) From 9f13489904457b36e8b744c41193a88c4e9262d8 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Tue, 23 Sep 2025 22:57:23 +0300 Subject: [PATCH 141/177] fix: set custom database table names for Book, Borrowing, and User models --- books/migrations/0003_alter_book_table.py | 17 +++++++++++++++++ books/models.py | 1 + .../migrations/0002_alter_borrowing_table.py | 17 +++++++++++++++++ borrowings/models.py | 1 + users/migrations/0003_alter_user_table.py | 17 +++++++++++++++++ users/models.py | 1 + 6 files changed, 54 insertions(+) create mode 100644 books/migrations/0003_alter_book_table.py create mode 100644 borrowings/migrations/0002_alter_borrowing_table.py create mode 100644 users/migrations/0003_alter_user_table.py 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/models.py b/books/models.py index 97d036d..551fb67 100644 --- a/books/models.py +++ b/books/models.py @@ -37,3 +37,4 @@ class Meta: verbose_name = "Book" verbose_name_plural = "Books" unique_together = ("title", "author", "cover") + db_table = "books" 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/models.py b/borrowings/models.py index 4434be8..708beb7 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -45,6 +45,7 @@ def validate_actual_return_date( ) class Meta: + db_table = "borrowings" constraints = [ models.CheckConstraint( check=models.Q( 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/models.py b/users/models.py index 3d1ecd1..6b3a616 100644 --- a/users/models.py +++ b/users/models.py @@ -50,6 +50,7 @@ class Meta: verbose_name = _("user") verbose_name_plural = _("users") ordering = ["-date_joined"] + db_table = "users" def __str__(self): return f"Email: {self.email}" From 8b4adbc7aa90b8451184d1f93e8b460c1a6474b7 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Tue, 23 Sep 2025 23:24:52 +0300 Subject: [PATCH 142/177] feat: add fixture data for books and users --- fixtures.json | 1167 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1167 insertions(+) create mode 100644 fixtures.json 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 + } + } +] From cf5896905baf83ee0bfaae1c15068341b82e0c69 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Wed, 24 Sep 2025 00:40:32 +0300 Subject: [PATCH 143/177] refactor: replace `borrowing_id` with `borrowing` in PaymentSerializer and tests for consistency --- payments/serializers.py | 5 ++--- payments/tests/test_endpoints.py | 6 +++--- payments/tests/test_serializers.py | 3 +-- payments/tests/test_views.py | 6 +++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/payments/serializers.py b/payments/serializers.py index bff317e..568c7e0 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -6,8 +6,7 @@ class PaymentSerializer(serializers.ModelSerializer): - borrowing_id = serializers.PrimaryKeyRelatedField( - source="borrowing", + borrowing = serializers.PrimaryKeyRelatedField( queryset=Borrowing.objects.all(), ) @@ -17,7 +16,7 @@ class Meta: "id", "status", "payment_type", - "borrowing_id", + "borrowing", "session_url", "session_id", "money_to_pay", diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index 5eb22f0..c5d3251 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -45,7 +45,7 @@ def test_create_ignores_read_only_fields(self): borrowing = self._create_borrowing() payload = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": borrowing.id, + "borrowing": borrowing.id, "money_to_pay": "12.34", "session_url": "https://malicious.example/override", "session_id": "fake-session", @@ -64,7 +64,7 @@ def test_create_invalid_payment_type_returns_400(self): borrowing = self._create_borrowing() payload = { "payment_type": "INVALID", - "borrowing_id": borrowing.id, + "borrowing": borrowing.id, "money_to_pay": "10.00", } resp = self.client.post(self.list_url, data=payload, format="json") @@ -76,7 +76,7 @@ def test_create_negative_amount_returns_400(self): borrowing = self._create_borrowing() payload = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": borrowing.id, + "borrowing": borrowing.id, "money_to_pay": "-0.01", } resp = self.client.post(self.list_url, data=payload, format="json") diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py index b1b1ece..ad918a1 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -3,7 +3,6 @@ from payments.models import Payment, PaymentStatus, PaymentType from payments.serializers import ( PaymentSerializer, - PaymentDetailSerializer, PaymentListSerializer, ) from borrowings.models import Borrowing @@ -41,7 +40,7 @@ def test_create_payment_valid(self): borrowing = self._create_borrowing() data = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": borrowing.id, + "borrowing": borrowing.id, "money_to_pay": "15.50", } serializer = PaymentSerializer(data=data) diff --git a/payments/tests/test_views.py b/payments/tests/test_views.py index e5208d3..a05fec2 100644 --- a/payments/tests/test_views.py +++ b/payments/tests/test_views.py @@ -44,13 +44,13 @@ def test_create_payment_success(self): url = reverse("payments:payment-list") payload = { "payment_type": PaymentType.PAYMENT, - "borrowing_id": borrowing.id, + "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_id"], borrowing.id) + self.assertEqual(resp.data["borrowing"], borrowing.id) self.assertEqual(resp.data["money_to_pay"], "12.34") self.assertEqual(resp.data["status"], PaymentStatus.PENDING) @@ -61,7 +61,7 @@ def test_create_payment_invalid_status(self): payload = { "status": "INVALID", "payment_type": PaymentType.PAYMENT, - "borrowing_id": borrowing.id, + "borrowing": borrowing.id, "money_to_pay": "12.34", } resp = self.client.post(url, data=payload, format="json") From 8c6949208fca0213fcd2f223e514d6c9606abb30 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 00:48:08 +0300 Subject: [PATCH 144/177] feat: update environment sample and requirements --- .env.sample | 34 +++++++++++++++++++--------------- requirements.txt | 4 +++- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.env.sample b/.env.sample index d46acc5..362262c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,15 +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= \ No newline at end of file +# 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/requirements.txt b/requirements.txt index 2d4644e..bf71deb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ django==5.2.6 djangorestframework==3.16.1 -django-filter==24.3 +django-filter==25.1 drf-spectacular==0.28.0 python-dotenv==1.1.1 psycopg2-binary==2.9.10 @@ -11,3 +11,5 @@ 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 From ed986ab7f25722242b5011e580a88cf5882d408e Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 01:04:10 +0300 Subject: [PATCH 145/177] feat: add Telegram messaging for notifications --- notifications/telegram_helper.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 notifications/telegram_helper.py diff --git a/notifications/telegram_helper.py b/notifications/telegram_helper.py new file mode 100644 index 0000000..04829fb --- /dev/null +++ b/notifications/telegram_helper.py @@ -0,0 +1,23 @@ +import os + +import requests +from dotenv import load_dotenv + + +load_dotenv() + +TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] +TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"] + + +def send_telegram_message(text): + 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 From bf0c9f08e3f525bd1ec3694dedab861aa6448ae9 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 01:46:05 +0300 Subject: [PATCH 146/177] feat: add redis --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index bf71deb..9353b11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ djangorestframework_simplejwt==5.5.1 stripe==12.5.1 requests==2.32.5 celery==5.5.3 +redis==6.4.0 From 51281d555497d8258ea3801e6d5645aaceb32d2d Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 01:46:22 +0300 Subject: [PATCH 147/177] refactor: telegram_helper --- notifications/telegram_helper.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/notifications/telegram_helper.py b/notifications/telegram_helper.py index 04829fb..8d78bba 100644 --- a/notifications/telegram_helper.py +++ b/notifications/telegram_helper.py @@ -14,10 +14,6 @@ def send_telegram_message(text): 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" - } + data = {"chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "HTML"} response = requests.post(url, data=data) return response.ok From 58375fa04d19576774c7b1b8897c865575df3c11 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 01:46:42 +0300 Subject: [PATCH 148/177] feat: integrate Celery with Redis --- core/__init__.py | 4 ++++ core/celery.py | 10 ++++++++++ core/settings/base.py | 11 +++++++++++ docker-compose.yml | 23 +++++++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 core/celery.py diff --git a/core/__init__.py b/core/__init__.py index e69de29..085ba3b 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -0,0 +1,4 @@ +from core.celery import app as celery_app + + +__all__ = ("celery_app",) 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/settings/base.py b/core/settings/base.py index be02c3f..497bfd7 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -3,6 +3,8 @@ import os from dotenv import load_dotenv +from celery.schedules import crontab + load_dotenv() @@ -129,3 +131,12 @@ STRIPE_PUBLISHABLE_KEY = os.environ["STRIPE_PUBLISHABLE_KEY"] STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"] + +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/docker-compose.yml b/docker-compose.yml index adde659..be01d6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,29 @@ services: depends_on: - db + redis: + image: redis:7 + ports: + - "6379:6379" + + 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: From c67ebbb6c767612ca6d0e3b5f323537b4cc33dac Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 02:04:51 +0300 Subject: [PATCH 149/177] feat: add notification for new borrowings and overdue checks --- borrowings/serializers.py | 2 ++ notifications/tasks.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 notifications/tasks.py diff --git a/borrowings/serializers.py b/borrowings/serializers.py index 9739b47..9cfe109 100644 --- a/borrowings/serializers.py +++ b/borrowings/serializers.py @@ -4,6 +4,7 @@ from books.serializers import BookSerializer from borrowings.models import Borrowing +from notifications.tasks import notify_new_borrowing from users.serializers import UserSerializer @@ -69,6 +70,7 @@ def create(self, validated_data): book.inventory -= 1 book.save() borrowing = Borrowing.objects.create(book=book, **validated_data) + notify_new_borrowing.delay(borrowing.id) return borrowing def validate_book(self, value): diff --git a/notifications/tasks.py b/notifications/tasks.py new file mode 100644 index 0000000..ac38360 --- /dev/null +++ b/notifications/tasks.py @@ -0,0 +1,44 @@ +from celery import shared_task +from django.utils import timezone + +from notifications.telegram_helper import send_telegram_message +from borrowings.models import Borrowing + + +@shared_task +def notify_new_borrowing(borrowing_id): + try: + borrowing = Borrowing.objects.select_related("book", "user").get( + id=borrowing_id + ) + message = ( + f"New borrowing created!\n" + f"{borrowing.user}\n" + f"Book: {borrowing.book.title}\n" + f"Borrowed: {borrowing.borrow_date}\n" + f"Expected return: {borrowing.expected_return_date}" + ) + send_telegram_message(message) + except Borrowing.DoesNotExist: + pass + + +@shared_task +def check_overdue_borrowings(): + 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 b in overdues: + msg = ( + f"Overdue borrowing!\n" + f"{b.user}\n" + f"Book: {b.book.title}\n" + f"Borrowed: {b.borrow_date}\n" + f"Expected return: {b.expected_return_date}\n" + f"Status: Not returned!" + ) + send_telegram_message(msg) + else: + send_telegram_message("✅ No borrowings overdue today!") From dc1e5cb6944cfa75e5182c0a5ac529e2c76c2a3a Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 02:36:17 +0300 Subject: [PATCH 150/177] test: add tests for Telegram messaging --- notifications/tests.py | 4 -- notifications/tests/__init__.py | 0 notifications/tests/test_tasks.py | 53 +++++++++++++++++++++ notifications/tests/test_telegram_helper.py | 17 +++++++ 4 files changed, 70 insertions(+), 4 deletions(-) delete mode 100644 notifications/tests.py create mode 100644 notifications/tests/__init__.py create mode 100644 notifications/tests/test_tasks.py create mode 100644 notifications/tests/test_telegram_helper.py diff --git a/notifications/tests.py b/notifications/tests.py deleted file mode 100644 index 8d9dff5..0000000 --- a/notifications/tests.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.test import TestCase - - -# Create your tests here. 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..deb32a7 --- /dev/null +++ b/notifications/tests/test_tasks.py @@ -0,0 +1,53 @@ +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, + ) + overdue_borrowing = Borrowing.objects.create( + user=user, + book=book, + expected_return_date=date.today() - timedelta(days=1), + ) + 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() From cbcc83f9c6afbf0dd82a717892d2a122d5f3add3 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 02:44:42 +0300 Subject: [PATCH 151/177] fix: add Telegram bot configuration to CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef48b3b..0fd70da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: 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 From 852e6f7545e6f973cb09464fb9385260fceac57d Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 02:50:46 +0300 Subject: [PATCH 152/177] feat: add Redis service to CI and update test --- .github/workflows/ci.yml | 6 ++++++ notifications/tests/test_tasks.py | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fd70da..1f48365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: - 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 diff --git a/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py index deb32a7..8ba72d3 100644 --- a/notifications/tests/test_tasks.py +++ b/notifications/tests/test_tasks.py @@ -45,6 +45,7 @@ def test_check_overdue_borrowings_sends_message(): overdue_borrowing = Borrowing.objects.create( user=user, book=book, + borrow_date=date.today() - timedelta(days=2), expected_return_date=date.today() - timedelta(days=1), ) with patch("notifications.tasks.send_telegram_message") as mock_send: From 3d367b7dd67b4d3d581a153c508791909aae8bcf Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 03:10:16 +0300 Subject: [PATCH 153/177] test: fix --- borrowings/tests/test_views.py | 5 ++++- notifications/tests/test_tasks.py | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/borrowings/tests/test_views.py b/borrowings/tests/test_views.py index e459d59..010054e 100644 --- a/borrowings/tests/test_views.py +++ b/borrowings/tests/test_views.py @@ -2,6 +2,7 @@ from rest_framework.test import APITestCase 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 @@ -49,9 +50,11 @@ def test_retrieve_borrowing(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["id"], self.borrowing.id) - def test_create_borrowing(self): + @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/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py index 8ba72d3..3abb039 100644 --- a/notifications/tests/test_tasks.py +++ b/notifications/tests/test_tasks.py @@ -42,11 +42,14 @@ def test_check_overdue_borrowings_sends_message(): inventory=3, daily_fee=2.00, ) + borrow_date = date.today() - timedelta(days=5) + expected_return_date = date.today() - timedelta(days=1) + overdue_borrowing = Borrowing.objects.create( user=user, book=book, - borrow_date=date.today() - timedelta(days=2), - expected_return_date=date.today() - timedelta(days=1), + 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 From d2c639e98ea9783389732acb97d115715975f13b Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 03:23:11 +0300 Subject: [PATCH 154/177] fix: test --- notifications/tests/test_tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py index 3abb039..668f555 100644 --- a/notifications/tests/test_tasks.py +++ b/notifications/tests/test_tasks.py @@ -42,8 +42,8 @@ def test_check_overdue_borrowings_sends_message(): inventory=3, daily_fee=2.00, ) - borrow_date = date.today() - timedelta(days=5) - expected_return_date = date.today() - timedelta(days=1) + borrow_date = date.today() + expected_return_date = date.today() + timedelta(days=5) overdue_borrowing = Borrowing.objects.create( user=user, From 7d1f68dac3ef57f666338a0e30e96200836f2a7b Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 03:59:55 +0300 Subject: [PATCH 155/177] feat: add return book action to BorrowingViewSet --- borrowings/views.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/borrowings/views.py b/borrowings/views.py index aae1557..e1f7d94 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -1,7 +1,11 @@ from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema_view, extend_schema -from rest_framework import mixins, viewsets +from rest_framework import mixins, viewsets, status +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.utils import timezone +from django.db import transaction from borrowings.models import Borrowing from borrowings.serializers import ( @@ -32,6 +36,11 @@ " 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, @@ -81,3 +90,33 @@ def get_queryset(self): 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, pk=None): + 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, + ) + + with transaction.atomic(): + borrowing.actual_return_date = timezone.now().date() + borrowing.save() + borrowing.book.inventory += 1 + borrowing.book.save() + + serializer = self.get_serializer(borrowing) + return Response(serializer.data, status=status.HTTP_200_OK) From b9b8641268affa889ded258b4120f1b2097247f4 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 11:12:45 +0300 Subject: [PATCH 156/177] feat: implement kostil Stripe webhook --- payments/stripe_helper.py | 6 +-- payments/urls.py | 12 ++++- payments/views.py | 101 +++++++++++++++++++++++++++++++++----- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py index 0754c70..5016eb0 100644 --- a/payments/stripe_helper.py +++ b/payments/stripe_helper.py @@ -2,6 +2,7 @@ from django.conf import settings from django.urls import reverse from decimal import Decimal + from payments.models import PaymentType stripe.api_key = settings.STRIPE_SECRET_KEY @@ -51,9 +52,8 @@ def create_stripe_session( } ], mode="payment", - success_url=request.build_absolute_uri( - reverse("payments:success") - ), + success_url=request.build_absolute_uri(reverse("payments:success")) + + "?session_id={CHECKOUT_SESSION_ID}", cancel_url=request.build_absolute_uri(reverse("payments:cancel")), ) diff --git a/payments/urls.py b/payments/urls.py index 4838cba..369c775 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,7 +1,11 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from payments.views import PaymentViewSet +from payments.views import ( + PaymentViewSet, + StripeWebhookView, + PaymentTestSuccessView, +) from payments import views @@ -10,7 +14,11 @@ router = DefaultRouter() router.register("", PaymentViewSet) urlpatterns = [ - path("", include(router.urls)), 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 index 79e39dc..344ee51 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,5 +1,8 @@ import stripe from django.conf import settings +from django.http import HttpResponse +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.response import Response @@ -61,6 +64,32 @@ def get_queryset(self): class PaymentSuccessView(APIView): """Handle successful payment callback from Stripe""" + def _update_payment_status(self, session_id, is_test=False): + """Common logic for updating payment status""" + try: + payment = Payment.objects.get(session_id=session_id) + payment.status = "PAID" + payment.save() + + 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, + ) + def get(self, request): session_id = request.GET.get("session_id") @@ -74,18 +103,8 @@ def get(self, request): stripe.api_key = settings.STRIPE_SECRET_KEY session = stripe.checkout.Session.retrieve(session_id) - payment = Payment.objects.get(session_id=session_id) - if session.payment_status == "paid": - payment.status = "PAID" - payment.save() - - return Response( - { - "message": "Payment successful!", - "payment_id": payment.id, - } - ) + return self._update_payment_status(session_id) else: return Response( {"message": "Payment not completed yet"}, @@ -116,3 +135,63 @@ def get(self, request): " for only 24 hours." } ) + + +class PaymentTestSuccessView(APIView): + """Test endpoint to simulate successful payment - FOR DEVELOPMENT ONLY""" + + permission_classes = (IsAuthenticated,) + + def post(self, request): + session_id = request.data.get("session_id") + + if not session_id: + return Response( + {"error": "session_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Reuse the common logic from PaymentSuccessView + 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): + 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: + payment = Payment.objects.get(session_id=session_id) + if session["payment_status"] == "paid": + payment.status = "PAID" + payment.save() + except Payment.DoesNotExist: + pass + + return HttpResponse(status=200) From 45de27f449473ae678966a485fe226f8ac20e785 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 11:17:09 +0300 Subject: [PATCH 157/177] refactor: remove comment --- payments/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/payments/views.py b/payments/views.py index 344ee51..5156a46 100644 --- a/payments/views.py +++ b/payments/views.py @@ -151,7 +151,6 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) - # Reuse the common logic from PaymentSuccessView success_view = PaymentSuccessView() return success_view._update_payment_status(session_id, is_test=True) From d0596366a44f15a080eefcd18d691551ad519413 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Wed, 24 Sep 2025 11:49:18 +0300 Subject: [PATCH 158/177] feat: enhance Telegram notifications --- notifications/tasks.py | 58 ++++++++++++++++++++++++++++++++---------- payments/views.py | 51 ++++++++++++++++++++++--------------- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/notifications/tasks.py b/notifications/tasks.py index ac38360..dfd7bbb 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -12,33 +12,63 @@ def notify_new_borrowing(borrowing_id): id=borrowing_id ) message = ( - f"New borrowing created!\n" - f"{borrowing.user}\n" - f"Book: {borrowing.book.title}\n" - f"Borrowed: {borrowing.borrow_date}\n" - f"Expected return: {borrowing.expected_return_date}" + 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): + try: + from payments.models import Payment + + 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(): 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 b in overdues: - msg = ( - f"Overdue borrowing!\n" - f"{b.user}\n" - f"Book: {b.book.title}\n" - f"Borrowed: {b.borrow_date}\n" - f"Expected return: {b.expected_return_date}\n" - f"Status: Not returned!" + 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(msg) + send_telegram_message(message) else: send_telegram_message("✅ No borrowings overdue today!") diff --git a/payments/views.py b/payments/views.py index 5156a46..ffa196d 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,5 +1,6 @@ import stripe from django.conf import settings +from django.db import transaction from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator @@ -15,6 +16,7 @@ PaymentListSerializer, PaymentDetailSerializer, ) +from notifications.tasks import notify_successful_payment @extend_schema_view( @@ -67,23 +69,28 @@ class PaymentSuccessView(APIView): def _update_payment_status(self, session_id, is_test=False): """Common logic for updating payment status""" try: - payment = Payment.objects.get(session_id=session_id) - payment.status = "PAID" - payment.save() - - message = ( - "Payment status updated to PAID (TEST MODE)" - if is_test - else "Payment successful!" - ) + with transaction.atomic(): + payment = Payment.objects.select_for_update().get( + session_id=session_id + ) + payment.status = "PAID" + payment.save() - return Response( - { - "message": message, - "payment_id": payment.id, - "status": payment.status, - } - ) + 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"}, @@ -186,10 +193,14 @@ def post(self, request): session_id = session["id"] try: - payment = Payment.objects.get(session_id=session_id) - if session["payment_status"] == "paid": - payment.status = "PAID" - payment.save() + with transaction.atomic(): + payment = Payment.objects.select_for_update().get( + session_id=session_id + ) + if session["payment_status"] == "paid": + payment.status = "PAID" + payment.save() + notify_successful_payment.delay(payment.id) except Payment.DoesNotExist: pass From a13ee9b798e32525553b61579171bc22de21dc23 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Wed, 24 Sep 2025 11:57:57 +0300 Subject: [PATCH 159/177] refactor: adjust string formatting and improve readability in borrowing and payment logic --- borrowings/views.py | 69 +++++++++++++++++++++++++++++++++++++++ payments/stripe_helper.py | 25 +++++++++++--- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/borrowings/views.py b/borrowings/views.py index e1f7d94..d08aa78 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import mixins, viewsets, status @@ -18,6 +20,9 @@ from payments.stripe_helper import create_stripe_session +FINE_MULTIPLIER = 2 + + @extend_schema_view( list=extend_schema( summary="List Borrowings", @@ -112,11 +117,75 @@ def return_book(self, request, pk=None): 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, actual_return_date): + 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/payments/stripe_helper.py b/payments/stripe_helper.py index 5016eb0..722a5a6 100644 --- a/payments/stripe_helper.py +++ b/payments/stripe_helper.py @@ -9,7 +9,7 @@ def create_stripe_session( - borrowing, payment_type=PaymentType.PAYMENT, request=None + borrowing, payment_type=PaymentType.PAYMENT, request=None, fine_amount=None ): """ Create a Stripe checkout session for a borrowing. @@ -18,9 +18,10 @@ def create_stripe_session( 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 + dict: Contains session_id and session_url and amount """ if payment_type == PaymentType.PAYMENT: borrow_days = ( @@ -30,8 +31,24 @@ def create_stripe_session( 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: - description = "Fine for overdue book" + 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) @@ -43,7 +60,7 @@ def create_stripe_session( "price_data": { "currency": "usd", "product_data": { - "name": f"{payment_type}: {borrowing.book.title}", + "name": product_name, "description": description, }, "unit_amount": amount_in_cents, From ff2f46f787dab5b3c4a4e4ba2e67347d20a4e445 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Wed, 24 Sep 2025 12:14:05 +0300 Subject: [PATCH 160/177] refactor: fix indentation and adjust string formatting in borrowing and payment modules --- borrowings/views.py | 12 ++++++------ payments/stripe_helper.py | 12 +++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/borrowings/views.py b/borrowings/views.py index d08aa78..a6d4e97 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -150,9 +150,9 @@ def return_book(self, request, pk=None): { "borrowing": serializer.data, "message": "Book returned successfully, " - " but you have a fine to pay", + " but you have a fine to pay", "days_overdue": ( - today - borrowing.expected_return_date + today - borrowing.expected_return_date ).days, "fine_amount": str(fine_amount), "payment_url": session_data["session_url"], @@ -166,10 +166,10 @@ def return_book(self, request, pk=None): { "borrowing": serializer.data, "message": "Book returned successfully," - " but error creating fine payment", + " but error creating fine payment", "error": str(e), "days_overdue": ( - today - borrowing.expected_return_date + today - borrowing.expected_return_date ).days, "fine_amount": str(fine_amount), }, @@ -181,11 +181,11 @@ def return_book(self, request, pk=None): def calculate_fine(self, borrowing, actual_return_date): days_overdue = ( - actual_return_date - borrowing.expected_return_date + actual_return_date - borrowing.expected_return_date ).days daily_fee = borrowing.book.daily_fee fine_amount = ( - Decimal(days_overdue) * daily_fee * Decimal(FINE_MULTIPLIER) + Decimal(days_overdue) * daily_fee * Decimal(FINE_MULTIPLIER) ) return fine_amount diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py index 722a5a6..5238a40 100644 --- a/payments/stripe_helper.py +++ b/payments/stripe_helper.py @@ -32,21 +32,23 @@ def create_stripe_session( 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") - + 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}") From ad7f06df8d3102764aef4d2000ff0ce5cf2f8fc9 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Wed, 24 Sep 2025 13:40:12 +0300 Subject: [PATCH 161/177] add README.md --- README.md | 186 ++++++++++++++++++++++++++++- screenshots/Books swagger.jpg | Bin 0 -> 65038 bytes screenshots/Borrowings swagger.jpg | Bin 0 -> 110656 bytes screenshots/DB diagram.jpg | Bin 0 -> 64878 bytes screenshots/User swagger.jpg | Bin 0 -> 68977 bytes screenshots/bot.png | Bin 0 -> 22020 bytes screenshots/paid.jpg | Bin 0 -> 20293 bytes screenshots/pay.png | Bin 0 -> 51715 bytes 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 screenshots/Books swagger.jpg create mode 100644 screenshots/Borrowings swagger.jpg create mode 100644 screenshots/DB diagram.jpg create mode 100644 screenshots/User swagger.jpg create mode 100644 screenshots/bot.png create mode 100644 screenshots/paid.jpg create mode 100644 screenshots/pay.png diff --git a/README.md b/README.md index 91dbea5..8569e0c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,187 @@ -# drf-library-api +# 📚 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/screenshots/Books swagger.jpg b/screenshots/Books swagger.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3b94eb89c3790ed26ccd7dd17c6ae957f58bab52 GIT binary patch literal 65038 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 PqqafnCE!iw<@Emm2iWpq literal 0 HcmV?d00001 diff --git a/screenshots/Borrowings swagger.jpg b/screenshots/Borrowings swagger.jpg new file mode 100644 index 0000000000000000000000000000000000000000..73341cc9fe0c3c4ae316b31bc5b865f8a6edcb7e GIT binary patch literal 110656 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⋙Y-FSEb=%!l7V`TEE-sjPXF04rCEhx}rHc&e5p`BBgzqIMaC9|3hO%-1_ zb$E^M+mVves&^>WQy1%zIV*qRT zke!*5M?x}gl1z~b^&diR6_T~_a^1neSip<|Sy|srpx0TqiV|h39P`Qw(Tb+4bP^1^ z-=1HP$y;iC%;yq&>l5WkZo6=)En(mBo(j}oN0z6#79%b$+X}x;Q!#Hvv~c@ogH7q& zo#4LIe&^lg?MLCX*mE-iDbmq_+m82?DBH*%Z#-Ofctj%;Ny7Fq5gr20ME^#f$c+Aa zHlFw{Ux#9L5T)|xdo<= z#JN#Y6Ro+s&=D#Qe4{WWg4yd^{)Zn=ji9Uzgpe03%~u(J1CMWG&yDp5Xnm?48BDwT zPZC_c!68^2-(nfaEh|dncmG^08u)cWjR;noscSz>xIodERt^~}} zP$gWLNR8CVj5db52O&@>l4U-Fo6= zs(dp|;aL29(g)W!S}QunvwvQLX9)YyKh@TN@2&h=1%`EOVZ2&>dCn0&&VJ3g>Plg2 zEYG9bWYa=gMyb$GvvUQbppnKhZ-@Pj-X|}0PrLh6TC)g>6()FT)3GW~nE2SHH9TT+#yAViQBB!%(?WWxOB=~u4G%!ed}xcOI|OO z#DxknABCo=tDjt7;VYn~hm1kuG-Wkfi@}d-3u7thKGR$Ete4}WOhBQgC;J^0MavN?RD;OQ>?nB+(6WmhH^888EwF;!Ll>2GUM z(frv5qXTnW_rfUqK6&!kOW#=*7D-=F=osxH7}FIo5=OYnOV{^w1%Eot__ew&Vdu*Ce(V5 z&Gg*-D3~N|8>pc=Uo6NzO{_p%T+S*@;kP*fnmACjuoaK$y9O^ET%{NQ(8O+aW zajxDjAvj50P~5k^-^&;jiQGe7u%w16mcWPip*QEIKdTGqX@&w$oSJivI5UKtw^hjr zH|5#lmGHMgpb+U}w~yAO317S1g{yY~P-*QcD%BOquT(5qRgHjnqhw&#_&ThGQZU58 z0T3B{seuj}lG4j{oZRGVUk?u`oUEYYDFtPy2SzMBmK%~WJqsV=zrjYKevnQpx33)i z&dyO{Elh3@G6}UV_O$}zPD_etfD(ixATnKq1?EX1O=%Qf`MTOPWKpkdPD4t^{>ZOW^a8Kj{*&F(!Xuf~%dRm^e_p0sKYq8IZ-akG-19nHrn% z{V+U4{Gynh6`6D1M~_VG^E4ZrQau6vorE#~Mray%U#cofei< z@sN0PK4rjFy8qPv>DY50t-Dx?;)wptc7shE^4-z&x*p)U_H+0zxh1}8f`2veq2zT; zRn5Vkh*(#$I=NAeyBsp$>$e!vRn*0QyQmO`qJy?}DhI>v>!TmMcvvh@*1p!I5WJS; zJK}Pw7NDY5h$GBU8Xue-Kt9A)h({iE>K)Wx)XGx@$rd&z1BKU# z*Q%-Cl4a5D!&nRKPkx!tRA(fVwz0Ke8RUP&9}!1%%FZnxyIWi_b#YK&apq`O8DC# zJj)Vjt5?)b68=4SUI}ZOdyuV)cFI+*5`Nfc^r|xbM75w@Mhp>P69ztkI%I7K+&8lW${6*H8;5@pakI(0g91 z1y};~E25s2uM6m{!?W?L3eqkBl^d7e*D&xU8`M+?YbUj!i+u0RgB5`A?W+(8>LK{s$ahq6488V&xfI$>7f?0I)_)nKMcb2IkxO=mn+)+rzlXs z#-mHx9WC<4OlX3;P3|}*axgm#M&$~k%$L0@C$j>UfbCWsMaaju$T;?sxCGoKzY_in z5I8y$F^BRqPwv8PiWX@b{u!~Zz9Fq7HRiG77W*h&PIVc7MGG@Z0myx~jP|+Hiss9Q z$~dvlNH?+iyfUx#fDcR8;akafrhM$2#Ma{QEoE;8;xlVLmzi0a9Kd)qTz$D#4zrqN~+AOb?zZaS3Ywc)2Zi{2D9kar){NX%~3Io4T?&4luT*W&ikZc9xA!T`4fNc!C!^kdZbz&%f*EF4 zlJ@4AC*k-rU-n}?W9E7S(vk|Jp>^yPUQc0;(rjv!!0Jamz9A&u^N|-ouht!gs_W11 zrV%$juG25luc-nz7*%f;SMH4>9LY^9rld|dq8k?p5N$e2g`P#>Vr|FoQA1@)J~bYj zByl|;w}>I~0UdYhOEqH4iVwN?y$J29IX7!=c&eG9#JiY+RMh!GyKem`c4~&88gdgJ zt^IhToiawJRzZAHemS*0#b%XhuU%hPDEOLi%snz%w2dY``(ixQ1D}sDDBPH!$PGxwY9Te!aZI3Pa5Ms4Guf3_-bKQ*Cs-8 z_BST1K5DM+i3u9}#_AQ5jI%d+m~pHuaXlk}F^)o(aBKFMU4H;Xw9^&A!EY45lh+X2_n2m7Sz1d%LTx~-5DZTPow1)faT0UO+ zAO-z4VHuu;V%xY+&r&`fin&FSuO_3=Yw3Mn0+yBwVC7qFytZ)s$Vc|w!$k0tq8p-iD)IjX2Go5$>E*fU%n;L7F{{+jrXyi; zL}vX9V67-Ywj*7+C?P%i|MdK{LOVC!?Pzx(&TPd^9)CYMq9aMM)vSwV zj(s88iO5;D2D2u6{JDO=-Ic->4nI8}C@Ij)%u#6EJz&7K6g$j zE4kA(XKYII)69+T6=2WDl%!jG_T!7r~|&}ml)bJ7IX6lbIg(U7IhQYp38o%#IT9ftofegeQxW7YdSR* z4>sF+U^NX?`-U>)RymIfx>%)&oadxeL&~|!aBmTop>tQJrjK<`M$x>`*O~C|I^Ws0 zf3V}b(|}W|)L18wro%^?l}_QNZ;+eM^nj;bw$5U|TZx%*bBU;MB8yGZ9QOD@61UyQ zx$d>EKA8&7Z3s3y`-b!NwHH{rD9hinHs4sM@ReRgT}S-`U~3}18etTEIXpChS$0aQ zyXdZ}#FDx!9juykGgw8-&O42Lu0C=+jQ$in2T_fpc{J~* z_}OD;?)0p&`mDSRYvj2Q@qNXWh2<5?#FM)rgX(kj!xC@cl5OpmS-+P3~r0co1*}u&pONi~-Pz{0k;-(bD)0tJ<`D(M< ziLvfUV{`C&jS6dwju+_(cirz9G-!WU9^+mhCwi+UUBM{`x17DSiTrW?c$hSyAur)n z7VMa6c}1Tcq6$7;$IHU&^J$~7f43zgwN(W!ZffTj;G)a$RHS_Ne8Ri&*e8uz(|1N( z5H;+PN2O(9Gv+YTcGb*ytPQ6A;j_5$&LdB_Yksor6lQZ^`uQGOnlYQ6MqWPOxq5hBU7Xsgo>Jnc7GR);Zc2mqEH;!33E&xXH zrLzZTFHeL^d@HQ;sHRq? zCOCNBI=T5arGlTAlQNfTG722|rkdcIpLTXuYX;X{gtIx{3_m#>OF8@yk!&9V9us~G1 zkCbR3Rwxe~Ne_C5Ef8=EKr9@eJ+Kb-#Y)*a7F1`pu{5Q~ety zvQtL-(e)o|Xnp*hluj__Qup!O_}3Vs+&^n!)T_&^APN&RT!c1D%8M<;6YX0gm#!wFgREmab=lxo!|n{Jkc~s%)I2CwY8=< z`+WE8%Zz!I-ldfo5iET5uGx*R87wAYt74~Brt84Ho}wnsJdtW(gsj<%e>`(}VG?F&heI&$W;(R28J7|} zO^47(=6vem>^@hW;%3I5!QX+zi%cQ3QKCPgn`?Ze(9N!a8`{b1WoQJo9fTqAfOciN z#!!07&hA|R(vunQ5gG+s+}!qOQH1er>{}KnVJjq7_$E*@^5pQ!p-Fc4?%k{=kTR+w zAw!jrGVwMCI*NWk})EU?0cM+?oL- zcqYZu3gWL59avfIqoP@X)OqdgZj}{X$+u%w)yju+#U!Z~*PF@i>W?0^H+3C>4l7cm zaLbKTc1BoZrca8nhWZ&7sz+|U?s;$XCCsBKeMl$fSjJ?mK4S98j%L81h>>-lmIX9E zGD!u8x#|F`%oi`AAf3+aEoJ&JW^%R^bKVS%1&bH$%3J=}5R0Rn#N&_}=~c7mjkg80 zRw`(kzg7$8ne0_cz#`L#LhCFzuHBn_ZZ{%M_VM)59aUXSIL*4-a+Z{XNi)w!MeAZ#YiSXR=Gw;GwkgjKFmyE zA|NUs0%1E@H|c-&sG3`uds&WMg#^DTPro}6t%uKRtcJoJ-;}0fp}el4L+4d0X63!W zsc3GYk8nCV4X2*@Rp(=U+&nPS-d9YgeFR<%ioW3!c5WL^bBI$}NW$BVLcb5if|xQH zgtnG%%eV1A!Ees-aTol%>BMz4F|XG55de=QQhr&oZ-|IFZ??-O_H5+xc6Z-Cg@miys(9}g-#CB zxbfqI)5hfVnW;%`F1NY0ybn6_Zq9ompN2Z7I!+f5AwxBJe!nnN{eLl2ZDMXA<&LPg z*HFhxK0H2Pc=f=ieT|UR=_=z2kN;=G0d!B+Oel-nPJb$%rQ&}O{rnf7uJ60`Y=ECs ze%sv$kr{HRc1yk^T^z5q5gl`nw0Kj+BjrAIq^_`jWy@=(?w znio{Jh}lz3QyaQUUZL^F!JsZ7idpnw@N^=iq~3ogc;^KFOc6i6|np{hE%-uJ7|R%K+%Ur!`IGyQ;C+$%zX zsJ#ooZf<6#`+R>Fd8kx1A+sF({*?l0%n`?19#YF_vZS6{4Gburu}xuGq+2rZ|ANW2 z{Yc@gy{G$6*UoFs`${_qPKXDb*!DQH0$+ltg0G72rCf6Wy2LSdr?m%*hn_y?;dSDENPb>nYwPPQ7XTrsy7~)Lm@QRUZ9t?Td54nS zalf{vx%+4`<`Il{dO72U+hW@cGIfOLj)lT+JA;@a(^ zSU2}&4-^mCu3Im$i#Myh3;lkmg*M@@2{SVv~cM~X}yl29%l5c*F=!$W%P*I!WE z9hHyTC3XdU$qrASSY9rqXzKJ_{Z*)jW|n7HXEzWPW>b0ubC+R-S2sHXhn#!)%_ zhzF<2Dw~@X@?<1}ON|%d@l>70qnF0vLquY(!P3if2C77DZD$*k@USoU^<5v;8bvLsI4ZF-^pI`(X*Ldk9rk(fbU2Mn|zr4yWzAJWw-F z5@Ghbo1}?`0a%@-;sFa<8J~6-dZ(`xu5kFr$HS}BcIhac>v>&gDO2U^|FdGV-^sCk zg#`Ki?w0=3)XyRz@#lCYINOW;O(1@blVguU)Cs; z&%6-wl}yHff~(~JS7^*|K5IJSsf%CenE@kj?aU96G{SE&7od7ud`LnEO9R_fP+c-& z?+-~;Y-A1&RJ9#9V+3Z%(f8Fuo9(9a=3O*isk`P2#+aWr7v$dJMPHBLAxz-sw%Oy@ zo@*y}jHwlwWsA_S8*iGZ$ulvJQ`YQR75%I{Zr`)fuqTcrwcb^0j^u9d#oCZHwe$+x z^XNHYZTi2K_7T6wTu~SMjmB8ci}#CKIK)BXluvh-Bly2B^*2?wTJcxLTP&`m=;gz? zr#1QT=KYtd++^!4@4D8&n2~af4@}mL3D(@S5?9Y27NnFtmSJlb`iSdju{;-4>*2h3 z=%n^+Xx`3<4DJ^XsxlqIsFa<}-k$%wl;GDCXwY!mm97Jwmo=e*3mU%+U9=bF`Gjdk{+65D==Q>Sv$!#D z@U;}gM@j@dHQOsU-`G8zGASKyAxv~8Fq-p82`Re(I!o5X!4?(={78lO%8JD?Gx5_C zB{;aX)KkSfHp(vm1_suyyTM!S_wqZUohS%ajLqlH!>DiYKDivz z=r01oO`XdLEhI&AmjPjW&po_Vc_q0m0qp8$H+{pI?M~}fgKi|={Hg^0$UKb}{0%@~HPx2M7t z%rvH#UTP>FMJDo`&1fD@Ll_3Q@d^bD1cd0L?kkq}He=Us z>!c6q!lS~*`yAXX?bVBTrM@e#jsr27bogfA8xqhuJ0*wD7xu|#`cqBjk>lr_y>M_{ z0vj8#Bg&)T_i}_J5j}LoO-(&Mv*DHR>p1m|)tPz4JM3R5j77kUcBaKf^uO5q4ydNq zZQW256r>0U2-2l02#83Hbd)9?5$Q!isY;bZ=^bg(3DSEJ5F%itcaUDBNbj8l2uXNL z_dfgVbI*O_jr+zp_uP99VhR3_I$cklBkDZ^*-I4`SMg^sV+KUCeCjHj8;#$-C?kyPhF^-n=p z?xGp}x^_+4ZZtW(8O71Fv|7taxU2~;^n8OL+iGB>1(IYb(;qoEKVpveolik1t5)|4 zC~1n{v^6;B?DfB2qhrfrJ;R&v> zxX|*eOO6jeGk=p^fJC60tuC2`C(QClIR=SJd&>>1`x#jz`_B}xQ=D9Ni47vDE^qlB z#j8!((`G9fIfR)`FGJ$M9oxDK=IozE%rV}N_E*ew-D5c=sehzw(?@ysn$A&BN2p8r z_r;exe!sO7I9IMLVznd%y7jVMUJ~o0So0*5QYzFRQscs7>Bj!_0`|+um5oD|^4n3` z#jZ*g%fq&XFVShQu_ezxq}eXsB>04N7*Jni?dkiDsq4^5GkiBZXQJz;@g2{z)qQaj zMXq56A%W=+%`W#=4ySY?#{0CrR+?B|vI+;<4*mgbbDhf2NO; z&%|W@5H8hKGU;F6FDrnFBKk4?*F1TlWVteHN6Yu|0V%$~j%o1{W-j-&n%AlG1+#l@ z5ruy{m36OItvmR8j3-|k$$eJk7j$Jvde@k}O*h;eUQ%Vzlqu%7xS{t$%xEVY(e1(e z=K7QyOz@!4?E20+ZBl2_^Z^p83%>i8?Y$#reA6vLWSnk6?I;lC+B1Q zn-F#T?UlR{^8meAP?(=*@^s_o(9FtOrG`0+V}$+{7d-wE+D&U{wJ~>y1v0vZgd`DX zzBq9TntoVnHRt9&{j^m*0l{AS-Im%Pj545p8mi^%-g5b@u+oSu^;1tg5N{bosb9+iZ$!DHT|MowsGJx6*%Xy5U?)z6=p*HJAM0S29`r z?Li7>8e3w{{O1{S*I0Ux2}*l7{iQVwCv9$+c@52kM&1zeMZ1w031w;4OZRP3_pA5H z*BY~HBKKl}N+DU#?(PY3xPr(-gHC`n%6WsE&xy`(k|YNr{40i zi;azaOjz5PW&)y8)uak`0z0Osy_`OyRH87V#UNo2BtDY@UF*zd;?nSccoF*Wi%}+2 zZ!+QJ0z7l}TmKy8+q$Ux{nSRa3S+hB;&Q4Q+&N<6Xld7iv#XA&v*2vYQ*m1i#{Q-YJKBE4Me%j*{*&5rYc<6g z(bY0YunH0?B)&UUYkr^ANxBSGE8(JdWTeOapx<9ZPpHAZkVI5I>$y>|;}?boi&6q# z?b)Rzp=4B|*STbz-%x-GO-kAYRk{eSI}2DcT>tV)J}Hs8#$z!>QgWWX#i7@=CMOAo zhJ(I-<+ZhfBsiuP0vWBEgBB`@S`wG#38R^Q5;eVME5@In;<;9rKMcttd!o+TH(`9} zwzLh@qkO%8jG}ybB9SUXrazG$$hY}WE)JE)+IgZwvFKeu?EGbzP|U>Ehq1Kk?0_>n z{A2v#9vkL_@N<1?56Y4o`?`cfZlarQd1K2+&mjDJvY>(MtJc15@p+r2YfcsWWHsZ* zfkw|UO(l0AW}^(?TkBzo^uVRF3^%G*Y<=B!#QbddH#7n3{k7|6m-$D{uLw9mefE%9V2=u2PWp88d$Ry;2;T*@w<>@}XQ4wO(^|Sm_Aw?2e#3 z2#paAf@7EMTNgSt3Cg(&gJBq(j;L|K;x1* zS>tv`$LIR)n(9$j-Ve8(mCYAg7v_HSP6wwh6pA&Nv(sK+d&eBlb{SP&AKN?lr0kZS z-?`pLstE4L>KNA;+@-(LuYXIR>}qHm*DKGjTxjHB5wur0yadaSAHG@kBqgzE3Y~ut zb3!uS1rxdPFmTw0(mJ_F3}JBrywBwZRYLS1=Iwyo=P=o>t|x$Y%~#sX^A+QP5nYtB z8{18d$>3>oE_-#AQ;=qxy4_%lErauY4ps)F^XiEY+w`)HehJ+1d^HCuKz6;dm0#B{ zxYi`*P$ z?z+SJ=aRYu?8%M0=qgAZ2HQ!Zs}3TV*p2HjjqVYKyWk>7Oo`OZA^XU172_-dfsyUR z1BS?_EsPmt#2!vee0)cEJ1k+af$dDQ>WP`Hda&7~hTO5? z+F04JQniyG@%Y-q&@jYL(1X=Z31jw}#K17yT9X0u*;L3c~=w{Y*_Z8^zh$?&1YVGdZg ziN}*^;{aDJYmyq86L9t8qa>qK#WHbPrX1N1%cfQs0p2;rY0@ z0hUazUkm$@n3@Wg;ARh?u$N&p|B64e(u4CVB;Ak-=f1GF%YRLehoQ`ef_B^b1YE>i z$!s}h<%e#Vf>&FF0%@%AL7gor?X%~Z4gSGCpjoaWh)k|vb5#XtSxC=gYTZk6)pYxa}&(Y z^O6tu+wP7NLZykV;2=^1|L?mtC(dCnZhW9{IR#O+WBj0nr=Zjqa+eat4UFX|TxlWl zYQtMrEjT!y-i6HQM})!Q9W4#1^;8laS`7{6RJ0e&-!Yf@W3k2CR>J6$Pv`OOC01cY^KgajJ18{0yl5vBPFq+r|iUsT{Kh*)P0$886*< z;Du%Oj06#d2z2V(ID9d|5RBCXGNm;$dKz?!E@AHSgkqPtTo)DsR~dD35R!Ux)f^x1 zQ6lvD(@Yz0Zn?hlTu1bysyRk{03mc>XTXpBYv61zSHP*~9|Bj^sSqU1f!X5>PE*_FZ!&W&MWiJly zUziiKYsHnL)ywu46jy47stUdeVI6`J50ZajDgIwris#8sEHy!S3cAOnnws?NgKH$r zA_lAtP&P967S`YP;n9d*=24cPR^mK~I;h=u@G6)RQ78~$4>>Pd^z{@3@K^33A%`fi z*6by^K9gVt0hWbb`#Z=Kor)#QRr z#GPf9F#D#l*Qw)#fz*tW+rF>7Am#R8t$qX=DHuJQ#YuDbQS*lJ$j8gzQt6rH)y1I) z^bdy0cdvN(a55K>b{l6I>&KztqMTP&?c*A-R@_B9`B>!&T1|P4_22~9gh}!Y?$ri5 z8Cn;Q={DJNkS`yZ-yy&M7Qubj5Bvdq3fl3`$RRONejcb&b7GcFzM_99y2L+l0Qs3* z{>9Ewe`fxUAS;tdy1^Pld#M`0UP99V_WJIwq0n@5R*IJNZ>NeXqJ&+zgB~FYYd!f| zh!f#4iln59PqHwzPlhg0^ojMc3)dp=bM4bs{Qs@stmcmV>Qi!k<;UO*Vn9`*LjNN zKI+poA8{Ers7gWV-L#R?J)1bQ$3MUwSe3s$XykZO7erTIe+sfe_MyTd28glgBq~PI zUhMPfMcGFdYffv-Ui{Yt4;bD$FK%#dT2U(9g5ILF1Ni8{tpMt4AOZgg^$DUXYQQS! z8+Zl}CayW|JzppkM24SmlGbcG3T;RHdo@5;SLApoC3)9x}Jozl^}Ku)() zP&IhpyT-JNONFF^#kZ_>kIlZ+g9e!nZRbv z3Q6*WmW$Oc-nhIB$hwVfZ1<=TvLJ0JR^tn#9mX0pFw-z-=CC^ti~Bzb?l7Si$zv&bDl;R&h{#;iZ^byheH{n zs|?PY6g#oU#70PEGP(%gW%pr{YBAdCc0JcEvxus+?>QzIYp=;uJovy=!q8n@zK=RM z;D)?wkn)OBXvv}Duq(xTB@xRH8F=VK5%5?qiwMzEbf2MFGAO9_i5g*w){%l@&(_@E#u;u{7J~Nc{;cW8Z5&#Y)F}K-Omn?cwIm5 zN+l=UVq2p>CuP~V+eR39^XXa3ByxcBb+mJ!YMmF4Yacn{KKk_NrHzGB zc919lt;`3cU#HGYkn&O`ujv==tRzMruaCx$C_@j|7EJoJ(bWx@Bt4w#l`4 z^!aTD{j%7CwHqX#{5W(XIU%7Y#%p|-d#!P438)HfhwZvS>`y_~#7n_bsf{ugw2PY# z7C9(h86B>;#dmTrE>VmJ*d`-=ymOsYH+~Ax5{AWB;;k zO>>&Lnm%1j_x*yo0kN39E9j0BlX?w1v9(+P&|(h9r)^ea`gT#yDJ z;IjkH?C@Xqhb;D|z}l~X-QOiOpQ<_vOCT$};x1T4=C|e7Z`8cZ{bqlzE50nbDprHB z0rM4G3~-9M=+c_!32$I?Sy3d;Cr`q1>C@R*EtuLIA@K+5T-Fr?Jq)gaCY2u8q!GAZAdg9eA!&%Zs*% zl*X=A%DL0wXY$hF8Q4PfKbA7K;M3U+MS%70oTpE}Wv?(m*f-R-Wj}LI*IXl7Gp$NuC!CVD8lC0`inYL}R7iEJ^Nn56Y7% zX?MQmTtDX-EPJdl??%U)I0cPqE;ZXj&of zQW#ZMcJJHX=X@unkOKhFn4bWYcEJ6vA5E^kwh~8GTT0}Bt_;@iShs8^QELFn>v;*Tss1 z0H#u)#V*exveeSHoqS|VesYo~O-xec=oD1IGv2APD3_aD$6-8x%9oCbC4c?pvunM> z?wwGqRI0b+u(3#U$)wn9;phYHv|BbdSCaETBR=Hu>%dzt<-Z&Y{*lBjgZLDaRo^t0 zX&OM>dbgpLPzEjxO{)dJ3Eb{XJO&p3b5EL9lEe|pe1R(ECoBl^6@x`Ui}h#H5UTuw z=sw+vc`hxc9_om**%bJzT%XR;dmfPZgIg{eR~#A~|G-{<%@zLblz2OmN>nJ!?=iX- z%@V;X?81p>YV!vq%Mr`^1<5Mw^WniH!J_r4S`kHL_V%ntF*yG1o|%mX#(s&48s!`2 zThk*b@OK~4lylA3oooazZ(8$@*qnl-b_(>rGACTh!*`{Hx-f{NMH_7mm=}{SIyS$+ z5}$)@;C|L3#+bgRS|@T{nmm>OgV%Z^a)c)%a^D=X=l_suUvnpS7EdEMn>e$_Kgk^| z-dn7TS}#l<3k!?7P5kp$a(|`|PeHb_=hO5rvqBWwe=X;R^)pmUM^|u3KUojGzhpJ+ z^h~n*bIDDv#CYA7tbL)in#wfmC;C@ny1M{q<&pTxNKZ)!qB`>^MpvX%kh8$f*j!C# zq^>zBzt|&KMB5MmV>wj-LQ}H?uvpS40MO13J2S}tR1cB8q6}#`Q2{jZt{trvc68i8@;z=`+6o9q#Nu_ z?8;;3ZxhPjuo&AS2iB&WH%3f7=IviM6~iOJpPo}H@>@HMu}P1*X?eDDp1<3Z_&v|0 z9E(^^6E=>dsSz7CK+}9V$~cI+=^?{mAUigCfA1bpUU-r69#e5a%?d! zazcM9yQPNb>&zd;3CV%FxMs|L^5j=H7cIsbjorukZ@9WGWfxOOJ^IRMWgb3tLilG8 z=L3K^&fh>>G4>aTOZS#HX_8GxOkZ|Gi2<#R?we-cT%E7JmYMWmo(k#g=w|=k!R2GU z`}dVo(8xmcH&uJr`loO`d{&61YRje-G_uzLSZ&^<&L-DErbLbiLh!vip;?zCcDjte z@CmB!#BR*I`XPFf0f4xcFYjW1fjD~r#CiV&aV&s<4(Tx~T9b$(pmMX*VB!7wW%h$O zO{m#bvb(yrUxlhlH~|pH`U}MIs+8@P@NV0C@HFm^g)O_T;!$FJ+jUK{G(_zAw2bo? z6E?=)T&TAIF|%7AhUB;LDG1OM%~AzlO3xgau2`FU;s;yRZow!+>QHEacUA>4@ft#& zXk)pMKQSB%z;Iw)9Bh7dsnna(;}jI6)-|k?S{zF-b~(ly<>2%It)?~jDt2e|S&>0h za(PY?_zN$C?zWSe_2)a+J#0d<1cvz%Px_7@mbKzHvQ33GZ2%-7$y1QP4h!~JTLQ7` z39XY3&dfguD0_)L1zFO5z>+W`$UC28JlA?{<+Pf1KAldfrw`5zv*QKR7=3wyL7LaM9`*WrZ&|M&eATl#Y z4ysL*95nPD1olTsa^3Nc-;BTEGIKi=tSHT_<1K!Rdt{nVtvK_|@9}TIE>Zk?5c+E> zyjohluLqHtZ)+j7W|+|D`94Q@Iy*n?@NDAD9{)Udh$(rbQPhzf9 z8JY;+DGYhAq|yF(U1%-4YRPKy?B&b`|4M$4#ZGtK44-SsD2VPD{FkZ?0PSVK)mS36 zgZ4H~L7y|Z$KsC(_IaJcUw_5bgVW@ zTh7d;)dE#xolZ&kY*z#~+=y183hBsLnTSEiyO>wPF@V4s`+dT%ZS>V<7rY)}slsk4 zPWH-fSYnzJQ(@D6*yeds4Hm^_%uHfCmhDDQ&(sv4OH`d+Y^|Oa9W6s-=C&X|r4m-v zxomJf;=VN%XB5Cd4E<&;JW%$~u#_7OHxtA(ZSnU*TcQ#T5y0ZHA?x~kEy$=DCVhH< zztur$J@C)H7(UE8@p7_L=YAVNEXWaE^2=8-E)S+vz0Osg4AfAhI*AXCc{SXPC0d_< z^9b&;Bz+2Uu2wC%Z>o}$vSlXhj7llrU*O0I0m{bZ!w8#y7@d&BTL7xVk@ZS%6?bHg zA1w4>KR|bw&C3sbp8c2~cpF6ty>!v%6y$zwW^AFro1^rB%3#~`8z}>NbfJYYp@=E# zLkYfWRq05qy<8;~RcfgrzwVnLlSC>(uSZ7n`wUe8E|dgt;c#l-Z@ADTi~Scal(^Ns z6N~w>ofkEeWit7-1~!0S+6;-LoQ%GoMXvcQ%u^n~th2D|lpMmPpp176at!RXtmu{IHY;^5gNzk_oE*KHmv z3}FY%tV8ujancdW(s5uA+82qHD?N%F5yQ?DIiFwwO^Sz+ z7jRdniwX#Ht%^=IDjkNlyQX2QBKZV?GV335Nr%m&wV=-!0dg%5&g?qTz@vLCzNzBj zH$*;99mnhJVn?A+E&Eq@xi8gTn&`7tT%ft0nJw#;C&6#rvnO?c#%s&b-2`!i>?h#aDCkNNFqT;KklM`a@{#w}m zi8X&*n$S3^MT`fCS-9Ca3Yz~~)I9}xk(xTk>-e!XE90VDn5Zkm~M;J zzVKR2(HCoPeVX|0zJj@r$B9TIJriN+!jP^dn_(w0qmE-?Z|fJGD#q4N&R))J@Gs>D zvVuN(6l@Ty_P3ES2(-(e6vy;=vMAS_Y^+hxsfV%8RWNVA59N1IbzoAkMc7Wibkv0a z{?}jjKc_p%N@Z<{qop=iQZv1T!zd~$YEyE@ZuFpP>D7hr$;-A%Mf|`DOadQA^ym?# z(8IPoTbCqfDw5G$PXoO0jJaIh*4}F@GTYRv_e<3h7@v1_TaQhg@`pn;R5z{^3)WHY zL2SNFb}$fX`X(l$h(lf6UU3!qm|BD#OS~dMfw_RB)9&Y?yT$NAIdZW#T*SGFnPP8L z_Jq(7NjLvTvou?}6>dQawlX!^)Jh6_9r=@-#a+W>8L95Xne?qwg-?g;Ay?DHz(o`dZ6JT%7v;B zpL>;?QcAd?+cl~LimZU$!I(@I4@yKETwYYSVM&C0sT-US0_yvY11op%fl>qS$gbbp zIAng!^xT1HRSQF(nqY{=a_0B?h?ru@WZ7_6Y0lwxBoO5tT4*K1l=xhdx0X54WzA2F zQj}r2@$hjYI0sY-_sPLlEGS~MiF4dmAAXMy9h-Z%EAdUwf``;f;JFm*1Pgyp zhm0%VJ0(2=+c{IAz{e@Mj}VtvUG1M|pp{Hn=8>Gl*@Xh5=0OVRPO!m(qHn}GeY7@X zoO++;lTz(rDzSFlD7iyDbd5ewe|{u0r4SiUA^yB)E#<;7CAkRgfYy&~$l!sl8-#lp zn+7E7u!}T(bAm*aNTD&Nd~?6=C#(sz=k=MpeK1;1>Lx~ZsnACuMPN^xs0GwMcu}?y zbc5~YSod_R!+twLX@|wSkC&DUcT;1mXDh**s!eyG{p(a+e{bRRRo~|F6$LgP*y|dviV5 zCs>L$21+q1P+3KkYwaH9i9Vg;vyf+W&=yuyw7t3NhT67`PDUX?OpM z8I^vQags7BXEvbaf6*5&VCR*ZY5*)e7gFyqA{l zNuv6*L(c5-Pjm*ZqW9YCLe{JHui;({lif3?pm(PrphL9~CD5Uo01t$Re~#cB2xw3Z z0(J>038<(qpQB)*VHFgXVKa3+&wfitMDxw{+p==MdQs!!;SmG9s43y?%~gZ%yqLp> zw&A*0LkgC}iY?+XeC(*$lp$zg;_5M*xjFy&2g^sq%$$nLQyq|QtbSsPxE2IDaCHBwZinFOs(uRJi6dUK#ZKOSan>` zrap0x{ebh%Qd{kZF=x9rp3<)eEfJ6PNv8bA`K$q^@(qK@@VC*zAD4{Y9V8k@KIfq2 z(_diVh*;F`v@;sk2;U2b+ze~qh%|yT0YdL&TnnEfykN!%w8e$1$$K{d{R|$# z{lHn+-M<@rf4m;KBeu2P{c1^k|C&ccU7N8*so-eB*~I_T9%_muVZkUlY=o zkBW9-Af$!C$2eoc*PlOH9hXKlEL)QvEzH>)nd3qaGHu!f`F9miEACt$ryKU{8k z5G)B!2^P^&ED)nn5;0t~`vU9IK3S#KMXO@-pwGb*3DDeA&|VqV*$n1O9Te8*W5>m> z^g4iXfTc;%oA$sYhq)usC8{(@h>kKO$TX)lZ25a45kuRNwPcCC;>U%;3QvRb$wWzZ z=W?M><)6wPCZE{<-wJa5v$ zLP4Jc%6$rw5Gr)$74XL$IKmWUs8)qhAFyQr^TKQg#zRDS-n{Fmt(7_Kwb|bd^wM_o zSKD}jmYdu&M>IDLbF=q~srH=3z_U6^Y#D_q`6^iC&o3+AUvVkTpzkML33w?d&YDWm z8)PFdd^Yj_V~;49SF*{R5){AiYUP*Rw7=P6{aKZ@sQ;I}_(EY-GY#>$^#eP`1zq`8 zq)U=J^i$agP>9~JX#|b=k{7bft`*<)Qjr7dN@bQ5Hm6t@REmiMSMmnQ%8NN)_2LS?q?7X?n9+C}tG! zi!nj#VzvOi7~pqCMd%yac@!^ICr$qvg2PWiDu#eCjPJ2+$RVF@25(jS!YL?Rt51C| z9C!TlA8B=J#b)z7_+R`YU%GpP!`7j_+yYb7?R5)6yPrD?75}>-6DP5>Ti)kp?NT8& zp0d&FY9k?>n%LXDd^Yj_vWK&+$V*F&kIb8@;6Xq$(*OehVllsCWu(!!w_~w5gqaZ` zAWBUTo{tKKJB6YJfU|{N6MUy?#u@U$?n{faC{v3k|DBBb9v5B1CARvpq)}cG8ePL; ztfseEdxY($F9mr)1-!ia{WLT`?_vc&CVy}Al7_y?G?XHg-eRYrXiyEO@2wUh6J^JUGU6Nyjlju@VJGvAU8ad4?Zmh8>1~-fWq_ybFs)_eVhJoxBo47yujGob$WbIL)mv(JQro(?fF7uPo7}tp<4*W z@?HfURR?{soaz(1OZG}tnG-c5sYrQ!v8%CP47WeWcU?pA9=9gavR@0P`ku5TX1F9c zel57zIa5>DXjJ4`BOCm6?l7ehs{DNQcg@C(DqY$m1+=7f0>1Oj|XrM)F~QH(9l1xI$8eYRF!NmDPn7W4}~ zj;Ot~D&69QPGx83#~J0l=bD@up0PvtvNM)cj_g0~aS*BdHBd3&-d6^nf{6BpX}0tV z<#p|%+7|$TieR5H4iIx%um^6^o~55hJ4JDd1?AJp*w)YoAr7t9?V|%+)4*5SW~b@w z&38reOV*qXHG3J?p-35B%9}>Rwgaaie<8TxkWOa;`?3WSMy-YKj_8uSrK%9k7$roB ziSvh@%bmwz3u!8~};LiepX#rfsqX7|T~w_20ZN=`u+mQF#1 z_j!UX%Uy)U4>~#*A<|MLZ~H7SMTRZ31UzQk?wh}*Bn;3DJ^7zM*r165?BoPL)UM&Z>!T#4m-4C;?_?=xev!$rlO9V9z+kjG6_W8 z{USeiOclDI{`)Q^aN)OwPZ7Ul{T3*D?cZ}ywV+a0^aheFwr04rQ&49UvTN^_Jx|Y!@_6jVtaJ#hlMTxIm&p4y=~&p&hWpG6;kJ*P zj6{0sH}xZ~*Hm2X4c4M!8nYA{9bx8Td8k5NqeVMIgw0+kDtGZ^UPwqtK?xT6_1A5l zLBbKfvRS5fw*40^a9`|uq5%;5T$VVZ(FB{QO5HkW8YKTYG%yZxXN3IsJskG`2pKI> zZNn{67HvJ;vYr*=Fk}i=p6Du?Ud7yrf}r;W!!Hbi3TBjn-NNwp2$PHgmIb|n3~YNy zK@e<4?az@XCu6l;BHZ6|BY0VgZB{<|OU{DM?{R0;XZHFrx;9fi`1%^t{R{D(;&Sw< zf?2BTA+i?v&!5LX4{NdXx3kEo;N|47KA%x&621~{-74l-FI7;_!(Kp)+H7BJ{^DHd zw?DC_q#dPuu;DQ|)IQW*AgB#vP_$H=M$~KnCDbg@=gp00GV*X*Z2LJlhPe%lM3I<~ znl5D~73)iFD`sGWk9m2$SC4Ogz~~?DL-oB2-fM?Asq?hl&8418`B&L>3^tiFcy7Mc zj-kuBZ(B=F-jM?4bi^b!?b-akn{WhU4=*@;nLm*GAeHyubPx7At(b_VCUYo{PxG@GZhi1e_IysoaogO7tvK+ULyy^>go z`Cu{ML30Qd;OkR<+W)DQPl?P@pK}O z8SVSdU#+5~T@Bo8ixH$kSxEIKjhg96ZMn=Y_9jBP+AV}Vl$}Lv>}amf+q?12 zu~i61l^0iBO|V#zmObPOYj0C-UW^A?M-0Dj>A7A*%ZIYmX9wSy4j4B3bbPSjd^6K~ z!rsK-N?Fx4rX-|Gd1icWqn)d2h=q=`Qu~2EKPP)}XjDi|%9m^FzoJEOtUOL9@#G}BT3*9$fz!Q76R4^4Z4fiYUAINGIyuIKsPL9f9 zUOfe2ioiw)E`swf#Dp~5r<-Wh61-3aySJD{`OI!5#Nx?Obmim&~HVI)LyLG)_pAr9~4+PCbf2u{JRqxo|ND*pNKdG5o+A@n#-&g*$jXS zLAJ;r4Gy%*ceXCS57)0BXIid3S>{^vf#e_P;54aDLD7H7NBzicYTNB@i`iw41XCXE z|J@+;8rD|9ka~clB*Y}NvL{8_0@P(*;AF+p&~9sVuAgkhZ2mgw#|H^u>o2L!w*R69 zE~~%nUIJ>%F3Z}{LX=Ef#I<#g+#x!%$J&95>dbL>f8SMVK9+o;Uz^>g5)prmU)<^t<<)Cu;(s!;qk~ zlrW7dT}m2VO|2r0o`Qvwlm$E8&Lwz+UhT<#vl0doolEoDv=JHtD}6;8mhCP!3z zpK<7{6Klv2_|u)2UzgESglF4-)dDf8!tgX`a|G_3Uhev8U=CA)r`d0Vd90=a^?%z# z^!U$dQ9H(EKB}(z%u5c3Kb-maMu+oCokb9~1#E_ex>e>lgHbkkghgBlI95heQu?Z=WJ++Ph(nW1qoJm_D~F;&ya;T2P~s}>MUFV6D0|F$DEbzHhoRFn;?UGy zB%&CipfA_K(pijT4eKk7kuE|HUoZb?@{vzBnkN66r~Y)qPtQuR5POm7M6__*V+Sevc_< zFC&^u{s%c&pMuVB4i>l4^cZ`@7}AME+VV|?IRF8FNI|jQa8O7Q%2(4ERem+(@W`P* zuSB0stbIsVzkF=6*zK^<8zA!ikv(wHCewL5+1*|MFwKL4VSOju(fUJ^ks2ieD~*^e z2Ob~cskb#F;^oIQPsll4_>$M;=wT+D-dZ9BDi)StOY1`6LXW{Fpn*qMAPLQ_Tz?va z#0>c3Wqb0cOW>$JU7I@NVS_}*620<0EMH2gGkj*|!e5-LjW15SsgGPza%cb7pa=#c zkSkvd8rmZi6kMhc|O}5z}%de!Q`R~TKf-9r1X{Uyv3%w@Kim! zUI)GEXSZev{%|SBO4L8hMfqAva@XySHc!{K0+Xt`9kmr%R*?>R3 zK|Z%)cW5kSL#);|f3LdXL2CaHUrAwG8)W|tz^t_#JUQ@&4%>9I* zZXB3dHB09!Ou3Ia1vS-$Z~&eeBNyg0oE{Pqe;$Yd?zP>iEG4u!1+ki03|?+*%en3! zsj5Q4l<@wFl;DjUp5_Jy2hC+g=`^=3t%x@(EZINxyo>mLM^@tb_}SYtYy6Efd>iC= z<%sX|p#_0PK?xzKl2P3JYElFt!98U{)VplSmt^S{sqI+&sHPx%>88)j9hVKZ{CvQ2 z8?Qvt(E_g;(@`hJ>;8sbO6A{10$1vnlSE#h(-^3Ha<6nD2z2*$Oglcr>LSHcrQE`B zqQmW3=IL#l9i?sCP3V?%ibQaa#?qKuuu{qyCz|L2c<-U_2|lBG3>nG#5cS=x7}5bR!>w=d z<_3m@?KLMdMmY`@cY}Kb+fqM5Aj;?AbvNCa@r~4VQKs3;!ZH^CsP}hVkHhF z$rD%T4}lLpHM$CuPRylIs#R-z*9GXHWB`4pPZf;aIX zn@>*8kf~{9O&R1G{gf#E&DDDxd!Sfu;7XEmRl4Lh^AEBaWy0tQ0;GCo3VoAttc^I= z;Bd4GF3)Ao>D47ecx7vYNkVD)_I-o#y{Aj_$7#y=3?#lspDXuS&dD+7=Q|zx4$7r+ zUdu+VF(x-3-|FVT09TJUf}+EpMfFNvG2RVO9`vtcA+KBZ;W=g*`FZ)cmfgH4-;Z*7 z9$sH!M#D|;2!24#?D|0@1Gs_|9}T7HW{6l?_~(9tG51^y_>#M#0G6NCFc5QnLoNhI zV6}EM$SI|_9XhTCs06s#uqe@T$lQ*Jgy~bYV}|dhpx%+oDPsC*kXdyY#$L*HNZ#8f zf9lf42ck&1joWg1udV|S6%M;CbL0J$n4MfIqy2+C?zcvgbtXtB-NG38C`a;8B@#Fk z>YDCD%;H1Ly0UT(w1HF7dndEOs^vHIO)IyuME<2z9PZhIH1o{je_vqvmk5@5S6D`{ zO>am?r$!W5>*j>VWxE@I_tI@nY|leK{U^h>i%GYPAIV{R?_{!_ffV$2r##YFvHB{W2pUcZBtEtlm3qqj7&egZg6Jy$Vjf&%IV zGP}aZ%@Em26AEJX1eOzK3NrIAzW1&*-e2Im2Sxc4KVifps-)Q=TulcbM?n<&pO<-!a7J#G7v?+EX3 z1@1yMv_MAlNL`oIZ6eIOxt9r!CdTPofz;7vNg8%06SQ}e*j>nxHG%lE(bwy4u$;#( z*HJt^u)SpWy>ayI#2-k8_Etz-Z-vV-dF@;hVWb-Y!|wGdK6yJr)7z+b;;QP9`FfR!QRsHlGr zn4+u~{^RU=LV+8bJ~#{z2}Z%Yn;nP8jQki%U3Y z^MC@+=_yzIt*4*;cb_^Q2VV4D%^hdC>q_-KzI^94ZgEqFdL51G1meqhm3pIoYbV(sOB`)>2 z+uxSKWYgkEvMJHvJ+p-SM^#fFK;{mGFKr4Z<=#4iqi?hR29ii1Z_kVL%-S`1sRVZh zXM_Vm)}{+_{yhroZQ!g-1D$DyqRDNR=_@7AL#;; zdSVFDCw_P57J(;`XX9I>d1?I0U^jXGLL?xf4TNr69N$Jk(|B00usrvuBATgE@=K4` z#o6bu-Ain#1a}pl?G?;Q-Yrm+X1tA+HJydr?78VCP1m_*M-7G5L6IvCbeHSk^>1pU zhy$6#-23kBc)AWVy{6NCt~slf;u3F(ySM~L_(W_LKll(npm>jIKX2X;6+-<(8QGFs zcGWR#EgT?UT>RE_XgN!R873!rb1edyF0#CrEz44#qV^0-F;*jk=>)IsC@8r;{G5*I zyMEYr$oy2NRNOirGd)BhLkK*it`V^Sm#y|nA96F24u|!6W6I%L9 zxyg!GtORq7P-$b0o>HiG+L4IYkD8_W z6<>D*m#%1J$d~fmcgGZI%d~r-1E5IrVp0;`yYz$C#NY%G;*nJF#^#6V5@{B4SruQ8 zr7~6b^Kq1=vptoFz&ist#_uXeE-wWB{MJkEUcPj`4B4CXzBB!92aX}W>?mgA8Z7u_# zV>k8A5|f{&gcReXoy@_KHzJ78n>;h!?GnVUFl~gqFdYBrb}btbZq$%U@Dd5$;|Z>MvGmW;ux)eguw$yd6w^Cf_F2YOv;h_lZWr5eo<< zj=DMNk4ujXEgCe3r0HLqjE7JEfLnFc+^u-ak?bcctA^UxzjH8NuRFVCq zgqiJ)DBSDk+;36V4*b5M$r>G3@2ZebY%q2Z?*x7_g)nwSl8;8?f81@yeRU^so-3vm z|JuuXm@W0KwF`zS1DcxdVCbnLFUKEBma^l3O(2$H>V|%6%TtEHGuo84A z_%NB{H8lty4Ovzv0BZTmE<)$Q^~=HX8JgU&|6_MFs=lw%WH5fsC-%<# z#^PQp9U7-m-u+HZS}qaQ_K+COtW?|5WCF`XE0Zx(XgFj&jC9B^UEzg(z4j%_`1#^- zuI6G9i0NJxjBvJvUc_ld0>4-;Rc|ziJb^D+)oH(P>@e#Tqz2<8cgI|0lNNG^nLX0Jbn3+mtCnm&TibRTTOPB_8OJXo&tue+} z#uCOD%giu~-$(b>^Ynbb*LDB#yME8_d0gk1&z#R^E{#{`oY(vPdcV&(^D;*&f+Ok> zc@q9>sA+u$$B%`fV&OMC&nQ~-1&<7+iS{SN=R($mX*2PVL+N@Bii%U;ZZab$rB{_MDo^*~^N3pWQ9DJJyoiU$aBJjv<7+UcMcmm3DFU z%hGV+WgWKD%pQ+lk7#T{j^BT@$Lm6^SN?9Vcc-_O{1M6e2+3pR(SH&e zW?SbI7~78na@Ri@EW8KIH>V}W*PQ-(=|EWI^V*S3LH<&oGX80>eiW>-9XgZ?x3JKEHXYMrU-e-vUB0?L%|~8Sj=xF z)Ec@>^YcqjdtC;{-7jccQm^T?R!Ys&PQ~Ba7NHU9A}V=$M*Nx3;S<*v-X*zn8@c;x zShbJWs&`?+=`s3S1~cL`w+#+L-zC&usk&|tynRQ=Z} zI;$4$f7mlW62BREFn}PBS2szzuDwkidEB|q`LPb~No?hs`Byf_YAf|%gE9>==uOYv z_B6-{Xm60L7FFbBZ(qxtuIeq${Q=NSP5r`v)9HIUzYG@nfIaGJd|!*L`}77ZCh=p_ z+UV|#&{|-^yZ8HiC%X1RcEfGy)(aj&*XLx4`@-6~UOty8J-L>96Mf$#nJ#b8elCKfVC!R_Yvh(0eH<>xs z65U@5{QCKzMg9s${kJgo|4d)e+q>W2{mc}Nyg7K&XJXmi)_pErueJTG%)Vw0V*9dr zd}2z1%R>jvZBE(hIg=%#Aqb+EIJ!;8)1f77Io7*C981Q0d-WB0|B4tFT8M>O^X*eN zBkv!9S<9ZhaNK{=G^E!hCapLww^_*T4f+RSa{h~$6a--*7qUrfvbM^I8>{R!2-A=V zz9CUN>v^!x`sdA9b8oyEO~DL zI%c{4sOj71{JVE#UQypybOL7*f;RA3A)x!95Nk#eO(oAi>`of@lFHWWH~PDG{d~#N z5PTe@(DwQEX7aUc<7)*M@?;-Mn~mvbKI@dOrE%{p3BZNEgH_xouV1MmntQOzP_t|@ z3w1Tn(yRch__CY)#LCmVQR!W|i%1WtILgb5xblG&9RBb0hu>KvlfpQ3x(Xb6LOMxh z>uA~sBWBpSh}7a=k9O=@mHhOVEv0pLU0xnuqpWsK+&x;L$@KEB_4h8qtVAyKcsD9~ zX}O4KqKc#Tm=O)PzMRZ@)DdWT+8wL-yp;T8hX_CFr4^obyQFSH6p`+P4F2YRw zh|$JJGME3sVUl_lqVN1&ztQRgrJgEKtH9TFwm4Sc2yHoRp$HR$| zX+ox5TYNRnPV20`I#oWa*om|PjuQJbK%eU*-98?^z3=5~F+kk)nb`dE#ZqG1ye#I|L)YYD3M^TXUscc7ea()3^d-hpeatozjWP)?v3v*5PYE4>R_p^Enfr! zP9LiwuiRL{tE&YSbBaDTqB0eAV^5NwbAcV2jfl(Plf zN7kK_xjDB1R*1+Nwz2@UCm=VW8GY;GGvPVC`fT$0q=g5H#@Vm-3ca#7p3%XK#7gCI zSCPI6be5!yWUgttye@dP__RfK_WqR2`?z&>;m5No&18^m=R? zx)LroBE0(h^vKK8im8shHezN=npc)9)vrgz8XQv$+lZuDQF06pJ;>DBdHRH%ps}S- znh)dA?0_V=wNv&(j|}vcs6O01_kntL{6U^2gj9Q%5_WprKrxITrY%$(J>-}+Fqj>r z4Qow1JoGw9iSUH|vLw7);9#(TuI?#i<@u~Q=)RL*g)UT!MuA%UbP#uSFPwOGK~ZGW zy4CmQZcB;1uQ7_N>DqK((K2s!7;${-$w?4l?9fSIvtfb9|NaX0U*8bO^F^kb>pU6y z=a(}hk%XOT3L54&)7x~i{Qt|FHmyChxu~{n*Q|NlImXXU(=Mc^983$5>^-Tmu+5os zBS|3s`G~$WU2H4dsg2MABrWyw)i%^KVOT&CZga&~=AI!BABycvdR$tOUPkF6v*@HsqV!>TZ;S*bhuld}+ zHq&vk<%uXwQNUch_k`bSb2;;Ub6|s#PX7UxzDpnw%VS)Y?;rj+bp$L?VQRT*-@usr zl$M5v$*U!S)q&6M{XR~Fj89Z`Z_)_dmIo|wh&>_ln+VD`KK+He#prBIg1GF_GtLos z;Y7LpE6smG14rBW$FA+Gfj!$-uLCx32lj0LzH7V4nw{%}cWf8kyH8#1dSWw)F(yEMZ~nA>_x2Y|kCqWx||S-29dr@n$e)iv@A3%_IUibPk+|BAr&F3PMLYzO~ruGV5Q zL{ie3sNKe%W+45G9GXL|CmZKnLv`e+;NZ23x#4YvKGiM$w(ZL=b+D8yUittmL1aByOLb4?4f>6Gy+l}_*F10JTbsi*Ke<-~4WPcouR2tII|2a^afwPzjG0+8I2dl*ApKL_GU$e(S*&CEVPH3h&2!E3!@CM0 za|t%0qQ?~+aMgpMiB&q&Q=WHhpRAO>i@>$ooWcpNNrO(4=Kh^aH z7WW#MMWxdsE9HMhV7n;ZX~&`h_m}vO=r?ZD;pqErR( z*F7Z{B*hMhtsZUE;vj3e-~m|r$lILdmne>^;ThY_lX+2y9(JJaOhn$REm+d%yIP$O)a?c3$#yipF(al3WeDzS=cWhFr zui~hR0qP1RHV@MqXRIrWJ%l8uUWOjBn={SpI%72KFgi?YYP%d!=Z9G6gqodwWHV(t z)b_T4xna(~tM-nn!(>LtFD&@XwdxI3`bjMl1A@b-Z|Ip1I(*sCIKzcs7Gw?w|5R`E z2isDetR10;Qo7?V&5MyQED4BdEuv4tdJ7#X-~U{-$zyp*@}eu{Zz3R^{l)V=kOpLG zV}IC0oc;CfBw<8S=clTT?>!A{W>>LN{&xhfky{g8BzX@J^2LLSpI$ z#~OOU*1i8TKT8bXaWdjVUrR*Aow2sIHqFVxW+HkS1?&7t6=sANQ5 zb<3sKI8}2T7FlQYEOKBcHR;=*|6Zux#AZ!XziZX zqGb;AwOs`dzvLQo;50~-;_E-ZD*U*s``f1IM$DR@>Z)|(y`%3$ejDl5Y3-GXE9Gw@fLt%73}vY9Ewp1Nf(xa$ znFVu6(+eY%@K#N(L2&uNECDB!>BSD6h18B1SlnGE`0OVuYZ}>+dw)~Zt7U`!Phth_fES@_?mrJ z9xwvm(cJ`9*%DXZcC^SbG7m#03%B4i{N6~D5vbLNoG=^uW?x|B^nL1kJB;^{d zw&xL93_|{lA=Gx*PgadalS%;BSE*$~wO$HE;wn1EGsO-1wuse;JLlyM!$g#H# zv4P5oYE@TaClYm~d+nu^zitV>bE-9I!N#Xp#@dr~pv`to3OL*upU~`iRQ%5Vx?jIe zAm3k-J$mpA{L-N>t{RZ(bV%x!ZT>!0K7|?uVG@TN7R(xn`frnCZaw%q7S&}vyJx+; zf=STKAwRfLHg8nL`D0aL4i$VjP!IAcRRUuxHk}O~QN8po_Qvg-Uk=&kAB*+K&*dLa zX1#o=rE-=n8QbK&@rSa3c%+Z&dHYq4*+F-!b7|J?FOxL>vm3&6`!~=mN4yAdAi>3^ zp+{TrTZqwHHl~uuk?FT4qp03s9Uo_K@R{!d1N7BSKr3|b$0b>c3fooy2lUO3MOEi8 zjq>>{3sLj?vTq+)B-#QyCw~8Jgdy=g8P|qQ&7%{gjgD^a5IT?{Y6Gsaw#W|x4mNPu zVJGXH*oj>5JNT}^>44Km*<)8D?c@8vaIU%aE%58es^Ufc7;vIpjSPm~te$Hcp%&sR zXRH9ek`{L<`v~+<=EskD)NV{sNuhpT)}e${<3LuCU1ZzTHx98{5$9sV+4qO7^fb{? zhwh^DQ0Ji%@bkTd?QIU}p{!%s_`Mwo=_~LnYM_o~G}0}IRvq(WvcUO2va$$)j|&Ix zI9~_nwUDj0u9Uxtzy`MM4iJ`|7&0RtbE`E?(pJu>6kuf{0dSxbP3->QdN6NbSjZ zqoYNa-&{H*8dZ1W-}lYB1u?kRZ_YdpaOPuom^Lpv^GD%ZDvF$w>In}?*SWTGkrln% zUXX&!&f?)clho#E!|q|TYp}j z?7=@s?KiD}tB=~Yr9&oJ;aIpqps^`=pU;9Ue#CDOU+>y5b?Fo+X7F+t0;8uMBCof< z^h{{-izK5en^;frL%Y<3qk26?ZkNm8_Pp2BwE5I&k|EixVsiKfbSh+a%eStENzboQ zQB`7Vwi_Q)AF>2i__TbAxfG~YSl9O$-6b0N<>fJ>!`}2l5kpVX^*C$8%XwYAv6@g} zc;jp4DWN8<4$$xexa`mfl{({6KU^94bB&IsIwr)Pgmr-nZTx%ZJ(J>$70yiA-hnrw zKXVQSslK0gq)6FzEXx2%QpBDXg5-IoEhJ>?1J_qM za!r(5%}l*-CHUy%J|B*0sEj~&;U-&gokl_v0WP@j&gAFpY23N?K^ zqxRULS1<0ijg8HC7R;(vYjHY}S8U>8;?NuQ#`7}%a?ys^OM|xRePGzr^t`Va+ntuh zi#p3>)~-uRJ2i5-sv%W3b>ZSUb-~KvOLhLfgMrSUx;(A&^WFUo9Yd>xt+Eu+7A|ZX zE6~Ut<_N2R2n?glpgFKui`u0qIWA#$nV(u z?Vle#(Af8U>xP@BPf7e|HzGz0M|9g%sZsujS1yd&dO||H>tVgy#I3eB+{8h$ReMm| zE-#2lTN=FTuO|3cosD|kW-y+)dBG;H`hjPXXhZ9QZpiVh`EQ)zS6znYufEg(TG*El z3iw07hWg9D)0GilcWk_!=FL30gX;3O zZ=lEZya!^8j!B@aeR(2|S!`KelI`T52JiL8&{3+3c+L+cTYI53e2!%+c52 zH-0+D6>OlnldmQgvlQsn`QbKmzuTA%ePs2mE9Gw@uw}RHCSAbByqcMIxtDmu*iTEj zMH^Eurad?+2_A}0_w|nxKe(nh*6a46=k}Y%Y)`!x{apC)Kf3`iBjyKY^qqAcB1Q{I zX`Z(Enp)S|@YZOgHrc?i{t4iPsg{6;=dupIJ;MRs4`0e!O#7nW&-I`N0%TpgKs{^Fm5?7$h1@*Nm7 z&zE__FZ%`+Y-ZFnHlpTd;%O>mit!uuI|e4+GW1h1t$GTaE^O>=kdeB0<6DPL_{)^6 zqLw--xU~wB4ZPtD;ElO(6U=Xjyg>r|CjVoJ8OqiFlixB6{D8k< zEaN-B%^6gIVDpZqfM1$xAllr`{Pxs+rTk3j`{vqW6V(|E>6q3LJ)NH_ z5H&J4eixJ9{`y?O!P5WI%bGqc1KYOjCmrB9fgW7;bESfPXMMqOTfY86P5IOkMsiXR z!Vh|Dfj}D@KYiq4zKg;ziP1X=qPAJEExt@&@e?(jLwdTe7{u|!KxN8?KQn+P-%b?Z8kxhDGbb8M0VNJ@CDneS99{ZrkN z@zFWta!<^`BY7XPH~n{B_UcdI_Eha$9#xLOs5L69yV!ksUx{LSEK7D`-VhHI>@G;a$vvg|P+pW} zVT2RAGIZ^g)p8-G@6+B`TN@o>7>9Hf7`+25*dxG>V{wbBVYZWnj>Uf^kO=M3)jEJw zQB|V0{#2X6AbD=gcPBFcbvj_q7JN5n9RbaBQD&a=P4vR}>u)PV_HPc;A1AiohU}8S zV;qn~dS>}thc&C$tXsQ&!`k(0fGpBA0;^UhBHPvq?@$}qxz948eecQZgWF|~TKN{A zx_Irzy`qwubs|T6Uo|i=<)R|4VHavZ%P_7i;)lG4E197YaZ^hIcqHR-s{)zVMxM_3sIG;ZvuAmA z^WH*gWTg3X^d3{}IS1bJj1OqtaVHyvXjpE`%atHlbCKlIm(0W(not>m z$NOC|KcCp>2Trz1q!y>A9C$*-9L_y}chONfue9>;zYBzm(3?A5DOJp%5)KGh>797~ z1E}fSE=Gznm4F*~zsILEQ?)WppT>S(*S8}G_a$D_lpsG^QZmcck252D(XMXklm6|s zUO$!lMX?0S--6Md#6XI@`mRnA`sn2~29q=_sAI|v%H6|c(XY-J6FFbFi4MYiNAoPT zYmbfuor4Iv8_%I!_D+rlYnaoU>al$> z_z+$;YsQD@!4Hj>ZlMW6wy5T5djz_9TWsZYsGIwzyUEL%2)P}~D26?w!U zo;;uf+KdZ3snxUbfFdV#2(RV8aY=x8zCJ6ZGbSik)nDD;tTcjqHAoASq9vJ{qNy-I zt~t<0em30CBb4O!s8J$GG_5U zKB^0UlbWf#mQ>;)Sk9AF{)qO|&Z(D&Lq3uxCsKCO$mO&zedW~n64u*={SRhiQZb=> zTyoPo2<{`Lvv$D+dfk~fcX&}yJV^>_N#Hr2NF>!|grHp6Di^&nDzEYsh(v!Lmeb8m zU^+9NxYUVF-wwJu9={o8IRMGmQ=T_|Se-Xr-X*dgal74;t1LIZ6-(27;Mv zv1?zDOUWch!@-g0p&(AH?sZO?vC9fh^@tpC$yZ2C&dYTDv6&x&XL?aD+2`Pg15*@| zdcc{s?tZ~5%@s8Mp);6JYgO8yFp69Bc@75zQ~ibyIz3a~w*E<)ssg!3pTK?wk<%^J zwtvxDUiY}{0owJ6{RhOTE$XfuWwGczKk_Nm2^z_^3H zNc9V+I;D_QRGL?g)*z{-su^3+&v0|}EJ>M$22QzO%q>WhcV@WR33xGBqa{d!T!YUWbg;y_z-|@UZk~~xyzR#?Mw>xcPDz9 z0lW>ZCTX=17I2HwOd|jAL@!CJ#t(UmHb@y~;o@nn6s@_TN&JA~`KY51{~5bHyM^TZ zUDYo658tMGJog+~xv_%B|GVy>m7c4jWWp(jJJJqeHSncn(>JkMjYmnaikE<+Ck=Nt zMyCpoj+_ZzNcf@QO)v4Db*TpzS45*iug zXJ#mbU^M8v;aw@mtxhOw=jh^7DbwVWY03)Gl`q=pkUAq32C{?~#7XWo>z`Z_X!=U+ zCBuNl3V--HwJa_Kw^*J+cWT@G)Bcu>t2fO@h>#wwdhx5i#=FK$x z0Pc>fd!v60vAW4{aI$qI^~KD7-Qj`VO_6AJF7yGFtIO69`?fam39OkxMPVK+3Gj1< z+Rl#~=~<;bVyM^^F19(gs=3W|(2eVO zn^WpsHMM z?1Gi`k~7a+9LP;vQ=&VI$VSZ~=JaVL<C9Ln6!W1qHOAmXRFuTY&uit82E82O){6Zcjcj=&WB|KQ zUE@U?$86qyN#JVD8L#zTtQ&?bKy0%dnMikZg9v%1hh$VeT5K(c4r0k@Zz`wcEgw|Y z$F%3nIE)gP1a>XzcoT4Syf{lQDu3&oe-PtVpU|AYTOgti?Y5}%epsLF*vko#W>T0q zI%?6E314*VNSqEIhCCmjyp%IKI;=o?52`z1=W}-57O*Ap(J)WN(`M_*EjKO3)v>44 zSS(K4ul8qh1tmYViet45+7cwJcE9X^2tpxF{TC66fZk1NQ5B5WqPD(wbuT5A+?)yG zTKRmEUySS9ZT3NX{7;?ED&3X9v{MyxMQ9Kd682G4$uSk>Y~4aei$(44I-T~v(&;pU z0e*rF^tVpAvDK9;fLWInZdIJypv=z@FW$wNxmDQ=&NrepT*U!Xj9t-vLLaE;~} znu7{`pqB6@L~pomvAJ)Ad}}6VpqED~qxS)@*-uZpc2H2Qa9SrcIFikUHo}?hm6>L} zbWO5<=124ZuvaqTOq%Lf!!-B~x;^RCVFF=D6V4hh8}K?w<|}J0zNQ^nwTt2AZX9{* z^I&v&_yOin-I9PQ6j+_udm{#S$4vDN|D0(n|2n0A$laj1`n5$Yie+~+_#~PDyJ9X` z#pr#*L2M|fkZgy>PiSrSVwXVqT{Gb7_-e?vdLri=!u+hNwz(SczgpaGK}b%7-zr7U zy1YQ6l=pLq?g}GKCLm2aw`kp2s26H%giGNGg#i0h?+TdmLiJ-z$^seA99Ae*pztIIaM5^l;M@%{GLCp@3Ye=BVVRQlboxMmP~l9^Rov~s zk(`h1GMy>PFR-Ix{gB1-5A;-7uERD?_0AFk<-Nn$Br3WnnFKGJY-uzrX;BLb@-=9p zYyf@G<#d$a)CwHrbw^jGYBF5IPD$^fW8Wv!!*$OqN)~_1m>nJq;)2zdYP_J zev_>>E-b;bZ}3Z1-P@382iC5tDdmt}J66TJb|~DeM{ZZSk+OG;9eMY=_iCQEZ*-5< z6|_ZP&0e|vZ-U9iU>^%tC=m7f%XNHgZpm;xk&c3uyX2y#n;tm|UI-#!N;s0)1Rl;G z3j%aZEiclv!&svQ+ps*KDbOQDp2X+%efg$a5*j$lf&J;NsBTyiP|daaRc*Z*Qln=_ z%n+SE(vo=qGAktSo>C0u(MT?eACy-^HP~6L<cPu)(>3-fF&H{nQ{FUn#X*rNfn@cL+l@`QSAmg+(*iFIfqI zYej-X7#6(@D3scaaz)bCPd^CBLO+e(#dQ&Mb##wX;BT#-=&ObEy%{NpAu`>b0Vf5q zS%$jjF8Jz#`pE#V-hrhF#~2TlzU-L2S!n7yz*diC@g+_8*ce|ZBM_xq&DT{fi;Oi* ztq25XWRI3n%5irni3Y8GIBMdSVR4PXpc0%s(Y@((fzHich;11Hu@Sa2~56F zRk$dIe6<lCc|5t;0VfFQka3>eM}MdXa|(_!f9 zlsRhUzJ;q}fdfkd2hB%Qgv6lAd_}LE@N1TjLj6)!ZEQ53dMBrJHptXB25U}0r6+{% zYExd(Q{qsVG?5g>)&d_8^1ogM{!V?f_fye{klHCK392&H_FRdpD?flR7`31A5zb*`0^%Gy zcd_yogEd_rp2)el#ST%)K!Fg^G#st|K-1V?Sd~)K6?RWfM>iq8rtH*7iEWv^$uUf7 zsayGhY<-NfI1PWZ)%*NeS2C%pe-dEGkqMd!3koK(T!6q+37+J}E?<-^n)4^35Mgl2 z_qbJah1*7k;X)bbR>UTkCnu~9XRS_k*)Gzgc=zR%GwY5v?o~>*TxD9k@^A%&f9MIX ze9ip_kuzRQ5-$f?9~YuquAiC#3X|&XzJdS6g{X^@r%8!)y;pjD+v?KT z%Yrtut66E~hR3|4Jjg4z|6MRaHr)?V$LVLJ^3YWX{DA?P;ko-4Ti`bzOy^ds<5 z_VMUXUIg^lVQC1P10My{uj)8IFJk4@MEVm)H&R^)+RbeKS@TpMe;)s(Cz>Wd9e~e3 z5j0y%K+^$q3S^+ncDw}ek2pp}(?joq~h1D79%*flop-NU5CVW^=&hb!7)sm$%XAa2T`)p4qsG|vG?eBD!LlNDJ0DzqTr*^ zSe(2w#oKa{^g(SZZX!JJ3A>yz$WmGGg={kbZ{sZq)ahbD>syut?nBr<-iqq8)2@g? zbTB9sT}P?v@TO~nlE*FjJ_q3*T;cRr5%OFig6UTu8njP7!~$U}z;$l#^NsfFS^Hu5 zQ>zR)t~UkI6^=3khRq~)sNO)4U*aXgX*{e2fs;%!h<*_{HA5dA3%F%wm6z`awf9<# z5Ti>nO-urJtU6KkYo;ArWrpbYa5OL&`n& zbb(r4z{^A4fOJQg^M`v^cLLu$9%InbidBKM5~;Kj?$t5Q#SuQ}G|QwcfO&<;$mbv4mY884emO*rp&HV3o+VsE07w~0vk z{*?#+|3LDu)SPn{7$=^jqMbK_wp6%G2Z+UxP@tklJqm-#YXOn~qG6JKdS+}t=2Z-) zkgr_Eo(UIV+>tyRFB8uP{$uPP)4Lp>l9q+9CG7@ z2NG*Z?sRE~QG^@0NP*3dMr`x^F5wtVU7afH+aFy-M%*tg2f`9G>Idw|sqb!;R`0e3 zauh~leyc)PLGUtebjKN!Amatt@w^ZJdeWcj)BlG_e=cx8kS^8+86ZG)&*`I#$xX0s z(#M2A(j->7V#7@_C{K7cXdxF-_Q#|T2F=2&DQ*v;=gy?N_6d_Piv=0wDE>qle@P$; zIuL}#FQ_Y)wL|h-r^29ypzg;8FVL8@s~n^zEjM0JOd%7fLqyG*}OX}=tFCc|DCOz{n<9NlG|9^{5BYh^&@^m}pMls_q;X_*3| z4$5=_KZ;q;XI3sN5d7E+I{)GT!az^TBMa$KV%E&mj9vQ&bJFBUrY-xP>dr&l6Dg5N z{JrNtHZBQlKQQQmh}gk6iu<@RDAjRrZKY{Ju=GV(D)xqp2rKuARbQs1#hqY7{cpo7 z%@s6$>po5=KNvBPVyPoD!b7=a>caBagIDqrxoP|65DtzHR2G$vHI zs7R~OOSl(?a%8SqZn`za-Knai`@U;Kozj5zk>Q>rJ$lqQ=|1PM`XV_%++hUQ_C|}J zlSNBjVFi-E2NjUZL51*RCEp1JNM@QU3qF$&I6$2&>)-dxVM(A(rrN-B{^Ja(wlt^- zSS3?i?loTqgo59jn|$&b*osBx6G_21P(BTZ8$@FU=?c{5>Nh6ZC7^0K?=c#K=+zVG z*UvL!<1(APu<|5CHwAUw4++|W#zJq-aAo>ZSyNmsA|`ZaJv6zQ&-CK%q}Aq;$qSWI z85&n8mdZrlXA<_I7f!N*UESSBLqZ`c1}fh|Maqz$%<1Lb^eHc{6zqyN1%{=BzU(>k z6$#l;j|}Gb`u&*qblyVegD>ER3<7?L_*g);{M?CkmQ zzgALi9o6(=dcQW)+~;^x%Ru!&Zn^{*jS4PqCp=BOS)Zb1=WZnIMdg&Y4bAT&3+%+vt8D%*a{2kR2edqgc9rL&B41N{l)*G#XnD za#~i26O<#Jr`=o4i*`tfTXGhYXG2z!u6FT95Ae@5JZ%Gx1@e zJXjx! zLTb@k<2&#GT+7+EZuA_UjSK(&GZD|0NU6?TPkh)1YT_BzpkPWD9uFtz4 z7huan>WF}I^)s`h?X``f#0+S2>)7m={_i{vQNYxpx2n literal 0 HcmV?d00001 diff --git a/screenshots/DB diagram.jpg b/screenshots/DB diagram.jpg new file mode 100644 index 0000000000000000000000000000000000000000..81e706dacd221853f4d8005acba70f3833aeecaa GIT binary patch literal 64878 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�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 DlK@a@ literal 0 HcmV?d00001 diff --git a/screenshots/paid.jpg b/screenshots/paid.jpg new file mode 100644 index 0000000000000000000000000000000000000000..252cfc7c1be7a242ded86ad6aa68a78c03cf565c GIT binary patch literal 20293 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 literal 0 HcmV?d00001 diff --git a/screenshots/pay.png b/screenshots/pay.png new file mode 100644 index 0000000000000000000000000000000000000000..9d98ad892837720ac46c6cfdb828584049879952 GIT binary patch literal 51715 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%0AI?sDW(&-IY`G`;xE+@cP<@49 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

AmvLj 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 literal 0 HcmV?d00001 From 5af41f6d2a041bff8e314ca3dea3bd2f2a51b031 Mon Sep 17 00:00:00 2001 From: Pavlo Semenikhin Date: Wed, 24 Sep 2025 15:01:28 +0300 Subject: [PATCH 162/177] update README.md --- README.md | 8 ++++---- .../{Books swagger.jpg => Books_swagger.jpg} | Bin ...orrowings swagger.jpg => Borrowings_swagger.jpg} | Bin screenshots/{DB diagram.jpg => DB_diagram.jpg} | Bin screenshots/{User swagger.jpg => User_swagger.jpg} | Bin 5 files changed, 4 insertions(+), 4 deletions(-) rename screenshots/{Books swagger.jpg => Books_swagger.jpg} (100%) rename screenshots/{Borrowings swagger.jpg => Borrowings_swagger.jpg} (100%) rename screenshots/{DB diagram.jpg => DB_diagram.jpg} (100%) rename screenshots/{User swagger.jpg => User_swagger.jpg} (100%) diff --git a/README.md b/README.md index 8569e0c..6766481 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,9 @@ docker-compose exec app python manage.py test ## 📸 Screenshots -![Api](screenshots/User swagger.jpg) -![Api](screenshots/Books swagger.jpg) -![Api](screenshots/Borrowings swagger.jpg) +![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) @@ -184,4 +184,4 @@ docker-compose exec app python manage.py test ## 📊 DB Structure -![DB](screenshots/DB diagram.jpg) +![DB](screenshots/DB_diagram.jpg) diff --git a/screenshots/Books swagger.jpg b/screenshots/Books_swagger.jpg similarity index 100% rename from screenshots/Books swagger.jpg rename to screenshots/Books_swagger.jpg diff --git a/screenshots/Borrowings swagger.jpg b/screenshots/Borrowings_swagger.jpg similarity index 100% rename from screenshots/Borrowings swagger.jpg rename to screenshots/Borrowings_swagger.jpg diff --git a/screenshots/DB diagram.jpg b/screenshots/DB_diagram.jpg similarity index 100% rename from screenshots/DB diagram.jpg rename to screenshots/DB_diagram.jpg diff --git a/screenshots/User swagger.jpg b/screenshots/User_swagger.jpg similarity index 100% rename from screenshots/User swagger.jpg rename to screenshots/User_swagger.jpg From 47fa42ea6c710ace4edb4c7994dc9c5ffa29bf37 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 15:50:09 +0300 Subject: [PATCH 163/177] refactor: delete empty files --- notifications/admin.py | 4 ---- notifications/models.py | 4 ---- notifications/urls.py | 6 ------ notifications/views.py | 4 ---- 4 files changed, 18 deletions(-) delete mode 100644 notifications/admin.py delete mode 100644 notifications/models.py delete mode 100644 notifications/urls.py delete mode 100644 notifications/views.py diff --git a/notifications/admin.py b/notifications/admin.py deleted file mode 100644 index 922985e..0000000 --- a/notifications/admin.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.contrib import admin - - -# Register your models here. diff --git a/notifications/models.py b/notifications/models.py deleted file mode 100644 index facd81d..0000000 --- a/notifications/models.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.db import models - - -# Create your models here. diff --git a/notifications/urls.py b/notifications/urls.py deleted file mode 100644 index 0960cbb..0000000 --- a/notifications/urls.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.urls import path - - -urlpatterns = [ - # Define your URL patterns here -] diff --git a/notifications/views.py b/notifications/views.py deleted file mode 100644 index f4787d4..0000000 --- a/notifications/views.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.shortcuts import render - - -# Create your views here. From 44f902385ab6b480387bcc9baa02dd54c1c95089 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 15:50:20 +0300 Subject: [PATCH 164/177] refactor: centralize Telegram configuration in base settings --- core/settings/base.py | 287 ++++++++++++++++--------------- notifications/telegram_helper.py | 11 +- 2 files changed, 150 insertions(+), 148 deletions(-) diff --git a/core/settings/base.py b/core/settings/base.py index 497bfd7..15bcf8a 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -1,142 +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"] - -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), - }, -} +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/notifications/telegram_helper.py b/notifications/telegram_helper.py index 8d78bba..4e19214 100644 --- a/notifications/telegram_helper.py +++ b/notifications/telegram_helper.py @@ -1,13 +1,12 @@ import os import requests -from dotenv import load_dotenv - -load_dotenv() - -TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] -TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"] +from core.settings.base import ( + load_dotenv, + TELEGRAM_BOT_TOKEN, + TELEGRAM_CHAT_ID, +) def send_telegram_message(text): From 95a06a7fa8aa9b53edf2d502cbf752c805de9064 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 15:55:37 +0300 Subject: [PATCH 165/177] refactor: remove notifications URL from routing --- core/urls.py | 1 - 1 file changed, 1 deletion(-) diff --git a/core/urls.py b/core/urls.py index fc9db60..0b42b92 100644 --- a/core/urls.py +++ b/core/urls.py @@ -43,5 +43,4 @@ path("books/", include("books.urls")), path("borrowings/", include("borrowings.urls")), path("payments/", include("payments.urls")), - path("notifications/", include("notifications.urls")), ] From c54f4a77fc0edbe4541d8f8806667fe8962d28e2 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 16:15:43 +0300 Subject: [PATCH 166/177] refactor: clean up imports and remove unnecessary whitespace --- books/admin.py | 1 + payments/admin.py | 1 + users/urls.py | 1 + 3 files changed, 3 insertions(+) diff --git a/books/admin.py b/books/admin.py index 7266287..f7a9a77 100644 --- a/books/admin.py +++ b/books/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from books.models import Book diff --git a/payments/admin.py b/payments/admin.py index 6e366f7..26fddeb 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from payments.models import Payment """ diff --git a/users/urls.py b/users/urls.py index 89c38c1..dd4b5ad 100644 --- a/users/urls.py +++ b/users/urls.py @@ -5,6 +5,7 @@ TokenRefreshView, TokenVerifyView, ) + from users.views import CreateUserViewSet, ManageUserViewSet From 8b9e3625bc4b79849fa4ee644c140a569c634fd0 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 16:15:52 +0300 Subject: [PATCH 167/177] refactor: add healthchecks for db, app, and redis services in docker-compose --- docker-compose.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index be01d6f..b3d5b94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,13 @@ services: ports: - "5432:5432" restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"] + interval: 10s + timeout: 5s + retries: 5 + env_file: + - .env app: build: . @@ -29,11 +36,21 @@ services: - 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: . From 8200938796c87e46f5a6559ee3d0918baf63748f Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 16:59:42 +0300 Subject: [PATCH 168/177] refactor: add type hints and clean up code across multiple files --- books/views.py | 1 + borrowings/serializers.py | 9 ++++++--- borrowings/views.py | 18 +++++++++++------- core/permissions.py | 4 +++- notifications/tasks.py | 8 ++++---- notifications/telegram_helper.py | 7 ++++--- payments/admin.py | 14 -------------- payments/serializers.py | 10 ++++++---- payments/stripe_helper.py | 13 ++++++++++--- payments/views.py | 21 +++++++++++++-------- users/models.py | 14 +++++++++++--- users/serializers.py | 14 ++++++++------ 12 files changed, 77 insertions(+), 56 deletions(-) diff --git a/books/views.py b/books/views.py index a4c227f..11091f7 100644 --- a/books/views.py +++ b/books/views.py @@ -1,6 +1,7 @@ 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 diff --git a/borrowings/serializers.py b/borrowings/serializers.py index 9cfe109..8afc924 100644 --- a/borrowings/serializers.py +++ b/borrowings/serializers.py @@ -1,7 +1,10 @@ +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 @@ -65,7 +68,7 @@ class Meta: fields = ("expected_return_date", "book") @transaction.atomic - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> Borrowing: book = validated_data.pop("book") book.inventory -= 1 book.save() @@ -73,12 +76,12 @@ def create(self, validated_data): notify_new_borrowing.delay(borrowing.id) return borrowing - def validate_book(self, value): + def validate_book(self, value: Book) -> Book: if value.inventory <= 0: raise serializers.ValidationError("Not enough books") return value - def validate(self, attrs): + 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(), diff --git a/borrowings/views.py b/borrowings/views.py index a6d4e97..d11c571 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -1,13 +1,15 @@ 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 django.utils import timezone -from django.db import transaction from borrowings.models import Borrowing from borrowings.serializers import ( @@ -62,7 +64,7 @@ class BorrowingViewSet( filter_backends = [DjangoFilterBackend] filterset_fields = ["user", "book", "borrow_date", "actual_return_date"] - def get_serializer_class(self): + def get_serializer_class(self) -> type: if self.action == "list": return BorrowingListSerializer if self.action == "retrieve": @@ -71,7 +73,7 @@ def get_serializer_class(self): return BorrowingCreateSerializer return BorrowingSerializer - def perform_create(self, serializer): + def perform_create(self, serializer: BorrowingCreateSerializer) -> None: borrowing = serializer.save(user=self.request.user) session_data = create_stripe_session( @@ -89,7 +91,7 @@ def perform_create(self, serializer): status="PENDING", ) - def get_queryset(self): + def get_queryset(self) -> Any: queryset = super().get_queryset() user = self.request.user if not user.is_staff: @@ -102,7 +104,7 @@ def get_queryset(self): url_path="return", permission_classes=[IsAuthenticated], ) - def return_book(self, request, pk=None): + 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: @@ -179,7 +181,9 @@ def return_book(self, request, pk=None): serializer = self.get_serializer(borrowing) return Response(serializer.data, status=status.HTTP_200_OK) - def calculate_fine(self, borrowing, actual_return_date): + def calculate_fine( + self, borrowing: Borrowing, actual_return_date: Any + ) -> Decimal: days_overdue = ( actual_return_date - borrowing.expected_return_date ).days diff --git a/core/permissions.py b/core/permissions.py index 0f86d81..efb5749 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -1,8 +1,10 @@ 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, view): + def has_permission(self, request: Request, view: APIView) -> bool: return bool( request.user and request.user.is_authenticated diff --git a/notifications/tasks.py b/notifications/tasks.py index dfd7bbb..6395909 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -1,12 +1,12 @@ from celery import shared_task from django.utils import timezone -from notifications.telegram_helper import send_telegram_message from borrowings.models import Borrowing +from notifications.telegram_helper import send_telegram_message @shared_task -def notify_new_borrowing(borrowing_id): +def notify_new_borrowing(borrowing_id: int) -> None: try: borrowing = Borrowing.objects.select_related("book", "user").get( id=borrowing_id @@ -25,7 +25,7 @@ def notify_new_borrowing(borrowing_id): @shared_task -def notify_successful_payment(payment_id): +def notify_successful_payment(payment_id: int) -> None: try: from payments.models import Payment @@ -50,7 +50,7 @@ def notify_successful_payment(payment_id): @shared_task -def check_overdue_borrowings(): +def check_overdue_borrowings() -> None: today = timezone.now().date() overdues = Borrowing.objects.filter( expected_return_date__lte=today, actual_return_date__isnull=True diff --git a/notifications/telegram_helper.py b/notifications/telegram_helper.py index 4e19214..49acc09 100644 --- a/notifications/telegram_helper.py +++ b/notifications/telegram_helper.py @@ -1,5 +1,3 @@ -import os - import requests from core.settings.base import ( @@ -9,7 +7,10 @@ ) -def send_telegram_message(text): +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" diff --git a/payments/admin.py b/payments/admin.py index 26fddeb..1b1516b 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -2,20 +2,6 @@ from payments.models import Payment -""" -Use this to register Payment model in admin panel -after Borrowing model implementation -""" -# @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",) - @admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): diff --git a/payments/serializers.py b/payments/serializers.py index 568c7e0..6a2179d 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -1,8 +1,10 @@ +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 -from borrowings.models import Borrowing class PaymentSerializer(serializers.ModelSerializer): @@ -23,17 +25,17 @@ class Meta: ] read_only_fields = ["id", "session_url", "session_id"] - def validate_status(self, value): + def validate_status(self, value: str) -> str: if value not in dict(PaymentStatus.choices): raise serializers.ValidationError("Invalid status") return value - def validate_payment_type(self, value): + def validate_payment_type(self, value: str) -> str: if value not in dict(PaymentType.choices): raise serializers.ValidationError("Invalid payment_type") return value - def validate_money_to_pay(self, value): + def validate_money_to_pay(self, value: Decimal) -> Decimal: if value is None: raise serializers.ValidationError("money_to_pay is required") if value < 0: diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py index 5238a40..062c66d 100644 --- a/payments/stripe_helper.py +++ b/payments/stripe_helper.py @@ -1,16 +1,23 @@ +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 decimal import Decimal +from borrowings.models import Borrowing from payments.models import PaymentType stripe.api_key = settings.STRIPE_SECRET_KEY def create_stripe_session( - borrowing, payment_type=PaymentType.PAYMENT, request=None, fine_amount=None -): + 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. diff --git a/payments/views.py b/payments/views.py index ffa196d..3b86208 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,11 +1,14 @@ +from typing import Any + import stripe from django.conf import settings from django.db import transaction -from django.http import HttpResponse +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 @@ -48,14 +51,14 @@ class PaymentViewSet( queryset = Payment.objects.select_related("borrowing") permission_classes = (IsAuthenticated,) - def get_serializer_class(self): + def get_serializer_class(self) -> type: if self.action == "list": return PaymentListSerializer if self.action == "retrieve": return PaymentDetailSerializer return PaymentSerializer - def get_queryset(self): + def get_queryset(self) -> Any: queryset = Payment.objects.select_related("borrowing") user = self.request.user if not user.is_staff: @@ -66,7 +69,9 @@ def get_queryset(self): class PaymentSuccessView(APIView): """Handle successful payment callback from Stripe""" - def _update_payment_status(self, session_id, is_test=False): + def _update_payment_status( + self, session_id: str, is_test: bool = False + ) -> Response: """Common logic for updating payment status""" try: with transaction.atomic(): @@ -97,7 +102,7 @@ def _update_payment_status(self, session_id, is_test=False): status=status.HTTP_404_NOT_FOUND, ) - def get(self, request): + def get(self, request: Request) -> Response: session_id = request.GET.get("session_id") if not session_id: @@ -133,7 +138,7 @@ def get(self, request): class PaymentCancelView(APIView): """Handle cancelled payment from Stripe""" - def get(self, request): + def get(self, request: Request) -> Response: return Response( { "message": "Payment was cancelled." @@ -149,7 +154,7 @@ class PaymentTestSuccessView(APIView): permission_classes = (IsAuthenticated,) - def post(self, request): + def post(self, request: Request) -> Response: session_id = request.data.get("session_id") if not session_id: @@ -166,7 +171,7 @@ def post(self, request): class StripeWebhookView(APIView): """Handle Stripe webhook events""" - def post(self, request): + 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) diff --git a/users/models.py b/users/models.py index 6b3a616..27e8766 100644 --- a/users/models.py +++ b/users/models.py @@ -1,3 +1,5 @@ +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 _ @@ -8,7 +10,9 @@ class UserManager(BaseUserManager): use_in_migrations = True - def _create_user(self, email, password, **extra_fields): + 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") @@ -18,13 +22,17 @@ def _create_user(self, email, password, **extra_fields): user.save(using=self._db) return user - def create_user(self, email, password=None, **extra_fields): + 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, 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) diff --git a/users/serializers.py b/users/serializers.py index c0124d1..1b9b003 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib.auth import get_user_model from rest_framework import serializers @@ -16,10 +18,10 @@ class Meta: model = User fields = ("email", "password") - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> User: return User.objects.create_user(**validated_data) - def validate_email(self, value): + def validate_email(self, value: str) -> str: if User.objects.filter(email=value).exists(): raise serializers.ValidationError( "User with this email already exists." @@ -51,7 +53,7 @@ class Meta: fields = ("id", "email", "first_name", "last_name", "password") read_only_fields = ("id",) - def update(self, instance, validated_data): + 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: @@ -59,7 +61,7 @@ def update(self, instance, validated_data): user.save() return user - def validate_email(self, value): + def validate_email(self, value: str) -> str: user = self.instance if User.objects.exclude(pk=user.pk).filter(email=value).exists(): raise serializers.ValidationError( @@ -67,7 +69,7 @@ def validate_email(self, value): ) return value - def validate_first_name(self, 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: @@ -76,7 +78,7 @@ def validate_first_name(self, value): ) return value - def validate_last_name(self, 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: From 7aa6c375771f814c3c931553676f5cf4fec3a4d4 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 17:07:52 +0300 Subject: [PATCH 169/177] refactor: defer notification tasks until after database transactions --- borrowings/serializers.py | 4 +++- notifications/tasks.py | 3 +-- payments/views.py | 13 +++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/borrowings/serializers.py b/borrowings/serializers.py index 8afc924..fec3d4b 100644 --- a/borrowings/serializers.py +++ b/borrowings/serializers.py @@ -73,7 +73,9 @@ def create(self, validated_data: dict[str, Any]) -> Borrowing: book.inventory -= 1 book.save() borrowing = Borrowing.objects.create(book=book, **validated_data) - notify_new_borrowing.delay(borrowing.id) + transaction.on_commit( + lambda: notify_new_borrowing.delay(borrowing.id) + ) return borrowing def validate_book(self, value: Book) -> Book: diff --git a/notifications/tasks.py b/notifications/tasks.py index 6395909..4c4d4fb 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -2,6 +2,7 @@ from django.utils import timezone from borrowings.models import Borrowing +from payments.models import Payment from notifications.telegram_helper import send_telegram_message @@ -27,8 +28,6 @@ def notify_new_borrowing(borrowing_id: int) -> None: @shared_task def notify_successful_payment(payment_id: int) -> None: try: - from payments.models import Payment - payment = Payment.objects.select_related( "borrowing__book", "borrowing__user" ).get(id=payment_id) diff --git a/payments/views.py b/payments/views.py index 3b86208..046f3b1 100644 --- a/payments/views.py +++ b/payments/views.py @@ -81,7 +81,9 @@ def _update_payment_status( payment.status = "PAID" payment.save() - notify_successful_payment.delay(payment.id) + transaction.on_commit( + lambda: notify_successful_payment.delay(payment.id) + ) message = ( "Payment status updated to PAID (TEST MODE)" @@ -101,6 +103,11 @@ def _update_payment_status( {"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") @@ -205,7 +212,9 @@ def post(self, request: HttpRequest) -> HttpResponse: if session["payment_status"] == "paid": payment.status = "PAID" payment.save() - notify_successful_payment.delay(payment.id) + transaction.on_commit( + lambda: notify_successful_payment.delay(payment.id) + ) except Payment.DoesNotExist: pass From 4fdf41fbf4db7119303048de2c1d1ce8c3251dba Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 17:09:44 +0300 Subject: [PATCH 170/177] fix: black --- borrowings/serializers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/borrowings/serializers.py b/borrowings/serializers.py index fec3d4b..9c8f0c5 100644 --- a/borrowings/serializers.py +++ b/borrowings/serializers.py @@ -73,9 +73,7 @@ def create(self, validated_data: dict[str, Any]) -> Borrowing: book.inventory -= 1 book.save() borrowing = Borrowing.objects.create(book=book, **validated_data) - transaction.on_commit( - lambda: notify_new_borrowing.delay(borrowing.id) - ) + transaction.on_commit(lambda: notify_new_borrowing.delay(borrowing.id)) return borrowing def validate_book(self, value: Book) -> Book: From c23661c02b8a577a9b503450060441ff40439a76 Mon Sep 17 00:00:00 2001 From: Arthur Oleinikov Date: Thu, 25 Sep 2025 17:13:57 +0300 Subject: [PATCH 171/177] refactor: replace APITestCase with TransactionTestCase --- borrowings/tests/test_views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/borrowings/tests/test_views.py b/borrowings/tests/test_views.py index 010054e..7fce9ba 100644 --- a/borrowings/tests/test_views.py +++ b/borrowings/tests/test_views.py @@ -1,5 +1,7 @@ from django.urls import reverse -from rest_framework.test import APITestCase +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 @@ -11,11 +13,12 @@ User = get_user_model() -class BorrowingViewSetTest(APITestCase): +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", @@ -57,4 +60,5 @@ def test_create_borrowing(self, mock_notify_task): 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() From d8803b771965b8cc28c3e81dfea1ed6e8d503fea Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 17:25:55 +0300 Subject: [PATCH 172/177] refactor: add `basename` to router registration and remove unused `queryset` in PaymentsView --- payments/urls.py | 2 +- payments/views.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 369c775..2faaef9 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -12,7 +12,7 @@ app_name = "payments" router = DefaultRouter() -router.register("", PaymentViewSet) +router.register("", PaymentViewSet, basename="payments") urlpatterns = [ path("success/", views.PaymentSuccessView.as_view(), name="success"), path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), diff --git a/payments/views.py b/payments/views.py index 046f3b1..7dc1497 100644 --- a/payments/views.py +++ b/payments/views.py @@ -48,7 +48,6 @@ class PaymentViewSet( viewsets.GenericViewSet, ): - queryset = Payment.objects.select_related("borrowing") permission_classes = (IsAuthenticated,) def get_serializer_class(self) -> type: From 7da07042ea71b38c14339bcbd8a408985c1e8c6a Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 19:22:38 +0300 Subject: [PATCH 173/177] refactor: update healthcheck command in docker-compose, add `basename` to BorrowingViewSet, and remove unused `queryset` in BorrowingsView --- borrowings/urls.py | 2 +- borrowings/views.py | 5 ----- docker-compose.yml | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/borrowings/urls.py b/borrowings/urls.py index d47cf8d..5e6eb6a 100644 --- a/borrowings/urls.py +++ b/borrowings/urls.py @@ -6,7 +6,7 @@ app_name = "borrowings" router = routers.DefaultRouter() -router.register("", BorrowingViewSet) +router.register("", BorrowingViewSet, basename="borrowings") urlpatterns = [ path("", include(router.urls)), diff --git a/borrowings/views.py b/borrowings/views.py index d11c571..bcd2504 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -55,11 +55,6 @@ class BorrowingViewSet( mixins.CreateModelMixin, viewsets.GenericViewSet, ): - queryset = ( - Borrowing.objects.all() - .select_related("book", "user") - .order_by("-borrow_date") - ) permission_classes = (IsAuthenticated,) filter_backends = [DjangoFilterBackend] filterset_fields = ["user", "book", "borrow_date", "actual_return_date"] diff --git a/docker-compose.yml b/docker-compose.yml index b3d5b94..e8113f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - "5432:5432" restart: always healthcheck: - test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 From 3c025a7b351cd8340b491c6ff3be2cfc34b1bba3 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 20:42:32 +0300 Subject: [PATCH 174/177] refactor: remove unused validation methods and tests in PaymentSerializer, add ordering to Borrowing model, improve BorrowingsView queryset, and update router basenames --- borrowings/models.py | 1 + borrowings/urls.py | 2 +- borrowings/views.py | 2 +- payments/serializers.py | 19 ------------------- payments/tests/test_endpoints.py | 12 ------------ payments/tests/test_serializers.py | 10 ---------- payments/urls.py | 2 +- 7 files changed, 4 insertions(+), 44 deletions(-) diff --git a/borrowings/models.py b/borrowings/models.py index 708beb7..8193a9c 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -46,6 +46,7 @@ def validate_actual_return_date( class Meta: db_table = "borrowings" + ordering = ["-borrow_date"] constraints = [ models.CheckConstraint( check=models.Q( diff --git a/borrowings/urls.py b/borrowings/urls.py index 5e6eb6a..177c922 100644 --- a/borrowings/urls.py +++ b/borrowings/urls.py @@ -6,7 +6,7 @@ app_name = "borrowings" router = routers.DefaultRouter() -router.register("", BorrowingViewSet, basename="borrowings") +router.register("", BorrowingViewSet, basename="borrowing") urlpatterns = [ path("", include(router.urls)), diff --git a/borrowings/views.py b/borrowings/views.py index bcd2504..0c0706d 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -87,7 +87,7 @@ def perform_create(self, serializer: BorrowingCreateSerializer) -> None: ) def get_queryset(self) -> Any: - queryset = super().get_queryset() + queryset = Borrowing.objects.all().select_related("book", "user") user = self.request.user if not user.is_staff: queryset = queryset.filter(user=user) diff --git a/payments/serializers.py b/payments/serializers.py index 6a2179d..64962d5 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -25,25 +25,6 @@ class Meta: ] read_only_fields = ["id", "session_url", "session_id"] - def validate_status(self, value: str) -> str: - if value not in dict(PaymentStatus.choices): - raise serializers.ValidationError("Invalid status") - return value - - def validate_payment_type(self, value: str) -> str: - if value not in dict(PaymentType.choices): - raise serializers.ValidationError("Invalid payment_type") - return value - - def validate_money_to_pay(self, value: Decimal) -> Decimal: - if value is None: - raise serializers.ValidationError("money_to_pay is required") - if value < 0: - raise serializers.ValidationError( - "money_to_pay must be non-negative" - ) - return value - class PaymentListSerializer(serializers.ModelSerializer): borrowing = serializers.SlugRelatedField(read_only=True, slug_field="id") diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index c5d3251..4b3c6e2 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -70,15 +70,3 @@ def test_create_invalid_payment_type_returns_400(self): 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) - - def test_create_negative_amount_returns_400(self): - self.client.force_authenticate(user=self.user) - borrowing = self._create_borrowing() - payload = { - "payment_type": PaymentType.PAYMENT, - "borrowing": borrowing.id, - "money_to_pay": "-0.01", - } - resp = self.client.post(self.list_url, data=payload, format="json") - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("money_to_pay", resp.data) diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py index ad918a1..f692bd0 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -77,16 +77,6 @@ def test_invalid_payment_type(self): self.assertFalse(serializer.is_valid()) self.assertIn("payment_type", serializer.errors) - def test_negative_amount(self): - data = { - "payment_type": PaymentType.PAYMENT, - "borrowing_id": 1, - "money_to_pay": "-0.01", - } - serializer = PaymentSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn("money_to_pay", serializer.errors) - def test_missing_money_to_pay(self): data = { "payment_type": PaymentType.PAYMENT, diff --git a/payments/urls.py b/payments/urls.py index 2faaef9..e72568c 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -12,7 +12,7 @@ app_name = "payments" router = DefaultRouter() -router.register("", PaymentViewSet, basename="payments") +router.register("", PaymentViewSet, basename="payment") urlpatterns = [ path("success/", views.PaymentSuccessView.as_view(), name="success"), path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), From 1eb41dfcaea7a98bff21d00b7c51eb2ac671a3f1 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 22:01:33 +0300 Subject: [PATCH 175/177] test: add Stripe webhook and PaymentTestSuccessView tests for payment handling --- payments/tests/test_stripe.py | 175 ++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 payments/tests/test_stripe.py diff --git a/payments/tests/test_stripe.py b/payments/tests/test_stripe.py new file mode 100644 index 0000000..7fe9747 --- /dev/null +++ b/payments/tests/test_stripe.py @@ -0,0 +1,175 @@ +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") \ No newline at end of file From 2f35830cf2f8a5a66573f8d09bec597bad39328f Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 22:03:11 +0300 Subject: [PATCH 176/177] test: standardize string quotes and formatting in Stripe tests for consistency --- payments/tests/test_stripe.py | 91 ++++++++++++++++------------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/payments/tests/test_stripe.py b/payments/tests/test_stripe.py index 7fe9747..b75feea 100644 --- a/payments/tests/test_stripe.py +++ b/payments/tests/test_stripe.py @@ -47,9 +47,11 @@ def setUp(self): 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): + @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", @@ -60,23 +62,23 @@ def test_stripe_webhook_successful_payment(self, mock_on_commit, mock_notify): "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" + 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) @@ -89,87 +91,76 @@ def test_stripe_webhook_payment_not_found(self): "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" + 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()) + @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" - } - + + payload = {"session_id": "cs_test_session_id_123"} + response = self.client.post( - self.test_success_url, - data=payload, - format="json" + 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.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" - } - + payload = {"session_id": "cs_test_session_id_123"} + response = self.client.post( - self.test_success_url, - data=payload, - format="json" + 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.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" - } - + + payload = {"session_id": "cs_nonexistent_session_id"} + response = self.client.post( - self.test_success_url, - data=payload, - format="json" + 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") \ No newline at end of file + self.assertEqual(response.data["error"], "Payment not found") From dd24c042d65dbad13c8cd9eef1c7c2a396128b73 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 22:09:25 +0300 Subject: [PATCH 177/177] refactor: replace `check` with `condition` in CheckConstraint for clarity and consistency in borrowing and payment models --- borrowings/models.py | 4 ++-- payments/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/borrowings/models.py b/borrowings/models.py index 8193a9c..c9c5ce0 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -49,13 +49,13 @@ class Meta: ordering = ["-borrow_date"] constraints = [ models.CheckConstraint( - check=models.Q( + condition=models.Q( expected_return_date__gte=models.F("borrow_date") ), name="expected_after_borrow", ), models.CheckConstraint( - check=( + condition=( models.Q(actual_return_date__isnull=True) | models.Q(actual_return_date__gte=models.F("borrow_date")) ), diff --git a/payments/models.py b/payments/models.py index de565d6..a09039f 100644 --- a/payments/models.py +++ b/payments/models.py @@ -55,7 +55,7 @@ class Meta: db_table = "payment" constraints = [ models.CheckConstraint( - check=Q(money_to_pay__gte=0), + condition=Q(money_to_pay__gte=0), name="money_to_pay_non_negative", ), models.UniqueConstraint(