This commit is contained in:
2025-06-02 16:34:47 +03:00
parent 8523328b9f
commit 35ad2ebcab
9 changed files with 265 additions and 26 deletions

View File

@@ -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"]

20
Makefile Normal file
View File

@@ -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

View File

@@ -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():

54
app/index.html Normal file
View File

@@ -0,0 +1,54 @@
<!doctype html>
<html>
<head>
<title>Добро пожаловать в API</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 15px 0;
}
a {
display: inline-block;
padding: 8px 15px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
a:hover {
background-color: #2980b9;
}
p {
margin: 5px 0;
}
</style>
</head>
<body>
<h1>Добро пожаловать в API!</h1>
<ul>
<li>
<p>Попробуйте <a href="/docs">Swagger UI</a></p>
</li>
<li>
<p>Попробуйте <a href="/redoc">ReDoc</a></p>
</li>
</ul>
</body>
</html>

View File

@@ -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

View File

@@ -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:

View File

@@ -4,3 +4,5 @@ sqlmodel
psycopg2-binary
python-decouple
alembic
pytest
httpx

0
tests/__init__.py Normal file
View File

70
tests/test_main.py Normal file
View File

@@ -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