diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ae2156 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +*.pyc +__pycache__ +*.pyo +*.pyd +.Python +env/ +venv/ +ENV/ +.env +.venv +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.gitignore +.mypy_cache +.pytest_cache +.hypothesis +db.sqlite3 +*.sqlite3 +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..215dfa2 --- /dev/null +++ b/.env.sample @@ -0,0 +1,29 @@ +# Django Configuration +SECRET_KEY=your-super-secret-key-change-this-in-production +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database Configuration - PostgreSQL (for Docker) +DB_HOST=db +DB_NAME=library_db +DB_USER=library_user +DB_PASSWORD=library_password +DB_PORT=5432 + +# For local development with SQLite (set to True) +USE_SQLITE=False + +# Stripe Configuration +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here +STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key_here + +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz +TELEGRAM_CHAT_ID=-1001234567890 + +# Redis Configuration (for Django-Q) +REDIS_HOST=redis +REDIS_PORT=6379 + +# Fine Multiplier (optional - default is 2) +# FINE_MULTIPLIER=2 diff --git a/.gitignore b/.gitignore index 7b8521d..d953e25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,27 @@ -.idea -venv \ No newline at end of file +# Python +venv/ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Django +*.log +db.sqlite3 +db.sqlite3-journal +/staticfiles/ +/media/ + +# Environment variables +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..846c965 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt /app/ +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Copy project +COPY . /app/ + +# Create directory for static files +RUN mkdir -p /app/staticfiles + +# Collect static files +RUN python manage.py collectstatic --noinput || true + +# Run migrations and start server +CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"] diff --git a/README.md b/README.md index 204fa44..3c4a73c 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ -# library-service +# Library Service API + +A full-stack Django REST API for managing a library system: book inventory, borrowings, users, payments (Stripe), and Telegram notifications. + +## 📋 Project Description + +This project modernizes a traditional library by implementing an online management system for book borrowings. The system optimizes library administrators' work and makes the service more user-friendly. + +**Problem solved:** +- Manual paper-based tracking of books, borrowings, and payments +- No real-time inventory management +- Cash-only payments +- No automated overdue notifications + +**Solution:** +- Web-based REST API for all library operations +- Automated Stripe payment processing +- Real-time Telegram notifications +- JWT-based authentication +- Scheduled daily overdue checks + +--- + +## ✨ Features + +### Books Management +- ✅ Full CRUD operations (admin only) +- ✅ Public book listing and search +- ✅ Inventory tracking +- ✅ Cover type (HARD/SOFT) support + +### User Management +- ✅ Custom user model with email authentication +- ✅ JWT token-based authentication +- ✅ User registration and profile management +- ✅ Admin/staff role permissions + +### Borrowing System +- ✅ Create borrowings with automatic inventory updates +- ✅ Filter by user and active/returned status +- ✅ Return functionality with fine calculation +- ✅ Automatic payment creation on borrowing + +### Payment Processing +- ✅ Stripe payment integration +- ✅ Automatic payment session creation +- ✅ Payment success/cancel webhooks +- ✅ Fine calculation for overdue returns +- ✅ Payment status tracking (PENDING/PAID) + +### Notifications +- ✅ Telegram bot integration +- ✅ New borrowing notifications +- ✅ Daily overdue check notifications +- ✅ Successful payment notifications + +### Background Tasks +- ✅ Django-Q integration for async tasks +- ✅ Scheduled daily overdue checks +- ✅ Redis-backed task queue + +--- + +## 🏗️ Architecture + +The system follows a microservices-inspired architecture with the following components: + +- **Books Service**: Manage book catalog and inventory +- **Users Service**: Handle authentication and user profiles +- **Borrowings Service**: Manage borrowing operations +- **Payments Service**: Process payments via Stripe +- **Notifications Service**: Send Telegram notifications +- **Background Tasks**: Django-Q cluster for scheduled tasks + +All services communicate via REST API endpoints documented in Swagger. + +--- + +## 🚀 Quick Start + +### Prerequisites + +- Python 3.13+ +- PostgreSQL 15+ (or SQLite for development) +- Redis 7+ +- Docker & Docker Compose (optional) + +### Local Development Setup + +1. **Clone the repository** 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..5f72ff6 --- /dev/null +++ b/books/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from books.models import Book + + +@admin.register(Book) +class BookAdmin(admin.ModelAdmin): + """Admin configuration for Book model.""" + + list_display = ["title", "author", "cover", "inventory", "daily_fee"] + list_filter = ["cover"] + search_fields = ["title", "author"] diff --git a/books/apps.py b/books/apps.py new file mode 100644 index 0000000..ca1a219 --- /dev/null +++ b/books/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BooksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "books" diff --git a/books/migrations/0001_initial.py b/books/migrations/0001_initial.py new file mode 100644 index 0000000..4444b3b --- /dev/null +++ b/books/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.7 on 2025-11-05 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Book", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("author", models.CharField(max_length=255)), + ( + "cover", + models.CharField( + choices=[("HARD", "Hard cover"), ("SOFT", "Soft cover")], + default="SOFT", + max_length=4, + ), + ), + ("inventory", models.PositiveIntegerField()), + ("daily_fee", models.DecimalField(decimal_places=2, max_digits=6)), + ], + options={ + "ordering": ["title"], + }, + ), + ] 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..9b8f0d8 --- /dev/null +++ b/books/models.py @@ -0,0 +1,29 @@ +from django.db import models + + +class Book(models.Model): + """Model representing a book in the library.""" + + COVER_HARD = "HARD" + COVER_SOFT = "SOFT" + + COVER_CHOICES = [ + (COVER_HARD, "Hard cover"), + (COVER_SOFT, "Soft cover"), + ] + + title = models.CharField(max_length=255) + author = models.CharField(max_length=255) + cover = models.CharField( + max_length=4, + choices=COVER_CHOICES, + default=COVER_SOFT + ) + inventory = models.PositiveIntegerField() + daily_fee = models.DecimalField(max_digits=6, decimal_places=2) + + class Meta: + ordering = ["title"] + + def __str__(self): + return f"{self.title} by {self.author}" diff --git a/books/serializers.py b/books/serializers.py new file mode 100644 index 0000000..23352e3 --- /dev/null +++ b/books/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from books.models import Book + + +class BookSerializer(serializers.ModelSerializer): + """Serializer for Book model.""" + + class Meta: + model = Book + fields = ["id", "title", "author", "cover", "inventory", "daily_fee"] + read_only_fields = ["id"] diff --git a/books/tests.py b/books/tests.py new file mode 100644 index 0000000..5ec3089 --- /dev/null +++ b/books/tests.py @@ -0,0 +1,63 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from books.models import Book + + +class BookTests(TestCase): + """Tests for Book model and API.""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + email="user@example.com", password="testpass123" + ) + self.admin = get_user_model().objects.create_superuser( + email="admin@example.com", password="adminpass123" + ) + self.book_data = { + "title": "Test Book", + "author": "Test Author", + "cover": "SOFT", + "inventory": 10, + "daily_fee": "1.50", + } + self.book = Book.objects.create(**self.book_data) + + def test_list_books_unauthenticated(self): + """Test listing books without authentication.""" + url = reverse("books:book-list") + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_book_as_admin(self): + """Test creating book as admin.""" + self.client.force_authenticate(user=self.admin) + url = reverse("books:book-list") + response = self.client.post(url, self.book_data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["title"], self.book_data["title"]) + + def test_create_book_as_user_forbidden(self): + """Test regular user cannot create books.""" + self.client.force_authenticate(user=self.user) + url = reverse("books:book-list") + response = self.client.post(url, self.book_data) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_book_as_admin(self): + """Test updating book as admin.""" + self.client.force_authenticate(user=self.admin) + url = reverse("books:book-detail", args=[self.book.id]) + updated_data = {"title": "Updated Title"} + response = self.client.patch(url, updated_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.book.refresh_from_db() + self.assertEqual(self.book.title, "Updated Title") diff --git a/books/urls.py b/books/urls.py new file mode 100644 index 0000000..49faf14 --- /dev/null +++ b/books/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from books.views import BookViewSet + + +router = DefaultRouter() +router.register("", BookViewSet, basename="book") + +urlpatterns = [ + path("", include(router.urls)), +] + +app_name = "books" diff --git a/books/views.py b/books/views.py new file mode 100644 index 0000000..71e9816 --- /dev/null +++ b/books/views.py @@ -0,0 +1,21 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser, IsAuthenticatedOrReadOnly + +from books.models import Book +from books.serializers import BookSerializer + + +class BookViewSet(viewsets.ModelViewSet): + """ViewSet for managing books.""" + + queryset = Book.objects.all() + serializer_class = BookSerializer + + def get_permissions(self): + """ + Admin users can create/update/delete books. + All users (including unauthenticated) can list/retrieve books. + """ + if self.action in ["create", "update", "partial_update", "destroy"]: + return [IsAdminUser()] + return [IsAuthenticatedOrReadOnly()] diff --git a/booksurls.py b/booksurls.py new file mode 100644 index 0000000..e69de29 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..5edc2a3 --- /dev/null +++ b/borrowings/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from borrowings.models import Borrowing + + +@admin.register(Borrowing) +class BorrowingAdmin(admin.ModelAdmin): + """Admin configuration for Borrowing model.""" + + list_display = [ + "book", + "user", + "borrow_date", + "expected_return_date", + "actual_return_date", + ] + list_filter = ["borrow_date", "actual_return_date"] + search_fields = ["book__title", "user__email"] diff --git a/borrowings/apps.py b/borrowings/apps.py new file mode 100644 index 0000000..9ab9f08 --- /dev/null +++ b/borrowings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BorrowingsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "borrowings" diff --git a/borrowings/migrations/0001_initial.py b/borrowings/migrations/0001_initial.py new file mode 100644 index 0000000..e6ec965 --- /dev/null +++ b/borrowings/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.7 on 2025-11-05 11:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +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, to="books.book" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-borrow_date"], + }, + ), + ] 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..41f35df --- /dev/null +++ b/borrowings/models.py @@ -0,0 +1,20 @@ +from django.conf import settings +from django.db import models + +from books.models import Book + + +class Borrowing(models.Model): + """Model representing a book borrowing.""" + + borrow_date = models.DateField(auto_now_add=True) + expected_return_date = models.DateField() + actual_return_date = models.DateField(null=True, blank=True) + book = models.ForeignKey(Book, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + class Meta: + ordering = ["-borrow_date"] + + def __str__(self): + return f"{self.book.title} borrowed by {self.user.email}" diff --git a/borrowings/serializers.py b/borrowings/serializers.py new file mode 100644 index 0000000..1dfa576 --- /dev/null +++ b/borrowings/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers + +from books.serializers import BookSerializer +from borrowings.models import Borrowing + + +class BorrowingSerializer(serializers.ModelSerializer): + """Serializer for Borrowing model.""" + + class Meta: + model = Borrowing + fields = [ + "id", + "borrow_date", + "expected_return_date", + "actual_return_date", + "book", + "user", + ] + read_only_fields = ["id", "borrow_date", "user"] + + +class BorrowingDetailSerializer(BorrowingSerializer): + """Detailed serializer with nested book info and payments.""" + + book = BookSerializer(read_only=True) + payments = serializers.SerializerMethodField() + + class Meta(BorrowingSerializer.Meta): + fields = BorrowingSerializer.Meta.fields + ["payments"] + + def get_payments(self, obj): + """Return all payments for this borrowing.""" + from payments.serializers import PaymentSerializer + + payments = obj.payments.all() + return PaymentSerializer(payments, many=True).data + + +class BorrowingCreateSerializer(serializers.ModelSerializer): + """Serializer for creating borrowings.""" + + class Meta: + model = Borrowing + fields = ["id", "expected_return_date", "book"] + read_only_fields = ["id"] + + def validate_book(self, value): + """Validate that book has available inventory.""" + if value.inventory < 1: + raise serializers.ValidationError( + "This book is currently out of stock." + ) + return value diff --git a/borrowings/tests.py b/borrowings/tests.py new file mode 100644 index 0000000..74937f6 --- /dev/null +++ b/borrowings/tests.py @@ -0,0 +1,129 @@ +from datetime import date, timedelta +from decimal import Decimal +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from books.models import Book +from borrowings.models import Borrowing + + +class BorrowingTests(TestCase): + """Tests for Borrowing model and API.""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + email="user@example.com", password="testpass123" + ) + self.admin = get_user_model().objects.create_superuser( + email="admin@example.com", password="adminpass123" + ) + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="SOFT", + inventory=5, + daily_fee=Decimal("2.00"), + ) + + @patch("payments.stripe_helper.create_stripe_session") + @patch("notifications.telegram_helper.notify_new_borrowing") + def test_create_borrowing(self, mock_notify, mock_stripe): + """Test creating a borrowing.""" + self.client.force_authenticate(user=self.user) + url = reverse("borrowings:borrowing-list") + data = { + "book": self.book.id, + "expected_return_date": (date.today() + timedelta(days=7)).isoformat(), + } + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.book.refresh_from_db() + self.assertEqual(self.book.inventory, 4) + mock_stripe.assert_called_once() + mock_notify.assert_called_once() + + def test_list_borrowings_user_sees_own(self): + """Test users see only their own borrowings.""" + other_user = get_user_model().objects.create_user( + email="other@example.com", password="testpass123" + ) + Borrowing.objects.create( + book=self.book, + user=self.user, + expected_return_date=date.today() + timedelta(days=7), + ) + Borrowing.objects.create( + book=self.book, + user=other_user, + expected_return_date=date.today() + timedelta(days=7), + ) + + self.client.force_authenticate(user=self.user) + url = reverse("borrowings:borrowing-list") + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + + def test_list_borrowings_admin_sees_all(self): + """Test admin sees all borrowings.""" + Borrowing.objects.create( + book=self.book, + user=self.user, + expected_return_date=date.today() + timedelta(days=7), + ) + Borrowing.objects.create( + book=self.book, + user=self.admin, + expected_return_date=date.today() + timedelta(days=7), + ) + + self.client.force_authenticate(user=self.admin) + url = reverse("borrowings:borrowing-list") + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 2) + + @patch("payments.stripe_helper.create_fine_payment") + def test_return_book(self, mock_fine): + """Test returning a book.""" + borrowing = Borrowing.objects.create( + book=self.book, + user=self.user, + expected_return_date=date.today() + timedelta(days=7), + ) + self.book.inventory = 4 + self.book.save() + + self.client.force_authenticate(user=self.user) + url = reverse("borrowings:borrowing-return-book", args=[borrowing.id]) + response = self.client.post(url, {}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + borrowing.refresh_from_db() + self.book.refresh_from_db() + self.assertIsNotNone(borrowing.actual_return_date) + self.assertEqual(self.book.inventory, 5) + + def test_cannot_borrow_out_of_stock(self): + """Test cannot borrow when inventory is 0.""" + self.book.inventory = 0 + self.book.save() + + self.client.force_authenticate(user=self.user) + url = reverse("borrowings:borrowing-list") + data = { + "book": self.book.id, + "expected_return_date": (date.today() + timedelta(days=7)).isoformat(), + } + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/borrowings/urls.py b/borrowings/urls.py new file mode 100644 index 0000000..87e2de4 --- /dev/null +++ b/borrowings/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from borrowings.views import BorrowingViewSet + + +router = DefaultRouter() +router.register("", BorrowingViewSet, basename="borrowing") + +urlpatterns = [ + path("", include(router.urls)), +] + +app_name = "borrowings" diff --git a/borrowings/views.py b/borrowings/views.py new file mode 100644 index 0000000..4068a2e --- /dev/null +++ b/borrowings/views.py @@ -0,0 +1,104 @@ +from datetime import date + +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from borrowings.models import Borrowing +from borrowings.serializers import ( + BorrowingCreateSerializer, + BorrowingDetailSerializer, + BorrowingSerializer, +) + + +class BorrowingViewSet( + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ViewSet for managing borrowings.""" + + queryset = Borrowing.objects.select_related("book", "user") + serializer_class = BorrowingSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Filter borrowings by user (non-admin see only their own).""" + queryset = self.queryset + user = self.request.user + + if not user.is_staff: + queryset = queryset.filter(user=user) + + is_active = self.request.query_params.get("is_active") + if is_active: + queryset = queryset.filter(actual_return_date__isnull=True) + + user_id = self.request.query_params.get("user_id") + if user_id and user.is_staff: + queryset = queryset.filter(user_id=user_id) + + return queryset + + def get_serializer_class(self): + """Return appropriate serializer based on action.""" + if self.action == "retrieve": + return BorrowingDetailSerializer + if self.action == "create": + return BorrowingCreateSerializer + return BorrowingSerializer + + def perform_create(self, serializer): + """ + Create borrowing, attach user, decrease book inventory, + create Stripe payment session, send Telegram notification. + """ + from notifications.telegram_helper import notify_new_borrowing + from payments.stripe_helper import create_stripe_session + + book = serializer.validated_data["book"] + book.inventory -= 1 + book.save() + + borrowing = serializer.save(user=self.request.user) + + # Create Stripe payment session automatically + create_stripe_session(borrowing, self.request) + + # Send Telegram notification about new borrowing + notify_new_borrowing(borrowing) + + @action(detail=True, methods=["post"], url_path="return") + def return_book(self, request, pk=None): + """Return a borrowed book and create fine if overdue.""" + from payments.stripe_helper import create_fine_payment + + borrowing = self.get_object() + + if borrowing.actual_return_date: + return Response( + {"error": "Book already returned."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Set actual return date (from request or today) + actual_date = request.data.get("actual_return_date") + if actual_date: + borrowing.actual_return_date = actual_date + else: + borrowing.actual_return_date = date.today() + + # Increase book inventory + borrowing.book.inventory += 1 + borrowing.book.save() + borrowing.save() + + # Create fine payment if book is returned late + if borrowing.actual_return_date > borrowing.expected_return_date: + create_fine_payment(borrowing, request) + + serializer = self.get_serializer(borrowing) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..968882f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + db: + image: postgres:15-alpine + container_name: library-service-db + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: library_db + POSTGRES_USER: library_user + POSTGRES_PASSWORD: library_password + ports: + - "5433:5432" + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: library-service-redis + ports: + - "6379:6379" + restart: unless-stopped + + web: + build: . + container_name: library-service-web + command: sh -c "sleep 20 && python manage.py migrate && python manage.py runserver 0.0.0.0:8000" + volumes: + - .:/app + ports: + - "8000:8000" + env_file: + - .env + environment: + - DEBUG=True + - DB_HOST=db + - DB_NAME=library_db + - DB_USER=library_user + - DB_PASSWORD=library_password + - REDIS_HOST=redis + - REDIS_PORT=6379 + restart: unless-stopped + + qcluster: + build: . + container_name: library-service-qcluster + command: sh -c "sleep 30 && python manage.py qcluster" + volumes: + - .:/app + env_file: + - .env + environment: + - DEBUG=True + - DB_HOST=db + - DB_NAME=library_db + - DB_USER=library_user + - DB_PASSWORD=library_password + - REDIS_HOST=redis + - REDIS_PORT=6379 + restart: unless-stopped + +volumes: + postgres_data: diff --git a/library_service/__init__.py b/library_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/library_service/asgi.py b/library_service/asgi.py new file mode 100644 index 0000000..841ed7c --- /dev/null +++ b/library_service/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for library_service 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/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings") + +application = get_asgi_application() diff --git a/library_service/settings.py b/library_service/settings.py new file mode 100644 index 0000000..1ce3be2 --- /dev/null +++ b/library_service/settings.py @@ -0,0 +1,241 @@ +""" +Django settings for library_service project. + +Generated by 'django-admin startproject' using Django 4.2.7. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +import os +from datetime import timedelta +from pathlib import Path + +from dotenv import load_dotenv + + +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv( + "SECRET_KEY", + "django-insecure-default-key-change-me" +) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv("DEBUG", "False") == "True" + +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",") + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Third party apps + "rest_framework", + "rest_framework_simplejwt", + "drf_spectacular", + "django_q", + # Local apps + "books.apps.BooksConfig", + "users.apps.UsersConfig", + "borrowings.apps.BorrowingsConfig", + "payments.apps.PaymentsConfig", + "notifications", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "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 = "library_service.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "library_service.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +# PostgreSQL configuration for Docker +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME", "library_db"), + "USER": os.getenv("DB_USER", "library_user"), + "PASSWORD": os.getenv("DB_PASSWORD", "library_password"), + "HOST": os.getenv("DB_HOST", "localhost"), + "PORT": os.getenv("DB_PORT", "5432"), + } +} + +# Fallback to SQLite for local development +if os.getenv("USE_SQLITE", "False") == "True": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + } + +# Custom User Model +AUTH_USER_MODEL = "users.User" + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +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", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# REST Framework Configuration +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_PAGINATION_CLASS": ( + "rest_framework.pagination.PageNumberPagination" + ), + "PAGE_SIZE": 10, +} + + +# JWT Settings +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZE", +} + + +# DRF Spectacular (Swagger) Settings +SPECTACULAR_SETTINGS = { + "TITLE": "Library Service API", + "DESCRIPTION": ( + "API for managing library books, borrowings, users, and payments. " + "Supports user authentication with JWT tokens, book inventory " + "management, borrowing tracking, Stripe payment integration, " + "and Telegram notifications." + ), + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, +} + + +# Stripe Configuration +STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") +STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "") + + +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") + + +# Django-Q Configuration for async tasks +Q_CLUSTER = { + "name": "library_service", + "workers": 4, + "timeout": 90, + "retry": 120, + "queue_limit": 50, + "bulk": 10, + "orm": "default", + "redis": { + "host": os.getenv("REDIS_HOST", "localhost"), + "port": int(os.getenv("REDIS_PORT", 6379)), + "db": 0, + }, +} + + +# Fine multiplier for overdue borrowings +# Example: If daily fee is $1 and overdue by 2 days, fine = 2 * 1 * 2 = $4 +FINE_MULTIPLIER = 2 diff --git a/library_service/urls.py b/library_service/urls.py new file mode 100644 index 0000000..f82ac3f --- /dev/null +++ b/library_service/urls.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, +) + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/books/", include("books.urls")), + path("api/users/", include("users.urls")), + path("api/borrowings/", include("borrowings.urls")), + path("api/payments/", include("payments.urls")), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="docs" + ), +] diff --git a/library_service/wsgi.py b/library_service/wsgi.py new file mode 100644 index 0000000..f9442bf --- /dev/null +++ b/library_service/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for library_service 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/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..649dddf --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings") + 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/management/__init__.py b/notifications/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/management/commands/__init__.py b/notifications/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/management/commands/setup_schedule.py b/notifications/management/commands/setup_schedule.py new file mode 100644 index 0000000..20047f1 --- /dev/null +++ b/notifications/management/commands/setup_schedule.py @@ -0,0 +1,17 @@ +from django.core.management.base import BaseCommand + +from notifications.tasks import schedule_overdue_check + + +class Command(BaseCommand): + """Django command to set up scheduled tasks.""" + + help = "Set up scheduled tasks for checking overdue borrowings" + + def handle(self, *args, **options): + schedule_overdue_check() + self.stdout.write( + self.style.SUCCESS( + "Successfully scheduled overdue borrowings check" + ) + ) diff --git a/notifications/tasks.py b/notifications/tasks.py new file mode 100644 index 0000000..06da4fa --- /dev/null +++ b/notifications/tasks.py @@ -0,0 +1,35 @@ +from datetime import date + +from django_q.tasks import schedule + +from borrowings.models import Borrowing +from notifications.telegram_helper import ( + notify_overdue_borrowing, + send_telegram_message, +) + + +def check_overdue_borrowings(): + """Check for overdue borrowings and send notifications.""" + today = date.today() + + overdue_borrowings = Borrowing.objects.filter( + expected_return_date__lt=today, + actual_return_date__isnull=True + ).select_related("book", "user") + + if not overdue_borrowings.exists(): + send_telegram_message("✅ No borrowings overdue today!") + return + + for borrowing in overdue_borrowings: + notify_overdue_borrowing(borrowing) + + +def schedule_overdue_check(): + """Schedule daily task to check overdue borrowings.""" + schedule( + "notifications.tasks.check_overdue_borrowings", + schedule_type="D", # D = Daily + repeats=-1, # Repeat indefinitely + ) diff --git a/notifications/telegram_helper.py b/notifications/telegram_helper.py new file mode 100644 index 0000000..7bfcbf7 --- /dev/null +++ b/notifications/telegram_helper.py @@ -0,0 +1,72 @@ +import os + +import requests +from django.conf import settings + + +def send_telegram_message(message): + """Send a message to Telegram chat via Bot API.""" + bot_token = settings.TELEGRAM_BOT_TOKEN + chat_id = settings.TELEGRAM_CHAT_ID + + if not bot_token or not chat_id: + print("Telegram credentials not configured. Skipping notification.") + return False + + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = { + "chat_id": chat_id, + "text": message, + "parse_mode": "HTML", + } + + try: + response = requests.post(url, json=payload, timeout=10) + response.raise_for_status() + return True + except requests.RequestException as e: + print(f"Failed to send Telegram message: {e}") + return False + + +def notify_new_borrowing(borrowing): + """Send notification about new borrowing.""" + message = ( + f"📚 New Borrowing Created\n\n" + f"Book: {borrowing.book.title}\n" + f"Author: {borrowing.book.author}\n" + f"Borrowed by: {borrowing.user.email}\n" + f"Borrow date: {borrowing.borrow_date}\n" + f"Expected return: {borrowing.expected_return_date}\n" + ) + send_telegram_message(message) + + +def notify_overdue_borrowing(borrowing): + """Send notification about overdue borrowing.""" + from datetime import date + + days_overdue = (date.today() - borrowing.expected_return_date).days + + message = ( + f"⚠️ Overdue Borrowing Alert\n\n" + f"Book: {borrowing.book.title}\n" + f"Author: {borrowing.book.author}\n" + f"Borrowed by: {borrowing.user.email}\n" + f"Expected return: {borrowing.expected_return_date}\n" + f"Days overdue: {days_overdue}\n" + ) + send_telegram_message(message) + + +def notify_successful_payment(payment): + """Send notification about successful payment.""" + message = ( + f"✅ Payment Successful\n\n" + f"Payment ID: {payment.id}\n" + f"Type: {payment.type}\n" + f"Amount: ${payment.money_to_pay}\n" + f"Book: {payment.borrowing.book.title}\n" + f"User: {payment.borrowing.user.email}\n" + ) + send_telegram_message(message) 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..817edfa --- /dev/null +++ b/payments/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from payments.models import Payment + + +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + """Admin configuration for Payment model.""" + + list_display = [ + "id", + "borrowing", + "status", + "type", + "money_to_pay", + ] + list_filter = ["status", "type"] + search_fields = ["borrowing__book__title", "borrowing__user__email"] diff --git a/payments/apps.py b/payments/apps.py new file mode 100644 index 0000000..bf05bf1 --- /dev/null +++ b/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payments" diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py new file mode 100644 index 0000000..c83b61c --- /dev/null +++ b/payments/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.7 on 2025-11-05 11:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("borrowings", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Payment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[("PENDING", "Pending"), ("PAID", "Paid")], + default="PENDING", + max_length=10, + ), + ), + ( + "type", + models.CharField( + choices=[("PAYMENT", "Payment"), ("FINE", "Fine")], + default="PAYMENT", + max_length=10, + ), + ), + ("session_url", models.URLField(max_length=500)), + ("session_id", models.CharField(max_length=255)), + ("money_to_pay", models.DecimalField(decimal_places=2, max_digits=10)), + ( + "borrowing", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="borrowings.borrowing", + ), + ), + ], + options={ + "ordering": ["-id"], + }, + ), + ] 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..3e5293e --- /dev/null +++ b/payments/models.py @@ -0,0 +1,48 @@ +from django.db import models + +from borrowings.models import Borrowing + + +class Payment(models.Model): + """Model representing a payment for borrowing.""" + + STATUS_PENDING = "PENDING" + STATUS_PAID = "PAID" + + STATUS_CHOICES = [ + (STATUS_PENDING, "Pending"), + (STATUS_PAID, "Paid"), + ] + + TYPE_PAYMENT = "PAYMENT" + TYPE_FINE = "FINE" + + TYPE_CHOICES = [ + (TYPE_PAYMENT, "Payment"), + (TYPE_FINE, "Fine"), + ] + + status = models.CharField( + max_length=10, + choices=STATUS_CHOICES, + default=STATUS_PENDING + ) + type = models.CharField( + max_length=10, + choices=TYPE_CHOICES, + default=TYPE_PAYMENT + ) + borrowing = models.ForeignKey( + Borrowing, + on_delete=models.CASCADE, + related_name="payments" + ) + session_url = models.URLField(max_length=500) + session_id = models.CharField(max_length=255) + money_to_pay = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + ordering = ["-id"] + + def __str__(self): + return f"Payment {self.id} - {self.status}" diff --git a/payments/serializers.py b/payments/serializers.py new file mode 100644 index 0000000..588288a --- /dev/null +++ b/payments/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers + +from payments.models import Payment + + +class PaymentSerializer(serializers.ModelSerializer): + """Serializer for Payment model.""" + + class Meta: + model = Payment + fields = [ + "id", + "status", + "type", + "borrowing", + "session_url", + "session_id", + "money_to_pay", + ] + read_only_fields = ["id"] diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py new file mode 100644 index 0000000..a3c366d --- /dev/null +++ b/payments/stripe_helper.py @@ -0,0 +1,134 @@ +from decimal import Decimal + +import stripe +from django.conf import settings +from django.urls import reverse + +from payments.models import Payment + + +stripe.api_key = settings.STRIPE_SECRET_KEY + + +def create_stripe_session(borrowing, request): + """Create Stripe checkout session for borrowing.""" + + # Calculate total price (days * daily_fee) + days = (borrowing.expected_return_date - borrowing.borrow_date).days + if days < 1: + days = 1 + + # Convert days to Decimal and multiply by daily_fee + total_price = Decimal(days) * borrowing.book.daily_fee + + # Convert to cents for Stripe + unit_amount = int(total_price * 100) + + # Build success and cancel URLs + success_url = request.build_absolute_uri( + reverse("payments:payment-success") + ) + cancel_url = request.build_absolute_uri( + reverse("payments:payment-cancel") + ) + + # Create Stripe session + session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[ + { + "price_data": { + "currency": "usd", + "product_data": { + "name": f"Borrowing: {borrowing.book.title}", + "description": f"Book rental for {days} days", + }, + "unit_amount": unit_amount, + }, + "quantity": 1, + } + ], + mode="payment", + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel_url, + ) + + # Create Payment record + payment = Payment.objects.create( + borrowing=borrowing, + session_url=session.url, + session_id=session.id, + money_to_pay=total_price, + type=Payment.TYPE_PAYMENT, + status=Payment.STATUS_PENDING, + ) + + return payment + + +def create_fine_payment(borrowing, request): + """Create fine payment for overdue borrowing.""" + + # Calculate overdue days + overdue_days = ( + borrowing.actual_return_date - borrowing.expected_return_date + ).days + if overdue_days <= 0: + return None + + # Calculate fine amount - convert all to Decimal + fine_amount = ( + Decimal(overdue_days) + * borrowing.book.daily_fee + * Decimal(settings.FINE_MULTIPLIER) + ) + + # Convert to cents for Stripe + unit_amount = int(fine_amount * 100) + + # Build URLs + success_url = request.build_absolute_uri( + reverse("payments:payment-success") + ) + cancel_url = request.build_absolute_uri( + reverse("payments:payment-cancel") + ) + + # Create Stripe session + session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[ + { + "price_data": { + "currency": "usd", + "product_data": { + "name": f"FINE: {borrowing.book.title}", + "description": f"Overdue fine for {overdue_days} days", + }, + "unit_amount": unit_amount, + }, + "quantity": 1, + } + ], + mode="payment", + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel_url, + ) + + # Create Payment record + payment = Payment.objects.create( + borrowing=borrowing, + session_url=session.url, + session_id=session.id, + money_to_pay=fine_amount, + type=Payment.TYPE_FINE, + status=Payment.STATUS_PENDING, + ) + + return payment + + +def check_session_status(session_id): + """Check Stripe session payment status.""" + session = stripe.checkout.Session.retrieve(session_id) + return session.payment_status == "paid" diff --git a/payments/tests.py b/payments/tests.py new file mode 100644 index 0000000..e89f236 --- /dev/null +++ b/payments/tests.py @@ -0,0 +1,58 @@ +from datetime import date, timedelta +from decimal import Decimal +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from books.models import Book +from borrowings.models import Borrowing +from payments.models import Payment + + +class PaymentTests(TestCase): + """Tests for Payment model and Stripe integration.""" + + def setUp(self): + 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="SOFT", + inventory=5, + daily_fee=Decimal("2.00"), + ) + self.borrowing = Borrowing.objects.create( + book=self.book, + user=self.user, + expected_return_date=date.today() + timedelta(days=5), + ) + + @patch("payments.stripe_helper.reverse") + @patch("payments.stripe_helper.stripe.checkout.Session.create") + def test_create_stripe_session(self, mock_stripe, mock_reverse): + """Test creating Stripe checkout session.""" + # Mock reverse to return dummy URLs + mock_reverse.return_value = "/payments/success/" + + # Mock Stripe session response + mock_session = MagicMock() + mock_session.url = "https://checkout.stripe.com/session" + mock_session.id = "cs_test_123" + mock_stripe.return_value = mock_session + + from django.test import RequestFactory + from payments.stripe_helper import create_stripe_session + + request = RequestFactory().get("/") + + payment = create_stripe_session(self.borrowing, request) + + self.assertIsInstance(payment, Payment) + self.assertEqual(payment.borrowing, self.borrowing) + self.assertEqual(payment.type, Payment.TYPE_PAYMENT) + self.assertEqual(payment.status, Payment.STATUS_PENDING) + self.assertEqual(payment.money_to_pay, Decimal("10.00")) # 5 days * $2 + mock_stripe.assert_called_once() diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 0000000..b7db97a --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from payments.views import PaymentViewSet + + +router = DefaultRouter() +router.register("", PaymentViewSet, basename="payment") + +urlpatterns = [ + path("", include(router.urls)), +] + +app_name = "payments" diff --git a/payments/views.py b/payments/views.py new file mode 100644 index 0000000..04538f2 --- /dev/null +++ b/payments/views.py @@ -0,0 +1,80 @@ +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from payments.models import Payment +from payments.serializers import PaymentSerializer +from payments.stripe_helper import check_session_status + + +class PaymentViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ViewSet for managing payments.""" + + queryset = Payment.objects.select_related("borrowing__book", "borrowing__user") + serializer_class = PaymentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Non-admin users see only their own payments.""" + queryset = self.queryset + user = self.request.user + + if not user.is_staff: + queryset = queryset.filter(borrowing__user=user) + + return queryset + + @action(detail=False, methods=["get"], url_path="success") + def payment_success(self, request): + """Handle successful payment and send Telegram notification.""" + from notifications.telegram_helper import notify_successful_payment + + session_id = request.query_params.get("session_id") + + if not session_id: + return Response( + {"error": "Session ID is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + payment = Payment.objects.get(session_id=session_id) + + if check_session_status(session_id): + payment.status = Payment.STATUS_PAID + payment.save() + + # Send Telegram notification about successful payment + notify_successful_payment(payment) + + return Response( + {"message": "Payment successful!"}, + status=status.HTTP_200_OK, + ) + else: + return Response( + {"error": "Payment not completed."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + except Payment.DoesNotExist: + return Response( + {"error": "Payment not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + @action(detail=False, methods=["get"], url_path="cancel") + def payment_cancel(self, request): + """Handle cancelled payment.""" + return Response( + { + "message": "Payment cancelled. You can complete it later " + "(session expires in 24h)." + }, + status=status.HTTP_200_OK, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4a8d9f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +Django==4.2.7 +djangorestframework==3.14.0 +djangorestframework-simplejwt==5.3.0 +drf-spectacular==0.26.5 +django-q==1.3.9 +python-dotenv==1.0.0 +stripe==7.4.0 +requests==2.31.0 +psycopg2-binary==2.9.11 +redis==3.5.3 +django-cors-headers==4.9.0 +PyJWT==2.10.1 +pytz==2025.2 +setuptools==69.0.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..32827a5 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,43 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from users.models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """Admin configuration for custom User model.""" + + ordering = ["id"] + list_display = ["email", "first_name", "last_name", "is_staff"] + fieldsets = ( + (None, {"fields": ("email", "password")}), + ("Personal Info", {"fields": ("first_name", "last_name")}), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + ) + } + ), + ("Important dates", {"fields": ("last_login",)}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "email", + "password1", + "password2", + "first_name", + "last_name", + "is_staff", + ), + }, + ), + ) diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..e9121c9 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..3fa24b5 --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,73 @@ +# Generated by Django 4.2.7 on 2025-11-05 11:22 + +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", + ), + ), + ("email", models.EmailField(max_length=255, unique=True)), + ("first_name", models.CharField(max_length=255)), + ("last_name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ( + "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={ + "abstract": False, + }, + ), + ] 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..97cf9a8 --- /dev/null +++ b/users/models.py @@ -0,0 +1,44 @@ +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) +from django.db import models + + +class UserManager(BaseUserManager): + """Manager for custom user model.""" + + def create_user(self, email, password=None, **extra_fields): + """Create, save and return a new user.""" + if not email: + raise ValueError("User must have an email address") + user = self.model(email=self.normalize_email(email), **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password): + """Create and return a new superuser.""" + user = self.create_user(email, password) + user.is_staff = True + user.is_superuser = True + user.save(using=self._db) + return user + + +class User(AbstractBaseUser, PermissionsMixin): + """Custom user model that uses email instead of username.""" + + email = models.EmailField(max_length=255, unique=True) + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + + objects = UserManager() + + USERNAME_FIELD = "email" + + def __str__(self): + return self.email diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..b0d96e4 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,35 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + """Serializer for the user object.""" + + class Meta: + model = get_user_model() + fields = ["id", "email", "password", "first_name", "last_name"] + read_only_fields = ["id"] + extra_kwargs = {"password": {"write_only": True, "min_length": 8}} + + def create(self, validated_data): + """Create a new user with encrypted password and return it.""" + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + """Update a user, set password correctly and return it.""" + password = validated_data.pop("password", None) + user = super().update(instance, validated_data) + + if password: + user.set_password(password) + user.save() + + return user + + +class UserDetailSerializer(UserSerializer): + """Serializer for user detail view.""" + + class Meta(UserSerializer.Meta): + fields = UserSerializer.Meta.fields + read_only_fields = ["id", "email"] diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..116c967 --- /dev/null +++ b/users/tests.py @@ -0,0 +1,50 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +class UserTests(TestCase): + """Tests for User model and API.""" + + def setUp(self): + self.client = APIClient() + self.user_data = { + "email": "test@example.com", + "password": "testpass123", + "first_name": "Test", + "last_name": "User", + } + + def test_create_user(self): + """Test creating a new user.""" + url = reverse("users:register") + response = self.client.post(url, self.user_data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn("id", response.data) + self.assertEqual(response.data["email"], self.user_data["email"]) + + def test_token_generation(self): + """Test JWT token generation.""" + user = get_user_model().objects.create_user(**self.user_data) + url = reverse("users:token") + response = self.client.post( + url, {"email": user.email, "password": self.user_data["password"]} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) + self.assertIn("refresh", response.data) + + def test_retrieve_user_profile(self): + """Test retrieving authenticated user profile.""" + user = get_user_model().objects.create_user(**self.user_data) + self.client.force_authenticate(user=user) + + url = reverse("users:me") + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["email"], user.email) diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..37a046d --- /dev/null +++ b/users/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView + +from users.views import ( + CreateUserView, + CustomTokenObtainPairView, + ManageUserView, +) + + +urlpatterns = [ + path("register/", CreateUserView.as_view(), name="register"), + path("token/", CustomTokenObtainPairView.as_view(), name="token"), + path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"), + path("me/", ManageUserView.as_view(), name="me"), +] + +app_name = "users" diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..fa75d78 --- /dev/null +++ b/users/views.py @@ -0,0 +1,28 @@ +from rest_framework import generics, permissions +from rest_framework_simplejwt.views import TokenObtainPairView + +from users.serializers import UserDetailSerializer, UserSerializer + + +class CreateUserView(generics.CreateAPIView): + """Create a new user in the system.""" + + serializer_class = UserSerializer + permission_classes = [permissions.AllowAny] + + +class ManageUserView(generics.RetrieveUpdateAPIView): + """Manage the authenticated user.""" + + serializer_class = UserDetailSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + """Retrieve and return authenticated user.""" + return self.request.user + + +class CustomTokenObtainPairView(TokenObtainPairView): + """Custom token view with proper header name.""" + + pass