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:
parent
4d6c2e1778
commit
bf28ab6f8e
47
README.md
47
README.md
@ -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
|
47
alembic/versions/20231018_add_character_model.py
Normal file
47
alembic/versions/20231018_add_character_model.py
Normal 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')
|
@ -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)
|
114
app/api/endpoints/characters.py
Normal file
114
app/api/endpoints/characters.py
Normal 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)
|
15
app/crud/crud_character.py
Normal file
15
app/crud/crud_character.py
Normal 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)
|
@ -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
25
app/models/character.py
Normal 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")
|
@ -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
49
app/schemas/character.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user