Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
DJANGO_SECRET_KEY=your_django_secret_key_here
DJANGO_DEBUG=True
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHAT_ID=your_telegram_chat_id_here
STRIPE_PUBLIC_KEY=your_stripe_public_key_here
STRIPE_SECRET_KEY=your_stripe_secret_key_here
STRIPE_SUCCESS_URL=http://127.0.0.1:8000/api/payments/success/
STRIPE_CANCEL_URL=http://127.0.0.1:8000/api/payments/cancel/
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ venv/
*.pyc
__pycache__/
db.sqlite3
.env
.env
celerybeat-schedule*
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.11-slim
LABEL maintainer="velviktor831@gmail.com"

ENV PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt

COPY . /app/
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 📚 Library Service API

This is a REST API for a Library Management System built with Django and Django REST Framework. It allows managing books, users, and borrowings, and features automated Telegram notifications with Stripe payment integration.

## 🛠 Features Implemented So Far

- **Books Service**: Manage book inventory, titles, authors, cover types, and daily fees.
- **Users Service**: User registration, authentication (JWT tokens), and profile management.
- **Borrowings Service**: Create and manage book borrowings with validation (prevents borrowing if out of stock).
- **Telegram Notifications**:
- Instant alerts when a new borrowing is created.
- Instant notifications when a book is successfully returned.
- **Daily Overdue Task**: Automated check for late returns via Celery and Celery Beat.
- **Payments Service (Stripe Integration)**:
- Automated Stripe Checkout Session creation for every new borrowing.
- Handling successful payments and canceling sessions.
- Automatic status updates (`PENDING` -> `PAID`) in the database upon successful transaction.

---

## 🚀 Installation & Setup

1. Clone the repository:
```bash
git clone https://github.com/Viktor395/library-service-api.git
cd library-service-api

2. Set up virtual environment

python -m venv .venv
source .venv/bin/activate # On Windows use: .venv\Scripts\activate
pip install -r requirements.txt

3. Environment Variables
Create a `.env` file in the root directory and add the following keys (you can use `.env.sample` as a template):

DJANGO_SECRET_KEY=your_django_secret_key_here
DJANGO_DEBUG=True
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHAT_ID=your_telegram_chat_id_here
STRIPE_PUBLIC_KEY=your_stripe_public_key_here
STRIPE_SECRET_KEY=your_stripe_secret_key_here
STRIPE_SUCCESS_URL=http://127.0.0.1:8000/api/payments/success/
STRIPE_CANCEL_URL=http://127.0.0.1:8000/api/payments/cancel/

4. Apply Migrations & Run Server

python manage.py migrate
python manage.py runserver

🐳 Running with Docker
If you prefer to run the entire infrastructure (Django with SQLite, Redis, and Celery) inside Docker containers, follow these steps:

1. Ensure you have Docker and Docker Compose installed.

2. Create and fill your .env file based on .env.sample.

3. Build and launch the containers using the following command in the root directory:

docker-compose up --build

This command automatically applies database migrations, starts the DRF web server on http://127.0.0.1:8000/, runs the Redis instance, and boots up both the Celery worker and Celery Beat scheduler in separate containers.



⏰ Background Tasks (Celery & Redis)
To run the automated background tasks (like daily overdue notifications), make sure Redis server is running, then start Celery components in separate terminal windows:

Run Celery Worker:
# For Windows:
celery -A library_config worker --loglevel=info -P threads
# For Linux/macOS:
celery -A library_config worker --loglevel=info

Run Celery Beat (Scheduler):

celery -A library_config beat --loglevel=info

## 🗺 API Endpoints for Payments

| Method | Endpoint | Description | Access |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/payments/` | List user's payments (or all for admin) | Authenticated |
| `GET` | `/api/payments/success/` | Stripe success callback (updates status to PAID) | Public |
| `GET` | `/api/payments/cancel/` | Stripe cancel callback | Public |
Empty file added book/__init__.py
Empty file.
9 changes: 9 additions & 0 deletions book/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.contrib import admin
from .models import Book


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "author", "cover", "inventory", "daily_fee")
list_filter = ("cover",)
search_fields = ("title", "author")
6 changes: 6 additions & 0 deletions book/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BookConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "book"
39 changes: 39 additions & 0 deletions book/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 5.0.14 on 2026-05-17 11:05

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", "Hardcover"), ("SOFT", "Softcover")],
default="SOFT",
max_length=4,
),
),
("inventory", models.PositiveIntegerField()),
("daily_fee", models.DecimalField(decimal_places=2, max_digits=6)),
],
),
]
Empty file added book/migrations/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions book/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.db import models


