diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e8478a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +.pytest_cache +*.egg-info +dist +build +.eggs + +# Virtual environments +.venv +venv +ENV + +# IDE +.idea +.vscode +*.swp +*.swo + +# Testing +htmlcov +.coverage +.tox +.nox + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Documentation (not needed in container) +*.md +!README.md + +# Development files +.env +*.bak +*.backup + +# Test files (optional - include if you want to run tests in container) +test_*.py + +# Windows batch files (not needed in container) +*.bat diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e4ba37f --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Library Management System Configuration +# Copy this file to .env and modify as needed + +# Server Configuration +HOST=127.0.0.1 +PORT=8000 +DEBUG=false + +# CORS Configuration +# Comma-separated list of allowed origins +# For production, specify your actual domain(s) +CORS_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 +CORS_ALLOW_CREDENTIALS=true +CORS_ALLOW_METHODS=GET,POST,PUT,DELETE,OPTIONS +CORS_ALLOW_HEADERS=Content-Type,Authorization + +# Data Configuration +LIBRARY_FILE=library.json + +# Logging Configuration +# Options: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO +# Leave empty for stdout only, or specify a file path +LOG_FILE= +LOG_FORMAT=%(asctime)s - %(name)s - %(levelname)s - %(message)s + +# OpenLibrary API Configuration +OPENLIBRARY_BASE_URL=https://openlibrary.org +OPENLIBRARY_TIMEOUT=10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5713950 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +# Continuous Integration workflow for Library Management System +# Runs tests and linting on every push and pull request + +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff + + - name: Lint with ruff + run: | + ruff check . --output-format=github + + - name: Run tests + run: | + pytest -v --tb=short + + - name: Run tests with coverage + if: matrix.python-version == '3.11' + run: | + pytest --cov=. --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: library-management:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Test Docker image + run: | + docker run -d --name test-container -p 8000:8000 library-management:latest + sleep 5 + curl -f http://localhost:8000/health || exit 1 + docker stop test-container diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..304b3f1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +# Release workflow for Library Management System +# Creates releases and publishes Docker images when tags are pushed + +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest -v + + - name: Extract version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + body: | + ## Library Management System v${{ steps.get_version.outputs.VERSION }} + + ### Installation + + **Using pip:** + ```bash + pip install -r requirements.txt + ``` + + **Using Docker:** + ```bash + docker pull ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }} + docker run -p 8000:8000 ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }} + ``` + + See [CHANGELOG.md](CHANGELOG.md) for detailed changes. + + docker-publish: + name: Publish Docker Image + runs-on: ubuntu-latest + needs: release + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f92a68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.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 +*.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/ + +# Translations +*.mo +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE settings +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# Logs +*.log +logs/ + +# Local development +.DS_Store +Thumbs.db + +# Backup files +*.bak +*.backup +library_backup*.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e7ef230 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Configuration module (`config.py`) for environment-based settings +- Input validation for ISBN (ISBN-10 and ISBN-13 with checksum verification) +- Input validation for publication year (reasonable bounds checking) +- Structured logging throughout the application +- Health check endpoint (`/health`) for container orchestration +- `.gitignore` file for proper version control +- `.env.example` template for environment configuration +- `pyproject.toml` for modern Python packaging +- `CONTRIBUTING.md` with development guidelines +- This `CHANGELOG.md` file +- Comprehensive docstrings for all modules + +### Changed +- CORS configuration now uses environment variables instead of allowing all origins +- Dependencies are now version-pinned for reproducibility +- API responses include proper validation error messages + +### Security +- Fixed overly permissive CORS configuration (`allow_origins=["*"]`) +- Added input validation to prevent malformed data + +## [1.0.0] - 2024-01-01 + +### Added +- **Tier 1: CLI Application** + - Add, remove, list, find, and update books + - JSON file-based persistence + - UTF-8 encoding support for international characters + +- **Tier 2: OpenLibrary Integration** + - Fetch book metadata by ISBN from OpenLibrary API + - Automatic population of title, author, and publication year + +- **Tier 3: Web Interface** + - FastAPI REST API with full CRUD operations + - Responsive web UI with Tailwind CSS + - Dynamic search across title, author, ISBN, and year + - Multiple sort options (date added, title, author, year) + - In-place book editing via modal dialog + - Two-step book fetching from OpenLibrary + - CSV export functionality + - Real-time book count display + +- **Development** + - Comprehensive test suite with pytest + - Windows batch scripts for ease of use + - LibraryThing JSON converter utility + +### Technical Details +- Python 3.8+ required +- FastAPI for REST API +- Pydantic for data validation +- httpx for HTTP client operations +- Vanilla JavaScript with Tailwind CSS for frontend + +[Unreleased]: https://github.com/UmutHasanoglu/Library-Management-Project/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/UmutHasanoglu/Library-Management-Project/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a866a0c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,134 @@ +# Contributing to Library Management System + +First off, thank you for considering contributing to the Library Management System! It's people like you that make this project better. + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check existing issues as you might find that the issue has already been reported. When you are creating a bug report, please include as many details as possible: + +- **Use a clear and descriptive title** for the issue +- **Describe the exact steps to reproduce the problem** +- **Provide specific examples** to demonstrate the steps +- **Describe the behavior you observed** and what you expected to see +- **Include your Python version** and operating system + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: + +- **Use a clear and descriptive title** +- **Provide a detailed description** of the suggested enhancement +- **Explain why this enhancement would be useful** + +### Pull Requests + +1. Fork the repository +2. Create a new branch from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` +3. Make your changes +4. Run the tests to ensure nothing is broken: + ```bash + pytest + ``` +5. Commit your changes with a descriptive message: + ```bash + git commit -m "Add feature: description of your changes" + ``` +6. Push to your fork: + ```bash + git push origin feature/your-feature-name + ``` +7. Open a Pull Request + +## Development Setup + +### Prerequisites + +- Python 3.8 or higher +- pip or uv package manager + +### Setup + +1. Clone the repository: + ```bash + git clone https://github.com/UmutHasanoglu/Library-Management-Project.git + cd Library-Management-Project + ``` + +2. Create a virtual environment: + ```bash + python -m venv .venv + source .venv/bin/activate # On Windows: .venv\Scripts\activate + ``` + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. (Optional) Install development dependencies: + ```bash + pip install -e ".[dev]" + ``` + +### Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=. --cov-report=html + +# Run specific test file +pytest test_library.py +``` + +### Code Style + +- Follow PEP 8 guidelines +- Use meaningful variable and function names +- Add docstrings to all public functions and classes +- Keep functions focused and single-purpose + +You can use `ruff` to check and format your code: +```bash +ruff check . +ruff format . +``` + +## Project Structure + +``` +Library-Management-Project/ +├── library.py # Core data models and persistence +├── main.py # CLI interface +├── api.py # FastAPI REST API +├── config.py # Configuration management +├── validators.py # Input validation utilities +├── converter.py # LibraryThing import utility +├── index.html # Web UI +├── test_*.py # Test files +└── library.json # Data storage +``` + +## Commit Messages + +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference issues and pull requests liberally after the first line + +## Questions? + +Feel free to open an issue with your question or reach out to the maintainers. + +Thank you for contributing! 🎉 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1be7030 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Library Management System - Docker Image +# Multi-stage build for optimized image size + +# Build stage +FROM python:3.11-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + + +# Production stage +FROM python:3.11-slim as production + +WORKDIR /app + +# Create non-root user for security +RUN useradd --create-home --shell /bin/bash appuser + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application files +COPY --chown=appuser:appuser library.py main.py api.py config.py validators.py ./ +COPY --chown=appuser:appuser index.html ./ + +# Create data directory and copy sample data if available +RUN mkdir -p /app/data && chown appuser:appuser /app/data +COPY --chown=appuser:appuser library.json /app/data/library.json + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HOST=0.0.0.0 \ + PORT=8000 \ + LIBRARY_FILE=/app/data/library.json \ + LOG_LEVEL=INFO + +# Switch to non-root user +USER appuser + +# Expose the API port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()" + +# Run the application +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6b17e79 --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +# Library Management System - Makefile +# Common commands for development and deployment + +.PHONY: help install install-dev test test-cov lint format run-cli run-api docker-build docker-run docker-stop clean + +# Default target +help: + @echo "Library Management System - Available Commands" + @echo "" + @echo "Setup:" + @echo " make install Install production dependencies" + @echo " make install-dev Install development dependencies" + @echo "" + @echo "Development:" + @echo " make test Run tests" + @echo " make test-cov Run tests with coverage report" + @echo " make lint Run linter (ruff)" + @echo " make format Format code (ruff)" + @echo "" + @echo "Running:" + @echo " make run-cli Start the CLI application" + @echo " make run-api Start the API server" + @echo "" + @echo "Docker:" + @echo " make docker-build Build Docker image" + @echo " make docker-run Run Docker container" + @echo " make docker-stop Stop Docker container" + @echo "" + @echo "Cleanup:" + @echo " make clean Remove generated files" + +# Setup targets +install: + pip install -r requirements.txt + +install-dev: + pip install -r requirements.txt + pip install ruff + +# Development targets +test: + pytest -v + +test-cov: + pytest --cov=. --cov-report=html --cov-report=term-missing + @echo "Coverage report generated in htmlcov/index.html" + +lint: + ruff check . + +format: + ruff format . + ruff check --fix . + +# Running targets +run-cli: + python main.py + +run-api: + uvicorn api:app --reload --host 127.0.0.1 --port 8000 + +# Docker targets +docker-build: + docker build -t library-management:latest . + +docker-run: + docker-compose up -d + @echo "Container started. API available at http://localhost:8000" + @echo "Use 'make docker-stop' to stop the container" + +docker-stop: + docker-compose down + +# Cleanup targets +clean: + rm -rf __pycache__ .pytest_cache htmlcov .coverage *.egg-info dist build + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true diff --git a/api.py b/api.py index 30c017f..816d88d 100644 --- a/api.py +++ b/api.py @@ -1,43 +1,72 @@ +""" +FastAPI REST API for the Library Management System. + +Provides endpoints for CRUD operations on books and integration +with the OpenLibrary API for fetching book metadata. +""" + +import logging +from contextlib import asynccontextmanager +from typing import List, Optional + from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from typing import Optional, List +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, field_validator + +import config from library import Library, Book as LibraryBook from main import get_book_details_from_openlibrary -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager +from validators import validate_isbn, validate_year, ValidationError + +# Configure logging +logging.basicConfig( + level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO), + format=config.LOG_FORMAT, + handlers=[ + logging.StreamHandler(), + *([logging.FileHandler(config.LOG_FILE)] if config.LOG_FILE else []) + ] +) +logger = logging.getLogger(__name__) + # This dictionary will hold our single Library instance. app_state = {} + @asynccontextmanager async def lifespan(app: FastAPI): - # This code runs when the application starts up. - print("Server starting up...") + """Manage application lifecycle - startup and shutdown.""" + logger.info("Server starting up...") # Create the single, shared Library instance and store it in the app_state. - app_state["library"] = Library() - print(f"Library loaded with {len(app_state['library'].books)} books.") + app_state["library"] = Library(config.LIBRARY_FILE) + logger.info(f"Library loaded with {len(app_state['library'].books)} books.") yield # This code runs when the application is shutting down. - print("Server shutting down...") + logger.info("Server shutting down...") app_state["library"].save_books() - print("Library data saved.") + logger.info("Library data saved.") + app = FastAPI( title="Library API", description="A simple API to manage a library of books.", version="1.0.0", - lifespan=lifespan # Use the lifespan manager + lifespan=lifespan ) -# Add CORS middleware to allow the HTML file to communicate with the API +# Add CORS middleware with configurable origins app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_origins=config.CORS_ORIGINS, + allow_credentials=config.CORS_ALLOW_CREDENTIALS, + allow_methods=config.CORS_ALLOW_METHODS, + allow_headers=config.CORS_ALLOW_HEADERS, ) +logger.info(f"CORS configured with origins: {config.CORS_ORIGINS}") + + class Book(BaseModel): """Pydantic model for a book, used for API request and response validation.""" title: str @@ -45,65 +74,120 @@ class Book(BaseModel): isbn: str year: int available: bool = True - # Add the new date field, make it optional for incoming requests date_added: Optional[str] = None + @field_validator('isbn') + @classmethod + def validate_isbn_format(cls, v: str) -> str: + """Validate ISBN format (ISBN-10 or ISBN-13).""" + try: + validate_isbn(v) + except ValidationError as e: + raise ValueError(str(e)) + return v + + @field_validator('year') + @classmethod + def validate_year_range(cls, v: int) -> int: + """Validate publication year is within reasonable range.""" + try: + validate_year(v) + except ValidationError as e: + raise ValueError(str(e)) + return v + + @field_validator('title', 'author') + @classmethod + def validate_not_empty(cls, v: str, info) -> str: + """Validate that title and author are not empty.""" + if not v or not v.strip(): + raise ValueError(f"{info.field_name} cannot be empty") + return v.strip() + + +@app.get("/health") +async def health_check(): + """Health check endpoint for container orchestration.""" + return { + "status": "healthy", + "books_count": len(app_state.get("library", Library()).books) + } + + @app.get("/books", response_model=List[Book]) async def list_all_books(): """Retrieve a list of all books in the library.""" library = app_state["library"] + logger.debug(f"Listing all {len(library.books)} books") return [book.to_dict() for book in library.books] + @app.get("/books/{isbn}", response_model=Book) async def get_single_book(isbn: str): """Retrieve a single book by its ISBN.""" library = app_state["library"] book = library.find_book(isbn) if book is None: + logger.warning(f"Book not found: ISBN={isbn}") raise HTTPException(status_code=404, detail="Book not found") + logger.debug(f"Retrieved book: {book.title} (ISBN={isbn})") return book.to_dict() + @app.get("/openlibrary/{isbn}") async def fetch_openlibrary_info(isbn: str): """Fetch book details from OpenLibrary without adding to the library.""" + logger.info(f"Fetching book details from OpenLibrary: ISBN={isbn}") book_data = get_book_details_from_openlibrary(isbn) if not book_data: + logger.warning(f"Book not found on OpenLibrary: ISBN={isbn}") raise HTTPException(status_code=404, detail="Book not found on OpenLibrary.") + logger.info(f"Found book on OpenLibrary: {book_data.get('title', 'Unknown')}") return book_data + @app.post("/books", response_model=Book, status_code=201) async def add_new_book(book: Book): """Add a new book to the library.""" library = app_state["library"] if any(b.isbn == book.isbn for b in library.books): + logger.warning(f"Attempted to add duplicate book: ISBN={book.isbn}") raise HTTPException(status_code=400, detail="Book with this ISBN already exists") - + new_book = LibraryBook(**book.model_dump(exclude_none=True)) library.add_book(new_book) + logger.info(f"Added new book: {book.title} (ISBN={book.isbn})") + # Find the book again to get the version with the timestamp added_book = library.find_book(new_book.isbn) return added_book.to_dict() + @app.put("/books/{isbn}", response_model=Book) async def update_existing_book(isbn: str, updated_book: Book): """Update an existing book's details.""" library = app_state["library"] book_to_update = library.find_book(isbn) if book_to_update is None: + logger.warning(f"Attempted to update non-existent book: ISBN={isbn}") raise HTTPException(status_code=404, detail="Book not found") - + update_data = updated_book.model_dump(exclude={'isbn'}, exclude_none=True) library.update_book(isbn, **update_data) - + logger.info(f"Updated book: ISBN={isbn}") + updated_book_data = library.find_book(isbn) return updated_book_data.to_dict() + @app.delete("/books/{isbn}", status_code=204) async def remove_existing_book(isbn: str): """Remove a book from the library.""" library = app_state["library"] if not library.find_book(isbn): + logger.warning(f"Attempted to remove non-existent book: ISBN={isbn}") raise HTTPException(status_code=404, detail="Book not found") - + library.remove_book(isbn) + logger.info(f"Removed book: ISBN={isbn}") return {} diff --git a/config.py b/config.py new file mode 100644 index 0000000..84d31bd --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +""" +Configuration module for the Library Management System. + +Loads settings from environment variables with sensible defaults. +""" + +import os +from typing import List + + +def get_list_from_env(key: str, default: str) -> List[str]: + """Parse a comma-separated environment variable into a list.""" + value = os.getenv(key, default) + return [item.strip() for item in value.split(",") if item.strip()] + + +# Server configuration +HOST: str = os.getenv("HOST", "127.0.0.1") +PORT: int = int(os.getenv("PORT", "8000")) +DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") + +# CORS configuration +# In production, set CORS_ORIGINS to your specific domain(s) +# Example: CORS_ORIGINS=https://example.com,https://www.example.com +CORS_ORIGINS: List[str] = get_list_from_env( + "CORS_ORIGINS", + "http://localhost:8000,http://127.0.0.1:8000,http://localhost:3000,null" +) +CORS_ALLOW_CREDENTIALS: bool = os.getenv("CORS_ALLOW_CREDENTIALS", "true").lower() in ("true", "1", "yes") +CORS_ALLOW_METHODS: List[str] = get_list_from_env("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS") +CORS_ALLOW_HEADERS: List[str] = get_list_from_env("CORS_ALLOW_HEADERS", "Content-Type,Authorization") + +# Data configuration +LIBRARY_FILE: str = os.getenv("LIBRARY_FILE", "library.json") + +# Logging configuration +LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") +LOG_FILE: str = os.getenv("LOG_FILE", "") # Empty means stdout only +LOG_FORMAT: str = os.getenv( + "LOG_FORMAT", + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + +# OpenLibrary API configuration +OPENLIBRARY_BASE_URL: str = os.getenv("OPENLIBRARY_BASE_URL", "https://openlibrary.org") +OPENLIBRARY_TIMEOUT: int = int(os.getenv("OPENLIBRARY_TIMEOUT", "10")) diff --git a/converter.py b/converter.py index aace705..9d128e7 100644 --- a/converter.py +++ b/converter.py @@ -1,7 +1,15 @@ +""" +LibraryThing to Library Management System converter. + +Converts book exports from LibraryThing JSON format to the format +used by the Library Management System. +""" + import json from datetime import datetime -def convert_library_format(input_filename, output_filename): + +def convert_library_format(input_filename: str, output_filename: str) -> None: """ Converts a LibraryThing JSON export to the format used by our app, ensuring correct handling of UTF-8 characters and adding the date_added field. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1c8558 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# Library Management System - Docker Compose Configuration +# Usage: docker-compose up -d + +services: + library-api: + build: + context: . + dockerfile: Dockerfile + container_name: library-management + ports: + - "${PORT:-8000}:8000" + volumes: + # Persist library data outside the container + - library-data:/app/data + # Mount the HTML file for easy updates (optional) + - ./index.html:/app/index.html:ro + environment: + - HOST=0.0.0.0 + - PORT=8000 + - LIBRARY_FILE=/app/data/library.json + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000} + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + +volumes: + library-data: + driver: local diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ba1b96e --- /dev/null +++ b/install.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Install script for Library Management System (Unix/Linux/macOS) + +set -e + +echo "=== Library Management System - Installation ===" +echo + +# Check for Python +if command -v python3 &> /dev/null; then + PYTHON=python3 +elif command -v python &> /dev/null; then + PYTHON=python +else + echo "Error: Python is not installed. Please install Python 3.8 or higher." + exit 1 +fi + +# Check Python version +PYTHON_VERSION=$($PYTHON -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +echo "Found Python $PYTHON_VERSION" + +# Check if uv is available (faster package manager) +if command -v uv &> /dev/null; then + echo "Using uv for package management..." + uv venv .venv + source .venv/bin/activate + uv pip install -r requirements.txt +else + echo "Using pip for package management..." + $PYTHON -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt +fi + +echo +echo "=== Installation Complete ===" +echo +echo "To activate the virtual environment, run:" +echo " source .venv/bin/activate" +echo +echo "To start the CLI:" +echo " ./start_cli.sh OR python main.py" +echo +echo "To start the Web UI:" +echo " ./start_webui.sh OR uvicorn api:app --reload" diff --git a/library.py b/library.py index 65d9005..6fc4ed1 100644 --- a/library.py +++ b/library.py @@ -1,3 +1,10 @@ +""" +Core data models and persistence layer for the Library Management System. + +Provides the Book dataclass and Library class for managing book collections +with JSON file-based persistence. +""" + import json from dataclasses import dataclass, asdict from datetime import datetime diff --git a/main.py b/main.py index 02df446..361af52 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,10 @@ +""" +Command-line interface for the Library Management System. + +Provides an interactive menu-driven interface for managing books, +including integration with the OpenLibrary API for fetching book metadata. +""" + import httpx import json from library import Library, Book diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a586f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,104 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "library-management-system" +version = "1.0.0" +description = "A comprehensive library management system with CLI, REST API, and Web UI" +readme = "README.md" +license = {text = "GPL-3.0"} +requires-python = ">=3.8" +authors = [ + {name = "Library Management Contributors"} +] +keywords = ["library", "books", "management", "api", "fastapi"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Web Environment", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Education", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", +] + +dependencies = [ + "fastapi>=0.109.0,<1.0.0", + "uvicorn[standard]>=0.27.0,<1.0.0", + "httpx>=0.26.0,<1.0.0", + "pydantic>=2.5.0,<3.0.0", + "python-dotenv>=1.0.0,<2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0,<9.0.0", + "pytest-cov>=4.1.0,<6.0.0", + "ruff>=0.1.0", +] + +[project.urls] +Homepage = "https://github.com/UmutHasanoglu/Library-Management-Project" +Repository = "https://github.com/UmutHasanoglu/Library-Management-Project" +Issues = "https://github.com/UmutHasanoglu/Library-Management-Project/issues" + +[project.scripts] +library-cli = "main:main" + +[tool.setuptools.packages.find] +where = ["."] + +[tool.pytest.ini_options] +testpaths = ["."] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.coverage.run] +source = ["."] +omit = ["test_*.py", ".venv/*", "venv/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] +fail_under = 70 + +[tool.ruff] +target-version = "py38" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults +] + +[tool.ruff.lint.isort] +known-first-party = ["library", "api", "main", "config", "validators"] diff --git a/requirements.txt b/requirements.txt index 5ca40e9..7378315 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,12 @@ -fastapi -uvicorn[standard] -httpx -pydantic -pytest \ No newline at end of file +# Core dependencies +fastapi>=0.109.0,<1.0.0 +uvicorn[standard]>=0.27.0,<1.0.0 +httpx>=0.26.0,<1.0.0 +pydantic>=2.5.0,<3.0.0 + +# Testing +pytest>=7.4.0,<9.0.0 +pytest-cov>=4.1.0,<6.0.0 + +# Optional: for loading .env files +python-dotenv>=1.0.0,<2.0.0 diff --git a/start_cli.sh b/start_cli.sh new file mode 100755 index 0000000..a78c721 --- /dev/null +++ b/start_cli.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Start the Library Management CLI (Unix/Linux/macOS) + +# Activate virtual environment if it exists +if [ -d ".venv" ]; then + source .venv/bin/activate +fi + +# Run the CLI +python main.py diff --git a/start_webui.sh b/start_webui.sh new file mode 100755 index 0000000..0255ba6 --- /dev/null +++ b/start_webui.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Start the Library Management Web UI (Unix/Linux/macOS) + +# Activate virtual environment if it exists +if [ -d ".venv" ]; then + source .venv/bin/activate +fi + +# Load environment variables if .env exists +if [ -f ".env" ]; then + export $(grep -v '^#' .env | xargs) +fi + +# Get host and port from environment or use defaults +HOST=${HOST:-127.0.0.1} +PORT=${PORT:-8000} + +echo "Starting Library Management Web UI..." +echo "API will be available at: http://${HOST}:${PORT}" +echo "Open index.html in your browser to use the Web UI" +echo + +# Try to open the browser (works on most systems) +if command -v xdg-open &> /dev/null; then + xdg-open index.html 2>/dev/null & +elif command -v open &> /dev/null; then + open index.html & +fi + +# Start the API server +uvicorn api:app --host "$HOST" --port "$PORT" --reload diff --git a/test_api.py b/test_api.py index 13f0a4f..d85793d 100644 --- a/test_api.py +++ b/test_api.py @@ -1,40 +1,37 @@ +"""Tests for the FastAPI REST API.""" + import pytest from fastapi.testclient import TestClient -import os -import json # Import the app and the Library class from api import app from library import Library +import config + @pytest.fixture def client(monkeypatch, tmp_path): """ This fixture creates a temporary, isolated environment for each test function. - + 1. `tmp_path`: A pytest fixture that creates a unique temporary directory. 2. `monkeypatch`: A pytest fixture to safely modify the behavior of our code for tests. """ # Define the path for a temporary library file inside the temp directory test_library_path = tmp_path / "test_library.json" - + # Create an empty library file to start fresh test_library_path.write_text("[]", encoding="utf-8") - # This is the magic: We tell our Library class to use the temporary file - # instead of the real "library.json" whenever it's initialized during tests. - # We define a proper function for __init__ that doesn't return a value. - def mock_init(self, filename=str(test_library_path)): - self.filename = filename - self.books = self.load_books() - - monkeypatch.setattr(Library, '__init__', mock_init) + # Patch the config to use the test file + monkeypatch.setattr(config, 'LIBRARY_FILE', str(test_library_path)) # The 'with' statement ensures FastAPI's lifespan events run correctly # for a clean startup and shutdown within the test. with TestClient(app) as c: yield c + # All tests now take 'client' as an argument, which provides the isolated environment. def test_list_all_books_empty(client): @@ -43,13 +40,15 @@ def test_list_all_books_empty(client): assert response.status_code == 200 assert response.json() == [] + def test_add_new_book(client): """Test adding a new book via the API successfully.""" + # Using a valid ISBN-13 (978-0-306-40615-7) book_data = { "title": "API Test Book", "author": "Tester", - "isbn": "99999", - "year": 2025 + "isbn": "9780306406157", + "year": 2024 } response = client.post("/books", json=book_data) assert response.status_code == 201 @@ -57,12 +56,39 @@ def test_add_new_book(client): list_response = client.get("/books") assert len(list_response.json()) == 1 - assert list_response.json()[0]["isbn"] == "99999" + assert list_response.json()[0]["isbn"] == "9780306406157" + + +def test_add_book_with_na_isbn(client): + """Test adding a book with N/A ISBN (for books without ISBN).""" + book_data = { + "title": "Book Without ISBN", + "author": "Anonymous", + "isbn": "N/A", + "year": 2000 + } + response = client.post("/books", json=book_data) + assert response.status_code == 201 + assert response.json()["isbn"] == "N/A" + + +def test_add_book_with_invalid_isbn_fails(client): + """Test that adding a book with an invalid ISBN returns a 422 error.""" + book_data = { + "title": "Invalid ISBN Book", + "author": "Tester", + "isbn": "12345", # Invalid ISBN + "year": 2024 + } + response = client.post("/books", json=book_data) + assert response.status_code == 422 + def test_add_duplicate_book_fails(client): """Test that adding a book with a duplicate ISBN results in a 400 error.""" - book_data = {"title": "Duplicate", "author": "Copy", "isbn": "11111", "year": 2025} - + # Using a valid ISBN-10 (0-306-40615-2) + book_data = {"title": "Duplicate", "author": "Copy", "isbn": "0306406152", "year": 2024} + response1 = client.post("/books", json=book_data) assert response1.status_code == 201 @@ -70,38 +96,54 @@ def test_add_duplicate_book_fails(client): assert response2.status_code == 400 assert "already exists" in response2.json()["detail"] + def test_get_single_book(client): """Test retrieving a single book by its ISBN after adding it.""" - book_data = {"title": "Findable Book", "author": "Seeker", "isbn": "88888", "year": 2022} + # Using a valid ISBN-13 + book_data = {"title": "Findable Book", "author": "Seeker", "isbn": "9780134685991", "year": 2022} client.post("/books", json=book_data) - - response = client.get("/books/88888") + + response = client.get("/books/9780134685991") assert response.status_code == 200 assert response.json()["title"] == "Findable Book" + def test_get_nonexistent_book(client): """Test that trying to retrieve a book that doesn't exist returns a 404 error.""" - response = client.get("/books/00000") + response = client.get("/books/0000000000") assert response.status_code == 404 + def test_update_existing_book(client): """Test updating an existing book.""" - book_data = {"title": "Old Title", "author": "Old Author", "isbn": "77777", "year": 2021} + # Using a valid ISBN-10 + book_data = {"title": "Old Title", "author": "Old Author", "isbn": "080442957X", "year": 2021} client.post("/books", json=book_data) - updated_data = {"title": "New Title", "author": "New Author", "isbn": "77777", "year": 2021, "available": False} - response = client.put("/books/77777", json=updated_data) + updated_data = {"title": "New Title", "author": "New Author", "isbn": "080442957X", "year": 2021, "available": False} + response = client.put("/books/080442957X", json=updated_data) assert response.status_code == 200 assert response.json()["title"] == "New Title" assert response.json()["available"] is False + def test_remove_existing_book(client): """Test removing a book.""" - book_data = {"title": "To Be Deleted", "author": "Deleter", "isbn": "66666", "year": 2020} + # Using a valid ISBN-13 + book_data = {"title": "To Be Deleted", "author": "Deleter", "isbn": "9780596007126", "year": 2020} client.post("/books", json=book_data) - response = client.delete("/books/66666") + response = client.delete("/books/9780596007126") assert response.status_code == 204 - get_response = client.get("/books/66666") + get_response = client.get("/books/9780596007126") assert get_response.status_code == 404 + + +def test_health_check(client): + """Test the health check endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "books_count" in data diff --git a/test_validators.py b/test_validators.py new file mode 100644 index 0000000..bf132c3 --- /dev/null +++ b/test_validators.py @@ -0,0 +1,125 @@ +"""Tests for the validators module.""" + +import pytest +from validators import ( + validate_isbn, + validate_year, + validate_title, + validate_author, + ValidationError, +) + + +class TestISBNValidation: + """Tests for ISBN validation.""" + + def test_valid_isbn10(self): + """Test valid ISBN-10 numbers.""" + # ISBN-10 with valid checksum + assert validate_isbn("0306406152") == "0306406152" + assert validate_isbn("0-306-40615-2") == "0306406152" # With hyphens + assert validate_isbn("080442957X") == "080442957X" # Ends with X + + def test_valid_isbn13(self): + """Test valid ISBN-13 numbers.""" + assert validate_isbn("9780306406157") == "9780306406157" + assert validate_isbn("978-0-306-40615-7") == "9780306406157" # With hyphens + + def test_invalid_isbn_length(self): + """Test ISBN with invalid length.""" + with pytest.raises(ValidationError, match="10 or 13 characters"): + validate_isbn("12345") + with pytest.raises(ValidationError, match="10 or 13 characters"): + validate_isbn("12345678901234") + + def test_invalid_isbn10_checksum(self): + """Test ISBN-10 with invalid checksum.""" + with pytest.raises(ValidationError, match="Invalid ISBN-10 checksum"): + validate_isbn("0306406151") # Wrong last digit + + def test_invalid_isbn13_checksum(self): + """Test ISBN-13 with invalid checksum.""" + with pytest.raises(ValidationError, match="Invalid ISBN-13 checksum"): + validate_isbn("9780306406158") # Wrong last digit + + def test_empty_isbn(self): + """Test empty ISBN.""" + with pytest.raises(ValidationError, match="cannot be empty"): + validate_isbn("") + + def test_na_isbn(self): + """Test N/A ISBN (allowed for backward compatibility).""" + assert validate_isbn("N/A") == "N/A" + assert validate_isbn("n/a") == "N/A" + assert validate_isbn("NA") == "N/A" + + def test_isbn10_invalid_characters(self): + """Test ISBN-10 with invalid characters.""" + with pytest.raises(ValidationError, match="9 digits"): + validate_isbn("030640615A") # A is not valid (only X for check digit) + + +class TestYearValidation: + """Tests for publication year validation.""" + + def test_valid_year(self): + """Test valid publication years.""" + assert validate_year(2024) == 2024 + assert validate_year(1984) == 1984 + assert validate_year(1450) == 1450 # First printed books + + def test_zero_year(self): + """Test year 0 (unknown) is allowed.""" + assert validate_year(0) == 0 + + def test_year_too_old(self): + """Test year before printing was invented.""" + with pytest.raises(ValidationError, match="before the first printed books"): + validate_year(1400) + + def test_year_in_future(self): + """Test year too far in the future.""" + with pytest.raises(ValidationError, match="in the future"): + validate_year(2100) + + +class TestTitleValidation: + """Tests for book title validation.""" + + def test_valid_title(self): + """Test valid titles.""" + assert validate_title("The Great Gatsby") == "The Great Gatsby" + assert validate_title(" Trimmed Title ") == "Trimmed Title" + + def test_empty_title(self): + """Test empty title.""" + with pytest.raises(ValidationError, match="cannot be empty"): + validate_title("") + with pytest.raises(ValidationError, match="cannot be empty"): + validate_title(" ") + + def test_title_too_long(self): + """Test title exceeding maximum length.""" + with pytest.raises(ValidationError, match="cannot exceed 500 characters"): + validate_title("A" * 501) + + +class TestAuthorValidation: + """Tests for author name validation.""" + + def test_valid_author(self): + """Test valid author names.""" + assert validate_author("F. Scott Fitzgerald") == "F. Scott Fitzgerald" + assert validate_author(" Trimmed Author ") == "Trimmed Author" + + def test_empty_author(self): + """Test empty author.""" + with pytest.raises(ValidationError, match="cannot be empty"): + validate_author("") + with pytest.raises(ValidationError, match="cannot be empty"): + validate_author(" ") + + def test_author_too_long(self): + """Test author name exceeding maximum length.""" + with pytest.raises(ValidationError, match="cannot exceed 300 characters"): + validate_author("A" * 301) diff --git a/validators.py b/validators.py new file mode 100644 index 0000000..1a6c70b --- /dev/null +++ b/validators.py @@ -0,0 +1,182 @@ +""" +Input validation utilities for the Library Management System. + +Provides validation functions for ISBN, year, and other book-related data. +""" + +import re +from datetime import datetime + + +class ValidationError(Exception): + """Raised when validation fails.""" + pass + + +def validate_isbn(isbn: str) -> str: + """ + Validate an ISBN-10 or ISBN-13 number. + + Args: + isbn: The ISBN string to validate (may contain hyphens or spaces). + + Returns: + The cleaned ISBN (digits only, or digits with X for ISBN-10). + + Raises: + ValidationError: If the ISBN format is invalid or checksum fails. + """ + if not isbn: + raise ValidationError("ISBN cannot be empty") + + # Handle special case for books without ISBN + if isbn.upper() in ("N/A", "NA", "NONE", "UNKNOWN"): + return "N/A" + + # Remove hyphens and spaces, convert to uppercase + cleaned = isbn.replace("-", "").replace(" ", "").upper() + + if len(cleaned) == 10: + return _validate_isbn10(cleaned) + elif len(cleaned) == 13: + return _validate_isbn13(cleaned) + else: + raise ValidationError( + f"ISBN must be 10 or 13 characters long, got {len(cleaned)}" + ) + + +def _validate_isbn10(isbn: str) -> str: + """ + Validate an ISBN-10 number. + + ISBN-10 checksum: sum of (digit * position) mod 11 should equal 0. + The last character can be 'X' representing 10. + """ + if not re.match(r"^\d{9}[\dX]$", isbn): + raise ValidationError( + "ISBN-10 must contain 9 digits followed by a digit or 'X'" + ) + + total = 0 + for i, char in enumerate(isbn): + if char == "X": + value = 10 + else: + value = int(char) + total += value * (10 - i) + + if total % 11 != 0: + raise ValidationError("Invalid ISBN-10 checksum") + + return isbn + + +def _validate_isbn13(isbn: str) -> str: + """ + Validate an ISBN-13 number. + + ISBN-13 checksum: alternating weights of 1 and 3, sum mod 10 should equal 0. + """ + if not re.match(r"^\d{13}$", isbn): + raise ValidationError("ISBN-13 must contain exactly 13 digits") + + total = 0 + for i, char in enumerate(isbn): + weight = 1 if i % 2 == 0 else 3 + total += int(char) * weight + + if total % 10 != 0: + raise ValidationError("Invalid ISBN-13 checksum") + + return isbn + + +def validate_year(year: int) -> int: + """ + Validate a publication year. + + Args: + year: The publication year to validate. + + Returns: + The validated year. + + Raises: + ValidationError: If the year is outside reasonable bounds. + """ + current_year = datetime.now().year + + # Allow year 0 for unknown publication years (backward compatibility) + if year == 0: + return year + + # Reasonable bounds: first printed book (1450) to next year + min_year = 1450 + max_year = current_year + 1 + + if year < min_year: + raise ValidationError( + f"Publication year {year} is before the first printed books ({min_year})" + ) + + if year > max_year: + raise ValidationError( + f"Publication year {year} is in the future (current year: {current_year})" + ) + + return year + + +def validate_title(title: str) -> str: + """ + Validate and clean a book title. + + Args: + title: The book title to validate. + + Returns: + The cleaned title. + + Raises: + ValidationError: If the title is empty or too long. + """ + if not title: + raise ValidationError("Title cannot be empty") + + cleaned = title.strip() + + if not cleaned: + raise ValidationError("Title cannot be empty or whitespace only") + + if len(cleaned) > 500: + raise ValidationError("Title cannot exceed 500 characters") + + return cleaned + + +def validate_author(author: str) -> str: + """ + Validate and clean an author name. + + Args: + author: The author name to validate. + + Returns: + The cleaned author name. + + Raises: + ValidationError: If the author name is empty or too long. + """ + if not author: + raise ValidationError("Author cannot be empty") + + cleaned = author.strip() + + if not cleaned: + raise ValidationError("Author cannot be empty or whitespace only") + + if len(cleaned) > 300: + raise ValidationError("Author name cannot exceed 300 characters") + + return cleaned