Global refactoring of the project to use poetry and implement tests,

fixing bugs, changing the handling of dto and db models, preparing to
add new functionality
This commit is contained in:
2025-06-24 13:30:35 +03:00
parent 51a6ba75c0
commit 6658d773bf
58 changed files with 2521 additions and 1008 deletions

View File

6
library_service/api.py Normal file
View File

@@ -0,0 +1,6 @@
from fastapi import APIRouter
import asyncpg
router = APIRouter(
prefix='/devices'
)

28
library_service/main.py Normal file
View File

@@ -0,0 +1,28 @@
from alembic import command
from alembic.config import Config
from contextlib import asynccontextmanager
from fastapi import FastAPI
from toml import load
from .settings import engine, get_app
from .routers import api_router
from .routers.misc import get_info
app = get_app()
alembic_cfg = Config("alembic.ini")
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[+] Initializing...")
# Initialize the database
with engine.begin() as connection:
alembic_cfg.attributes['connection'] = connection
command.upgrade(alembic_cfg, "head")
print("[+] Starting...")
yield # Here FastAPI will start handling requests;
print("[+] Application shutdown")
# Include routers
app.include_router(api_router)

View File

@@ -0,0 +1,2 @@
from .dto import *
from .db import *

View File

@@ -0,0 +1,7 @@
from .author import Author
from .book import Book
from .links import AuthorBookLink, AuthorWithBooks, BookWithAuthors
__all__ = [
'Author', 'Book', 'AuthorBookLink', 'AuthorWithBooks', 'BookWithAuthors'
]

View File

@@ -0,0 +1,14 @@
from typing import List, Optional, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from ..dto.author import AuthorBase
from .links import AuthorBookLink
if TYPE_CHECKING:
from .book import Book
class Author(AuthorBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="authors",
link_model=AuthorBookLink
)

View File

