Enhance Anime Information API with Advanced Features

- Add proper model relationships for better querying
- Implement character model and endpoints for managing anime characters
- Add advanced filtering options (year, score range, source, studio, etc.)
- Add statistics endpoint for analyzing anime collection
- Include pagination metadata for easier navigation
- Create Alembic migration for the new character model
- Update README with new features and documentation
This commit is contained in:
Automated Action 2025-05-17 21:58:44 +00:00
parent 4d6c2e1778
commit bf28ab6f8e
9 changed files with 397 additions and 11 deletions

View File

@ -6,7 +6,10 @@ This is a FastAPI application that provides anime information via a RESTful API.
- **Anime Information**: Comprehensive anime data including titles, synopsis, episodes, scores, etc.
- **Genre Management**: Create, read, update, and delete anime genres
- **Search Capabilities**: Search anime by title, genre, or status
- **Character Management**: Create, read, update, and delete anime characters
- **Advanced Search**: Search anime with multiple filters including year, score range, source, studio, etc.
- **Smart Pagination**: Built-in pagination with metadata for easy navigation
- **Statistics Dashboard**: Get statistical insights about the anime collection
- **Health Endpoint**: Check the health of the application and its database connection
## Tech Stack
@ -82,7 +85,8 @@ The API will be available at http://localhost:8000, and the interactive API docu
- `GET /health`: Check application health
### Anime
- `GET /api/v1/anime/`: Get anime list with search capabilities
- `GET /api/v1/anime/`: Get anime list with advanced filtering and pagination
- `GET /api/v1/anime/statistics`: Get statistics about the anime collection
- `POST /api/v1/anime/`: Create new anime
- `GET /api/v1/anime/{id}`: Get anime by ID
- `PUT /api/v1/anime/{id}`: Update anime
@ -93,4 +97,41 @@ The API will be available at http://localhost:8000, and the interactive API docu
- `POST /api/v1/genres/`: Create new genre
- `GET /api/v1/genres/{id}`: Get genre by ID
- `PUT /api/v1/genres/{id}`: Update genre
- `DELETE /api/v1/genres/{id}`: Delete genre
- `DELETE /api/v1/genres/{id}`: Delete genre
### Characters
- `GET /api/v1/characters/`: Get all characters
- `POST /api/v1/characters/`: Create new character
- `GET /api/v1/characters/{id}`: Get character by ID
- `PUT /api/v1/characters/{id}`: Update character
- `DELETE /api/v1/characters/{id}`: Delete character
- `GET /api/v1/characters/anime/{anime_id}`: Get all characters for a specific anime
## Advanced Search Parameters
The anime search endpoint supports the following parameters:
- `title`: Filter by title (partial match)
- `genre_id`: Filter by genre ID
- `status`: Filter by anime status (airing, finished, upcoming)
- `year_from`: Filter by starting year (inclusive)
- `year_to`: Filter by ending year (inclusive)
- `score_min`: Filter by minimum score (inclusive)
- `score_max`: Filter by maximum score (inclusive)
- `source`: Filter by source material (manga, light novel, etc.)
- `studio`: Filter by studio name (partial match)
- `sort_by`: Sort by field (id, title, score, popularity, etc.)
- `sort_order`: Sort order (asc, desc)
- `skip`: Number of items to skip for pagination
- `limit`: Maximum number of items to return
## Statistics
The statistics endpoint provides the following information:
- Total anime count
- Average score and episode count
- Distribution by status, source, and studio
- Yearly distribution of releases
- Score distribution
- Top genres

View File

