Skip to content
Merged
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
39 changes: 8 additions & 31 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,11 @@ FROM python:3.13-slim-bookworm
# Install UV (ultra-fast Python package installer) from Astral.sh
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Create non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser

# Ensure Python output is sent straight to terminal without buffering
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_SYSTEM_PYTHON=1
PYTHONDONTWRITEBYTECODE=1

# ======================
# SYSTEM DEPENDENCIES
# ======================
# Install system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
Expand All @@ -27,34 +21,17 @@ RUN apt-get update && \
# Set working directory inside container
WORKDIR /app

# ======================
# DEPENDENCY INSTALLATION
# ======================
# Copy dependency files with proper ownership
COPY --chown=appuser:appuser pyproject.toml uv.lock ./
# Copy dependency files
COPY pyproject.toml uv.lock ./

# Install Python dependencies using UV
RUN uv sync --locked --no-dev

# ======================
# APPLICATION CODE
# ======================
# Copy entrypoint script first
COPY --chown=appuser:appuser entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
RUN uv sync --dev --locked

# Copy the rest of the application code
COPY --chown=appuser:appuser . .

# Create directories for Django with proper permissions
RUN mkdir -p /app/static /app/media && \
chown -R appuser:appuser /app
COPY . .

# ======================
# RUNTIME CONFIGURATION
# ======================
# Switch to non-root user
USER appuser
# Making the file executable
RUN chmod +x entrypoint.sh

# Expose the port Django runs on
EXPOSE 8000
Expand Down
21 changes: 15 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# ======================
# VARIABLES
# ======================
USERID := $(shell id -u)
GROUPID := $(shell id -g)
PYTHON := docker compose run -u $(USERID):$(GROUPID) --rm django uv run
UV := docker compose run -u $(USERID):$(GROUPID) --rm django uv
PYTHON := $(UV) run
DOCKER_COMPOSE := docker compose
RUFF := uvx ruff

Expand All @@ -23,7 +22,7 @@ build: ## Build Docker images
$(DOCKER_COMPOSE) build

up: ## Start all services
$(DOCKER_COMPOSE) up -d
$(DOCKER_COMPOSE) up

down: ## Stop all services
$(DOCKER_COMPOSE) down
Expand Down Expand Up @@ -79,8 +78,15 @@ check: ## Run all code quality checks
$(RUFF) check apps config
$(RUFF) format --check apps config

test: ## Run tests
$(PYTHON) manage.py test
# ======================
# TESTS
# ======================
test: ## Run pytest tests with coverage in Docker
$(PYTHON) pytest

coverage: ## Generate coverage report
$(PYTHON) pytest --cov=apps --cov-report=html --cov-report=term-missing
@echo "Coverage report generated in htmlcov/index.html"

# ======================
# DEVELOPMENT
Expand Down Expand Up @@ -127,3 +133,6 @@ health: ## Check services health
$(DOCKER_COMPOSE) ps
@echo "\n--- Service Health ---"
@curl -f http://localhost:8000/health/ || echo "Django service not responding"

parse:
$(PYTHON) manage.py parse_books
2 changes: 1 addition & 1 deletion apps/books/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.contrib import admin

from apps.books.models import Author, Book, Comment, Publisher, Tag
from .models import Author, Book, Comment, Publisher, Tag


@admin.register(Author)
Expand Down
15 changes: 8 additions & 7 deletions apps/books/management/commands/parse_books.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@
from asgiref.sync import sync_to_async
from django.core.management.base import BaseCommand

from apps.books.models import Author, Book, Publisher
from apps.books.scrapers.base_scraper import BaseScraper
from apps.books.scrapers.piter_publ.book_parser import BookParser
from apps.books.scrapers.piter_publ.piter_scraper import PiterScraper
from apps.books.services.author_service import AuthorService
from apps.books.services.book_saver import BookSaver
from apps.books.services.publisher_service import PublisherService
from logger.books.log import get_logger

from ...models import Author, Book, Publisher
from ...scrapers.base_scraper import BaseScraper
from ...scrapers.piter_publ.book_parser import BookParser
from ...scrapers.piter_publ.piter_scraper import PiterScraper
from ...services.author_service import AuthorService
from ...services.book_saver import BookSaver
from ...services.publisher_service import PublisherService

logger = get_logger(__name__)
author_service = AuthorService(Author)
publisher_service = PublisherService(Publisher)
Expand Down
Empty file added apps/books/services/__init__.py
Empty file.
4 changes: 2 additions & 2 deletions apps/books/services/author_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import List

from apps.books.models import Author
from apps.books.validators.validators import AuthorInput
from ..models import Author
from ..validators.validators import AuthorInput