@@ -0,0 +1,14 @@
from typing import List, Optional, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from ..dto.book import BookBase
from .links import AuthorBookLink
if TYPE_CHECKING:
from .author import Author
class Book(BookBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
authors: List["Author"] = Relationship(
back_populates="books",
link_model=AuthorBookLink
)

View File

@@ -0,0 +1,15 @@
from sqlmodel import SQLModel, Field
from typing import List
from library_service.models.dto.author import AuthorRead
from library_service.models.dto.book import BookRead
class AuthorBookLink(SQLModel, table=True):
author_id: int | None = Field(default=None, foreign_key="author.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class AuthorWithBooks(AuthorRead):
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(BookRead):
authors: List[AuthorRead] = Field(default_factory=list)

View File

@@ -0,0 +1,15 @@
from .author import (
AuthorBase, AuthorCreate, AuthorUpdate,
AuthorRead, AuthorList
)
from .book import (
BookBase, BookCreate, BookUpdate,
BookRead, BookList
)
# from .common import PaginatedResponse
__all__ = [
'AuthorBase', 'AuthorCreate', 'AuthorUpdate', 'AuthorRead', 'AuthorList',
'BookBase', 'BookCreate', 'BookUpdate', 'BookRead', 'BookList',
# 'PaginatedResponse'
]

View File

@@ -0,0 +1,25 @@
from sqlmodel import SQLModel
from pydantic import ConfigDict
from typing import Optional, List
class AuthorBase(SQLModel):
name: str
model_config = ConfigDict( #pyright: ignore
json_schema_extra={
"example": {"name": "author_name"}
}
)
class AuthorCreate(AuthorBase):
pass
class AuthorUpdate(SQLModel):
name: Optional[str] = None
class AuthorRead(AuthorBase):
id: int
class AuthorList(SQLModel):
authors: List[AuthorRead]
total: int

View File

@@ -0,0 +1,30 @@
from sqlmodel import SQLModel
from pydantic import ConfigDict
from typing import Optional, List
class BookBase(SQLModel):
title: str
description: str
model_config = ConfigDict( #pyright: ignore
json_schema_extra={
"example": {
"title": "book_title",
"description": "book_description"
}
}
)
class BookCreate(BookBase):
pass
class BookUpdate(SQLModel):
title: Optional[str] = None
description: Optional[str] = None
class BookRead(BookBase):
id: int
class BookList(SQLModel):
books: List[BookRead]
total: int

View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter
from .authors import router as authors_router
from .books import router as books_router
from .relationships import router as relationships_router
from .misc import router as misc_router
api_router = APIRouter()
# Including all routers
api_router.include_router(authors_router)
api_router.include_router(books_router)
api_router.include_router(relationships_router)
api_router.include_router(misc_router)

View File

@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
from library_service.models.dto import (
AuthorCreate, AuthorUpdate, AuthorRead,
AuthorList, BookRead
)
router = APIRouter(prefix="/authors", tags=["authors"])
# Create an author
@router.post("/", response_model=AuthorRead)
def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
db_author = Author(**author.model_dump())
session.add(db_author)
session.commit()
session.refresh(db_author)
return AuthorRead(**db_author.model_dump())
# Read authors
@router.get("/", response_model=AuthorList)
def read_authors(session: Session = Depends(get_session)):
authors = session.exec(select(Author)).all()
return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors],
total=len(authors)
)
# Read an author with their books
@router.get("/{author_id}", response_model=AuthorWithBooks)
def get_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()
book_reads = [BookRead(**book.model_dump()) for book in books]
author_data = author.model_dump()
author_data['books'] = book_reads
return AuthorWithBooks(**author_data)
# Update an author
@router.put("/{author_id}", response_model=AuthorRead)
def update_author(
author_id: int,
author: AuthorUpdate,
session: Session = Depends(get_session)
):
db_author = session.get(Author, author_id)
if not db_author:
raise HTTPException(status_code=404, detail="Author not found")
update_data = author.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_author, field, value)
session.commit()
session.refresh(db_author)
return AuthorRead(**db_author.model_dump())
# Delete an author
@router.delete("/{author_id}", response_model=AuthorRead)
def delete_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")
author_read = AuthorRead(**author.model_dump())
session.delete(author)
session.commit()
return author_read

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import Deque, List
from library_service.settings import get_session
from library_service.models.db import Book, Author, AuthorBookLink, BookWithAuthors
from library_service.models.dto import (
BookCreate, BookUpdate, BookRead,
BookList, AuthorRead
)
router = APIRouter(prefix="/books", tags=["books"])
# Create a book
@router.post("/", response_model=Book)
def create_book(book: BookCreate, session: Session = Depends(get_session)):
db_book = Book(**book.model_dump())
session.add(db_book)
session.commit()
session.refresh(db_book)
return BookRead(**db_book.model_dump())
# Read books
@router.get("/", response_model=BookList)
def read_books(session: Session = Depends(get_session)):
books = session.exec(select(Book)).all()
return BookList(
books=[BookRead(**book.model_dump()) for book in books],
total=len(books)
)
# Read a book
@router.get("/{book_id}", response_model=BookWithAuthors)
def get_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")
return BookWithAuthors(**book.model_dump())
# Update a book
@router.put("/{book_id}", response_model=Book)
def update_book(book_id: int, book: BookUpdate, 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")
db_book.title = book.title or db_book.title
db_book.description = book.description or db_book.description
session.commit()
session.refresh(db_book)
return db_book
# Delete a book
@router.delete("/{book_id}", response_model=BookRead)
def delete_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")
book_read = BookRead(id=(book.id or 0), title=book.title, description=book.description)
session.delete(book)
session.commit()
return book_read
# Get all authors for a book
@router.get("/{book_id}/authors/", response_model=List[AuthorRead])
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 [AuthorRead(**author.model_dump()) for author in authors]

View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter, Request, FastAPI
from fastapi.params import Depends
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from datetime import datetime
from typing import Dict
from httpx import get
from library_service.settings import get_app
# Templates initialization
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(tags=["misc"])
# Formatted information about the application
def get_info(app) -> Dict:
return {
"status": "ok",
"app_info": {
"title": app.title,
"version": app.version,
"description": app.description,
},
"server_time": datetime.now().isoformat(),
}
# Root endpoint
@router.get("/", response_class=HTMLResponse)
async def root(request: Request, app=Depends(get_app)):
return templates.TemplateResponse(request, "index.html", get_info(app))
# API Information endpoint
@router.get("/api/info")
async def api_info(app=Depends(get_app)):
return JSONResponse(content=get_info(app))

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List, Dict
from library_service.settings import get_session
from library_service.models.db import Book, Author, AuthorBookLink
router = APIRouter(prefix="/relationships", tags=["relations"])
# Add author to book
@router.post("/", response_model=AuthorBookLink)
def add_author_to_book(author_id: int, book_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")
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
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")
link = AuthorBookLink(author_id=author_id, book_id=book_id)
session.add(link)
session.commit()
session.refresh(link)
return link
# Remove author from book
@router.delete("/", response_model=Dict[str, str])
def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)):
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"}

View File

@@ -0,0 +1,52 @@
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from sqlmodel import create_engine, SQLModel, Session
from toml import load
load_dotenv()
with open("pyproject.toml") as f:
config = load(f)
# Dependency to get the FastAPI application instance
def get_app() -> FastAPI:
return FastAPI(
title=config["tool"]["poetry"]["name"],
description=config["tool"]["poetry"]["description"],
version=config["tool"]["poetry"]["version"],
openapi_tags=[
{
"name": "authors",
"description": "Operations with authors.",
},
{
"name": "books",
"description": "Operations with books.",
},
{
"name": "relations",
"description": "Operations with relations.",
},
{
"name": "misc",
"description": "Miscellaneous operations.",
}
]
)
USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB")
HOST = os.getenv("POSTGRES_SERVER")
if not USER or not PASSWORD or not DATABASE or not HOST:
raise ValueError("Missing environment variables")
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}"
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
# Dependency to get a database session
def get_session():
with Session(engine) as session:
yield session

View File

@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_info.title }}</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>Welcome to {{ app_info.title }}!</h1>
<p>Description: {{ app_info.description }}</p>
<p>Version: {{ app_info.version }}</p>
<p>Current Time: {{ server_time }}</p>
<p>Status: {{ status }}</p>
<ul>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
</ul>
</body>
</html>