@ -0,0 +1,47 @@
"""Add character model
Revision ID: c57a40c2b63e
Revises: b57a40c2b63d
Create Date: 2023-10-18 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c57a40c2b63e'
down_revision = 'b57a40c2b63d'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create character table
op.create_table(
'character',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('role', sa.String(50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('voice_actor', sa.String(255), nullable=True),
sa.Column('image_url', sa.String(255), nullable=True),
sa.Column('anime_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['anime_id'], ['anime.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_character_id'), 'character', ['id'], unique=False)
op.create_index(op.f('ix_character_name'), 'character', ['name'], unique=False)
# Add unique constraint to anime_genre table (if not already present)
op.create_unique_constraint('uix_anime_genre', 'animegenre', ['anime_id', 'genre_id'])
def downgrade() -> None:
# Drop the character table
op.drop_index(op.f('ix_character_name'), table_name='character')
op.drop_index(op.f('ix_character_id'), table_name='character')
op.drop_table('character')
# Drop unique constraint from anime_genre
op.drop_constraint('uix_anime_genre', 'animegenre', type_='unique')

View File

@ -16,19 +16,80 @@ def search_anime(
title: Optional[str] = None,
genre_id: Optional[int] = None,
status: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
score_min: Optional[float] = None,
score_max: Optional[float] = None,
source: Optional[str] = None,
studio: Optional[str] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "asc",
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Search for anime with filters.
Search for anime with advanced filters.
- **title**: Filter by title (partial match)
- **genre_id**: Filter by genre ID
- **status**: Filter by anime status (airing, finished, upcoming)
- **year_from**: Filter by starting year (inclusive)
- **year_to**: Filter by ending year (inclusive)
- **score_min**: Filter by minimum score (inclusive)
- **score_max**: Filter by maximum score (inclusive)
- **source**: Filter by source material (manga, light novel, etc.)
- **studio**: Filter by studio name (partial match)
- **sort_by**: Sort by field (id, title, score, popularity, etc.)
- **sort_order**: Sort order (asc, desc)
- **skip**: Number of items to skip for pagination
- **limit**: Maximum number of items to return
"""
anime = crud.anime.search(db, title=title, genre_id=genre_id, status=status, skip=skip, limit=limit)
total = crud.anime.search_count(db, title=title, genre_id=genre_id, status=status)
anime = crud.anime.search(
db,
title=title,
genre_id=genre_id,
status=status,
year_from=year_from,
year_to=year_to,
score_min=score_min,
score_max=score_max,
source=source,
studio=studio,
sort_by=sort_by,
sort_order=sort_order,
skip=skip,
limit=limit
)
total = crud.anime.search_count(
db,
title=title,
genre_id=genre_id,
status=status,
year_from=year_from,
year_to=year_to,
score_min=score_min,
score_max=score_max,
source=source,
studio=studio
)
# Calculate pagination metadata
page = skip // limit + 1 if limit > 0 else 1
total_pages = (total + limit - 1) // limit if limit > 0 else 1
has_next = page < total_pages
has_prev = page > 1
return {
"results": anime,
"total": total,
"page": skip // limit + 1 if limit > 0 else 1,
"size": limit
"page": page,
"size": limit,
"pages": total_pages,
"has_next": has_next,
"has_prev": has_prev,
"next_page": page + 1 if has_next else None,
"prev_page": page - 1 if has_prev else None
}
@ -89,4 +150,22 @@ def delete_anime(
if not anime:
raise HTTPException(status_code=404, detail="Anime not found")
crud.anime.remove(db=db, id=id)
return None
return None
@router.get("/statistics", response_model=schemas.anime.AnimeStatistics)
def get_anime_statistics(
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get statistics about the anime collection.
Returns various statistical data including:
- Total anime count
- Average score and episode count
- Distribution by status, source, and studio
- Yearly distribution of releases
- Score distribution
- Top genres
"""
return crud.anime.get_statistics(db=db)

View File

@ -0,0 +1,114 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, schemas
from app.api import deps
router = APIRouter()
@router.get("/", response_model=List[schemas.character.Character])
def read_characters(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve characters.
"""
return crud.character.get_multi(db, skip=skip, limit=limit)
@router.post("/", response_model=schemas.character.Character)
def create_character(
*,
db: Session = Depends(deps.get_db),
character_in: schemas.character.CharacterCreate,
) -> Any:
"""
Create new character.
"""
# Verify anime exists
anime = crud.anime.get(db=db, id=character_in.anime_id)
if not anime:
raise HTTPException(
status_code=404,
detail=f"Anime with id {character_in.anime_id} not found",
)
return crud.character.create(db=db, obj_in=character_in)
@router.get("/{id}", response_model=schemas.character.Character)
def read_character(
*,
db: Session = Depends(deps.get_db),
id: int,
) -> Any:
"""
Get character by ID.
"""
character = crud.character.get(db=db, id=id)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
return character
@router.put("/{id}", response_model=schemas.character.Character)
def update_character(
*,
db: Session = Depends(deps.get_db),
id: int,
character_in: schemas.character.CharacterUpdate,
) -> Any:
"""
Update a character.
"""
character = crud.character.get(db=db, id=id)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
# If updating anime_id, verify new anime exists
if character_in.anime_id is not None and character_in.anime_id != character.anime_id:
anime = crud.anime.get(db=db, id=character_in.anime_id)
if not anime:
raise HTTPException(
status_code=404,
detail=f"Anime with id {character_in.anime_id} not found",
)
return crud.character.update(db=db, db_obj=character, obj_in=character_in)
@router.delete("/{id}", response_model=None, status_code=204)
def delete_character(
*,
db: Session = Depends(deps.get_db),
id: int,
) -> Any:
"""
Delete a character.
"""
character = crud.character.get(db=db, id=id)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
crud.character.remove(db=db, id=id)
return None
@router.get("/anime/{anime_id}", response_model=List[schemas.character.Character])
def read_anime_characters(
*,
db: Session = Depends(deps.get_db),
anime_id: int,
) -> Any:
"""
Get all characters for a specific anime.
"""
# Verify anime exists
anime = crud.anime.get(db=db, id=anime_id)
if not anime:
raise HTTPException(status_code=404, detail=f"Anime with id {anime_id} not found")
return crud.character.get_by_anime(db=db, anime_id=anime_id)

View File

@ -0,0 +1,15 @@
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.character import Character
from app.schemas.character import CharacterCreate, CharacterUpdate
class CRUDCharacter(CRUDBase[Character, CharacterCreate, CharacterUpdate]):
def get_by_anime(self, db: Session, *, anime_id: int) -> List[Character]:
"""Get all characters for a specific anime"""
return db.query(Character).filter(Character.anime_id == anime_id).all()
character = CRUDCharacter(Character)

View File

@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Float, Text, Date
from sqlalchemy.orm import relationship
from app.db.base_class import Base
@ -18,4 +19,7 @@ class Anime(Base):
popularity = Column(Integer, nullable=True)
studio = Column(String(100), nullable=True)
source = Column(String(50), nullable=True) # manga, light novel, etc.
image_url = Column(String(255), nullable=True)
image_url = Column(String(255), nullable=True)
# Relationships
characters = relationship("Character", back_populates="anime", cascade="all, delete-orphan")

25
app/models/character.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Date
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Character(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), index=True, nullable=False)
role = Column(String(50), nullable=True) # Main, Supporting, Antagonist
description = Column(Text, nullable=True)
voice_actor = Column(String(255), nullable=True)
image_url = Column(String(255), nullable=True)
anime_id = Column(Integer, ForeignKey("anime.id", ondelete="CASCADE"), nullable=False)
# Additional character details
age = Column(String(50), nullable=True) # Can be a range or "Unknown"
gender = Column(String(50), nullable=True)
birth_date = Column(Date, nullable=True)
height = Column(String(50), nullable=True) # in cm
weight = Column(String(50), nullable=True) # in kg
blood_type = Column(String(10), nullable=True)
popularity_rank = Column(Integer, nullable=True)
# Relationships
anime = relationship("Anime", back_populates="characters")

View File

@ -1,8 +1,13 @@
from typing import Optional, List
from typing import Optional, List, TYPE_CHECKING, ForwardRef
from datetime import date
from pydantic import BaseModel, Field
from app.schemas.genre import Genre
if TYPE_CHECKING:
from app.schemas.character import Character
else:
Character = ForwardRef("Character")
class AnimeBase(BaseModel):
title: str
@ -39,6 +44,13 @@ class Anime(AnimeBase):
from_attributes = True
class AnimeWithCharacters(Anime):
characters: Optional[List[Character]] = []
class Config:
from_attributes = True
class AnimeSearchResults(BaseModel):
results: List[Anime]
total: int

49
app/schemas/character.py Normal file
View File

@ -0,0 +1,49 @@
from typing import Optional
from datetime import date
from pydantic import BaseModel
class CharacterBase(BaseModel):
name: str
role: Optional[str] = None
description: Optional[str] = None
voice_actor: Optional[str] = None
image_url: Optional[str] = None
age: Optional[str] = None
gender: Optional[str] = None
birth_date: Optional[date] = None
height: Optional[str] = None
weight: Optional[str] = None
blood_type: Optional[str] = None
popularity_rank: Optional[int] = None
class CharacterCreate(CharacterBase):
anime_id: int
class CharacterUpdate(CharacterBase):
name: Optional[str] = None
anime_id: Optional[int] = None
class Character(CharacterBase):
id: int
anime_id: int
class Config:
from_attributes = True
class CharacterWithAnime(Character):
anime_title: str = None
class Config:
from_attributes = True
class CharacterSearchResults(BaseModel):
results: list[Character]
total: int
page: int
size: int