class AuthorService:
Expand Down
9 changes: 5 additions & 4 deletions apps/books/services/book_saver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
from django.db import transaction
from pydantic import ValidationError

from apps.books.models import Author, Book, Publisher
from apps.books.services.author_service import AuthorService
from apps.books.services.publisher_service import PublisherService
from apps.books.validators.validators import BookInput
from logger.books.log import get_logger

from ..models import Author, Book, Publisher
from ..validators.validators import BookInput
from .author_service import AuthorService
from .publisher_service import PublisherService

logger = get_logger(__name__)
author_service = AuthorService(Author)
publisher_service = PublisherService(Publisher)
Expand Down
2 changes: 1 addition & 1 deletion apps/books/services/publisher_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from apps.books.models import Publisher
from ..models import Publisher


class PublisherService:
Expand Down
4 changes: 2 additions & 2 deletions apps/books/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from asgiref.sync import sync_to_async
from celery import shared_task

from apps.books.management.commands.parse_books import (
from .management.commands.parse_books import (
AsyncBookFetcher,
book_saver,
logger,
)
from apps.books.scrapers.piter_publ.piter_scraper import PiterScraper
from .scrapers.piter_publ.piter_scraper import PiterScraper


@shared_task
Expand Down
1 change: 0 additions & 1 deletion apps/books/tests.py

This file was deleted.

13 changes: 13 additions & 0 deletions apps/books/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.urls import path

from . import views

app_name = "books"

urlpatterns = [
path("", views.index, name="index"),
path("search/", views.book_search, name="search"),
path("filter/", views.book_filter, name="filter"),
path("<int:book_id>/", views.book_detail, name="detail"),
path("<int:book_id>/comments/", views.add_comment, name="add_comment"),
]
Empty file.
129 changes: 128 additions & 1 deletion apps/books/views.py
Original file line number Diff line number Diff line change
@@ -1 +1,128 @@
# Create your views here.
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.csrf import csrf_exempt

from .models import Book, Comment, Publisher


def index(request):
books = (
Book.objects.select_related("publisher")
.prefetch_related("author", "tags")
.order_by("-created")
)
publishers = Publisher.objects.all()

# Apply filters
search = request.GET.get("search", "")
category = request.GET.get("category", "")
publisher_id = request.GET.get("publisher", "")
sort_by = request.GET.get("sort", "-created")

if search:
books = books.filter(
Q(title__icontains=search)
| Q(description__icontains=search)
| Q(author__first_name__icontains=search)
| Q(author__last_name__icontains=search)
).distinct()

if category:
books = books.filter(tags__slug=category)

if publisher_id:
books = books.filter(publisher_id=publisher_id)

books = books.order_by(sort_by)

# Pagination
paginator = Paginator(books, 12)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

# Check if this is an HTMX request
if request.headers.get("HX-Request"):
return render(request, "books/partials/books_grid.html", {"books": page_obj})

return render(
request,
"books/index.html",
{
"books": page_obj,
"publishers": publishers,
},
)


def book_detail(request, book_id):
book = get_object_or_404(Book, id=book_id)

# Check if this is an HTMX request for modal
if request.headers.get("HX-Request"):
return render(request, "books/detail.html", {"book": book})

return render(request, "books/detail.html", {"book": book})


def book_search(request):
search = request.GET.get("search", "")
books = Book.objects.select_related("publisher").prefetch_related("author", "tags")

if search:
books = books.filter(
Q(title__icontains=search)
| Q(description__icontains=search)
| Q(author__first_name__icontains=search)
| Q(author__last_name__icontains=search)
).distinct()

books = books.order_by("-created")

# Pagination
paginator = Paginator(books, 12)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

return render(request, "books/partials/books_grid.html", {"books": page_obj})


def book_filter(request):
books = Book.objects.select_related("publisher").prefetch_related("author", "tags")

# Apply filters
category = request.GET.get("category", "")
publisher_id = request.GET.get("publisher", "")
sort_by = request.GET.get("sort", "-created")

if category:
books = books.filter(tags__slug=category)

if publisher_id:
books = books.filter(publisher_id=publisher_id)

books = books.order_by(sort_by)

# Pagination
paginator = Paginator(books, 12)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

return render(request, "books/partials/books_grid.html", {"books": page_obj})


@login_required
@csrf_exempt
def add_comment(request, book_id):
if request.method == "POST":
book = get_object_or_404(Book, id=book_id)
text = request.POST.get("text", "").strip()

if text:
comment = Comment.objects.create(book=book, user=request.user, text=text)

return render(request, "books/partials/comment.html", {"comment": comment})

return HttpResponse("", status=400)
Loading