From 35ad2ebcabad9e9239ffe866e4beeff71e5a0b81 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Mon, 2 Jun 2025 16:34:47 +0300 Subject: [PATCH] a --- Dockerfile | 3 +- Makefile | 20 +++++++ app/database.py | 2 +- app/index.html | 54 +++++++++++++++++++ app/main.py | 132 ++++++++++++++++++++++++++++++++++++--------- docker-compose.yml | 8 +++ requirements.txt | 2 + tests/__init__.py | 0 tests/test_main.py | 70 ++++++++++++++++++++++++ 9 files changed, 265 insertions(+), 26 deletions(-) create mode 100644 Makefile create mode 100644 app/index.html create mode 100644 tests/__init__.py create mode 100644 tests/test_main.py diff --git a/Dockerfile b/Dockerfile index 992d426..1ca502b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /code COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -COPY ./app /code/app COPY alembic.ini ./ +COPY ./app /code/app +COPY ./tests /code/tests CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7939961 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: help build up start down destroy stop restart logs db-shell + +help: + @echo "Available commands:" + @echo " make build - Build the Docker images" + @echo " make up - Start the containers" + @echo " make down - Stop and remove the containers" + @echo " make db-shell - Access the database shell" + +build: + docker-compose -f docker-compose.yml build + +up: + docker-compose -f docker-compose.yml up -d + +down: + docker-compose -f docker-compose.yml down + +db-shell: + docker-compose -f docker-compose.yml exec timescale psql -Upostgres diff --git a/app/database.py b/app/database.py index 0199d4a..f6e7d41 100644 --- a/app/database.py +++ b/app/database.py @@ -6,7 +6,7 @@ DATABASE_URL = config('DATABASE_URL', cast=str, default='sqlite:///./bookapi.db' # Create database engine engine = create_engine(str(DATABASE_URL), echo=True) -SQLModel.metadata.create_all(engine) +# SQLModel.metadata.create_all(engine) # Get database session def get_session(): diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..dd79520 --- /dev/null +++ b/app/index.html @@ -0,0 +1,54 @@ + + + + Добро пожаловать в API + + + +

Добро пожаловать в API!

+ + + diff --git a/app/main.py b/app/main.py index c09d5d7..e70e791 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,13 @@ +from datetime import datetime +from pathlib import Path +from typing import List + from alembic import command from alembic.config import Config -from fastapi import FastAPI, Depends, HTTPException +from fastapi import FastAPI, Depends, Request, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse from sqlmodel import SQLModel, Session, select -from typing import List + from .database import engine, get_session from .models import Author, AuthorBase, Book, BookBase, AuthorBookLink @@ -20,6 +25,14 @@ app = FastAPI( "name": "books", "description": "Operations with books.", }, + { + "name": "relations", + "description": "Operations with relations.", + }, + { + "name": "misc", + "description": "Miscellaneous operations.", + } ] ) @@ -32,9 +45,21 @@ def on_startup(): command.upgrade(alembic_cfg, "head") # Root endpoint -@app.get("/", tags=["authors", "books"]) -async def hello_world(): - return {"message": "Hello world!"} +@app.get("/", tags=["misc"]) +async def root(request: Request, html: str = ""): + + if html != "": # API response + data = { + "title": app.title, + "version": app.version, + "description": app.description, + "status": "ok" + } + return JSONResponse({"message": "Hello world!", "data": data, "time": datetime.now(), }) + else: # Browser response + with open(Path(__file__).parent / "index.html", 'r', encoding='utf-8') as file: + html_content = file.read() + return HTMLResponse(html_content) # Create an author @app.post("/authors/", response_model=Author, tags=["authors"]) @@ -75,17 +100,11 @@ def delete_author(author_id: int, session: Session = Depends(get_session)): # Create a book with authors @app.post("/books/", response_model=Book, tags=["books"]) -def create_book(book: BookBase, author_ids: List[int] | None = None, session: Session = Depends(get_session)): +def create_book(book: BookBase, session: Session = Depends(get_session)): db_book = Book(title=book.title, description=book.description) session.add(db_book) session.commit() session.refresh(db_book) - # Create relationships if author_ids are provided - if author_ids: - for author_id in author_ids: - link = AuthorBookLink(author_id=author_id, book_id=db_book.id) - session.add(link) - session.commit() return db_book # Read books @@ -96,7 +115,7 @@ def read_books(session: Session = Depends(get_session)): # Update a book with authors @app.put("/books/{book_id}", response_model=Book, tags=["books"]) -def update_book(book_id: int, book: BookBase, author_ids: List[int] | None = None, session: Session = Depends(get_session)): +def update_book(book_id: int, book: BookBase, session: Session = Depends(get_session)): db_book = session.get(Book, book_id) if not db_book: raise HTTPException(status_code=404, detail="Book not found") @@ -105,17 +124,6 @@ def update_book(book_id: int, book: BookBase, author_ids: List[int] | None = Non db_book.description = book.description session.commit() session.refresh(db_book) - # Update relationships if author_ids are provided - if author_ids is not None: - # Clear existing relationships - existing_links = session.exec(select(AuthorBookLink).where(AuthorBookLink.book_id == book_id)).all() - for link in existing_links: - session.delete(link) - # Create new relationships - for author_id in author_ids: - link = AuthorBookLink(author_id=author_id, book_id=db_book.id) - session.add(link) - session.commit() return db_book # Delete a book @@ -128,3 +136,79 @@ def delete_book(book_id: int, session: Session = Depends(get_session)): session.delete(db_book) session.commit() return book + +# Add author to book +@app.post("/relationships/", response_model=AuthorBookLink, tags=["relations"]) +def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(get_session)): + # Check if author and book exist + author = session.get(Author, author_id) + if not author: + raise HTTPException(status_code=404, detail="Author not found") + + book = session.get(Book, book_id) + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + # Check if relationship already exists + existing_link = session.exec( + select(AuthorBookLink) + .where(AuthorBookLink.author_id == author_id) + .where(AuthorBookLink.book_id == book_id) + ).first() + + if existing_link: + raise HTTPException(status_code=400, detail="Relationship already exists") + + # Create new relationship + link = AuthorBookLink(author_id=author_id, book_id=book_id) + session.add(link) + session.commit() + session.refresh(link) + return link + +# Remove author from book +@app.delete("/relationships/", tags=["relations"]) +def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)): + # Find the relationship + link = session.exec( + select(AuthorBookLink) + .where(AuthorBookLink.author_id == author_id) + .where(AuthorBookLink.book_id == book_id) + ).first() + + if not link: + raise HTTPException(status_code=404, detail="Relationship not found") + + session.delete(link) + session.commit() + return {"message": "Relationship removed successfully"} + +# Get all authors for a book +@app.get("/books/{book_id}/authors/", response_model=List[Author], tags=["books", "relations"]) +def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): + book = session.get(Book, book_id) + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + authors = session.exec( + select(Author) + .join(AuthorBookLink) + .where(AuthorBookLink.book_id == book_id) + ).all() + + return authors + +# Get all books for an author +@app.get("/authors/{author_id}/books/", response_model=List[Book], tags=["authors", "relations"]) +def get_books_for_author(author_id: int, session: Session = Depends(get_session)): + author = session.get(Author, author_id) + if not author: + raise HTTPException(status_code=404, detail="Author not found") + + books = session.exec( + select(Book) + .join(AuthorBookLink) + .where(AuthorBookLink.author_id == author_id) + ).all() + + return books diff --git a/docker-compose.yml b/docker-compose.yml index e301d3b..c2bf913 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,5 +21,13 @@ services: depends_on: - db + test: + build: . + command: pytest + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + depends_on: + - db + volumes: postgres_data: diff --git a/requirements.txt b/requirements.txt index b263068..985a34d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ sqlmodel psycopg2-binary python-decouple alembic +pytest +httpx diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..1b46e73 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,70 @@ +import pytest # pyright: ignore +from fastapi.testclient import TestClient +from app.main import app + +@pytest.fixture() +def client(): + with TestClient(app) as test_client: + yield test_client + +# Тесты для авторов +def test_create_author(client): + response = client.post("/authors/", json={"name": "Author Name"}) + assert response.status_code == 200 + assert response.json()["name"] == "Author Name" + +def test_read_authors(client): + response = client.get("/authors/") + assert response.status_code == 200 + assert isinstance(response.json(), list) # Проверяем, что ответ - это список + +def test_update_author(client): + # Сначала создаем автора, чтобы его обновить + create_response = client.post("/authors/", json={"name": "Author Name"}) + author_id = create_response.json()["id"] + + response = client.put(f"/authors/{author_id}", json={"name": "Updated Author Name"}) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Author Name" + +def test_delete_author(client): + # Сначала создаем автора, чтобы его удалить + create_response = client.post("/authors/", json={"name": "Author Name"}) + author_id = create_response.json()["id"] + author_name = create_response.json()["name"] + + response = client.delete(f"/authors/{author_id}") + assert response.status_code == 200 + assert response.json()["name"] == author_name + +# Тесты для книг +def test_create_book(client): + response = client.post("/books/", json={"title": "Book Title", "description": "Book Description"}) + assert response.status_code == 200 + assert response.json()["title"] == "Book Title" + +def test_read_books(client): + response = client.get("/books/") + assert response.status_code == 200 + assert isinstance(response.json(), list) # Проверяем, что ответ - это список + +def test_update_book(client): + # Сначала создаем книгу, чтобы ее обновить + create_response = client.post("/books/", json={"title": "Book Title", "description": "Book Description"}) + book_id = create_response.json()["id"] + + response = client.put(f"/books/{book_id}", json={"title": "Updated Book Title", "description": "Updated Description"}) + assert response.status_code == 200 + assert response.json()["title"] == "Updated Book Title" + +def test_delete_book(client): + # Сначала создаем книгу, чтобы ее удалить + create_response = client.post("/books/", json={"title": "Book Title", "description": "Book Description"}) + book_id = create_response.json()["id"] + book_title = create_response.json()["title"] + book_description = create_response.json()["description"] + + response = client.delete(f"/books/{book_id}") + assert response.status_code == 200 + assert response.json()["title"] == book_title + assert response.json()["description"] == book_description