From bf28ab6f8e12098de366ae92f6d8482aada54188 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Sat, 17 May 2025 21:58:44 +0000 Subject: [PATCH] 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 --- README.md | 47 +++++++- .../versions/20231018_add_character_model.py | 47 ++++++++ app/api/endpoints/anime.py | 91 +++++++++++++- app/api/endpoints/characters.py | 114 ++++++++++++++++++ app/crud/crud_character.py | 15 +++ app/models/anime.py | 6 +- app/models/character.py | 25 ++++ app/schemas/anime.py | 14 ++- app/schemas/character.py | 49 ++++++++ 9 files changed, 397 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/20231018_add_character_model.py create mode 100644 app/api/endpoints/characters.py create mode 100644 app/crud/crud_character.py create mode 100644 app/models/character.py create mode 100644 app/schemas/character.py diff --git a/README.md b/README.md index b3b943f..843ad54 100644 --- a/README.md +++ b/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 \ No newline at end of file +- `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 \ No newline at end of file diff --git a/alembic/versions/20231018_add_character_model.py b/alembic/versions/20231018_add_character_model.py new file mode 100644 index 0000000..5070ea5 --- /dev/null +++ b/alembic/versions/20231018_add_character_model.py @@ -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') \ No newline at end of file diff --git a/app/api/endpoints/anime.py b/app/api/endpoints/anime.py index a016fcd..25d2202 100644 --- a/app/api/endpoints/anime.py +++ b/app/api/endpoints/anime.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/api/endpoints/characters.py b/app/api/endpoints/characters.py new file mode 100644 index 0000000..f324cdf --- /dev/null +++ b/app/api/endpoints/characters.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_character.py b/app/crud/crud_character.py new file mode 100644 index 0000000..2f896c9 --- /dev/null +++ b/app/crud/crud_character.py @@ -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) \ No newline at end of file diff --git a/app/models/anime.py b/app/models/anime.py index 61520ca..d9559b0 100644 --- a/app/models/anime.py +++ b/app/models/anime.py @@ -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) \ No newline at end of file + image_url = Column(String(255), nullable=True) + + # Relationships + characters = relationship("Character", back_populates="anime", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/models/character.py b/app/models/character.py new file mode 100644 index 0000000..ce6eaca --- /dev/null +++ b/app/models/character.py @@ -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") \ No newline at end of file diff --git a/app/schemas/anime.py b/app/schemas/anime.py index 93e9994..fcd2462 100644 --- a/app/schemas/anime.py +++ b/app/schemas/anime.py @@ -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 diff --git a/app/schemas/character.py b/app/schemas/character.py new file mode 100644 index 0000000..387ebd6 --- /dev/null +++ b/app/schemas/character.py @@ -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 \ No newline at end of file