class Book(models.Model):
class CoverChoices(models.TextChoices):
HARD = "HARD", "Hardcover"
SOFT = "SOFT", "Softcover"

title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
cover = models.CharField(
max_length=4,
choices=CoverChoices.choices,
default=CoverChoices.SOFT
)
inventory = models.PositiveIntegerField()
daily_fee = models.DecimalField(max_digits=6, decimal_places=2)

def __str__(self):
return f"{self.title} by {self.author} ({self.cover})"
8 changes: 8 additions & 0 deletions book/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import Book


class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ("id", "title", "author", "cover", "inventory", "daily_fee")
3 changes: 3 additions & 0 deletions book/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
12 changes: 12 additions & 0 deletions book/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import BookViewSet

router = DefaultRouter()
router.register("", BookViewSet)

urlpatterns = [
path("", include(router.urls)),
]

app_name = "book"
14 changes: 14 additions & 0 deletions book/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAdminUser, AllowAny
from .models import Book
from .serializers import BookSerializer


class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer

def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [IsAdminUser()]
Empty file added borrowing/__init__.py
Empty file.
4 changes: 4 additions & 0 deletions borrowing/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from django.contrib import admin
from .models import Borrowing

admin.site.register(Borrowing)
6 changes: 6 additions & 0 deletions borrowing/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BorrowingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "borrowing"
51 changes: 51 additions & 0 deletions borrowing/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 4.2.30 on 2026-05-19 14:30

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
("book", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="Borrowing",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("borrow_date", models.DateField(auto_now_add=True)),
("expected_return_date", models.DateField()),
("actual_return_date", models.DateField(blank=True, null=True)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="borrowings",
to="book.book",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="borrowings",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
Empty file.
49 changes: 49 additions & 0 deletions borrowing/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from django.conf import settings
from django.db import models
from book.models import Book


class Borrowing(models.Model):
borrow_date = models.DateField(auto_now_add=True)
expected_return_date = models.DateField()
actual_return_date = models.DateField(null=True, blank=True)
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="borrowings")
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="borrowings"
)

def __str__(self):
return f"{self.user.email} borrowed {self.book.title} on {self.borrow_date}"

def save(self, *args, **kwargs):
is_new = self.pk is None

is_returning = False
if not is_new:
old_instance = Borrowing.objects.get(pk=self.pk)
if old_instance.actual_return_date is None and self.actual_return_date is not None:
is_returning = True

super().save(*args, **kwargs)

from borrowing.notification_helper import send_telegram_message

if is_new:
message = (
f"🎉 <b>New Borrowing Created!</b>\n\n"
f"👤 <b>User:</b> {self.user.email}\n"
f"📚 <b>Book:</b> {self.book.title}\n"
f"📅 <b>Borrow Date:</b> {self.borrow_date}\n"
f"⏳ <b>Expected Return:</b> {self.expected_return_date}"
)
send_telegram_message(message)

elif is_returning:
message = (
f"✅ <b>Book Returned Successfully!</b>\n\n"
f"👤 <b>User:</b> {self.user.email}\n"
f"📚 <b>Book:</b> {self.book.title}\n"
f"📅 <b>Returned On:</b> {self.actual_return_date}\n"
f"stocked back to inventory ✨"
)
send_telegram_message(message)
26 changes: 26 additions & 0 deletions borrowing/notification_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import os
import requests
from django.conf import settings

def send_telegram_message(message: str) -> None:
bot_token = settings.TELEGRAM_BOT_TOKEN
chat_id = settings.TELEGRAM_CHAT_ID

if not bot_token or not chat_id:
print("Telegram bot credentials are not set in .env")
return

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)
if response.status_code != 200:
print(f"Failed to send telegram message: {response.text}")
except requests.RequestException as e:
print(f"Error connecting to Telegram API: {e}")
Loading