diff --git a/README.md b/README.md index 843ad54..a4fe194 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The API will be available at http://localhost:8000, and the interactive API docu - `DELETE /api/v1/genres/{id}`: Delete genre ### Characters -- `GET /api/v1/characters/`: Get all characters +- `GET /api/v1/characters/`: Search characters with filters (name, anime_id, role) - `POST /api/v1/characters/`: Create new character - `GET /api/v1/characters/{id}`: Get character by ID - `PUT /api/v1/characters/{id}`: Update character @@ -109,6 +109,8 @@ The API will be available at http://localhost:8000, and the interactive API docu ## Advanced Search Parameters +### Anime Search Parameters + The anime search endpoint supports the following parameters: - `title`: Filter by title (partial match) @@ -125,6 +127,16 @@ The anime search endpoint supports the following parameters: - `skip`: Number of items to skip for pagination - `limit`: Maximum number of items to return +### Character Search Parameters + +The character search endpoint supports the following parameters: + +- `name`: Filter by character name (partial match) +- `anime_id`: Filter by anime ID +- `role`: Filter by character role (Main, Supporting, Antagonist, etc.) +- `skip`: Number of items to skip for pagination +- `limit`: Maximum number of items to return + ## Statistics The statistics endpoint provides the following information: diff --git a/alembic/versions/20231018_add_character_table.py b/alembic/versions/20231018_add_character_table.py new file mode 100644 index 0000000..2cf03ba --- /dev/null +++ b/alembic/versions/20231018_add_character_table.py @@ -0,0 +1,47 @@ +"""Add character table + +Revision ID: 9a87c5ed9d12 +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 = '9a87c5ed9d12' +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.Column('age', sa.String(50), nullable=True), + sa.Column('gender', sa.String(50), nullable=True), + sa.Column('birth_date', sa.Date(), nullable=True), + sa.Column('height', sa.String(50), nullable=True), + sa.Column('weight', sa.String(50), nullable=True), + sa.Column('blood_type', sa.String(10), nullable=True), + sa.Column('popularity_rank', sa.Integer(), nullable=True), + 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) + + +def downgrade() -> None: + 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') \ No newline at end of file diff --git a/app/api/api.py b/app/api/api.py index 9a4458f..22182c8 100644 --- a/app/api/api.py +++ b/app/api/api.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.endpoints import anime, genres, health +from app.api.endpoints import anime, genres, health, characters api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(anime.router, prefix="/api/v1/anime", tags=["anime"]) -api_router.include_router(genres.router, prefix="/api/v1/genres", tags=["genres"]) \ No newline at end of file +api_router.include_router(genres.router, prefix="/api/v1/genres", tags=["genres"]) +api_router.include_router(characters.router, prefix="/api/v1/characters", tags=["characters"]) \ No newline at end of file diff --git a/app/api/endpoints/characters.py b/app/api/endpoints/characters.py index f324cdf..23fa7bd 100644 --- a/app/api/endpoints/characters.py +++ b/app/api/endpoints/characters.py @@ -9,16 +9,38 @@ from app.api import deps router = APIRouter() -@router.get("/", response_model=List[schemas.character.Character]) -def read_characters( +@router.get("/", response_model=schemas.character.CharacterSearchResults) +def search_characters( db: Session = Depends(deps.get_db), + name: str = None, + anime_id: int = None, + role: str = None, skip: int = 0, limit: int = 100, ) -> Any: """ - Retrieve characters. + Search for characters with filters. """ - return crud.character.get_multi(db, skip=skip, limit=limit) + characters = crud.character.search( + db, + name=name, + anime_id=anime_id, + role=role, + skip=skip, + limit=limit + ) + total = crud.character.search_count( + db, + name=name, + anime_id=anime_id, + role=role + ) + return { + "results": characters, + "total": total, + "page": skip // limit + 1 if limit > 0 else 1, + "size": limit + } @router.post("/", response_model=schemas.character.Character) diff --git a/app/crud/__init__.py b/app/crud/__init__.py index cb7f700..ebd6096 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -1,4 +1,5 @@ from app.crud.crud_anime import anime as anime from app.crud.crud_genre import genre as genre +from app.crud.crud_character import character as character -__all__ = ["anime", "genre"] \ No newline at end of file +__all__ = ["anime", "genre", "character"] \ No newline at end of file diff --git a/app/crud/crud_character.py b/app/crud/crud_character.py index 2f896c9..0881c68 100644 --- a/app/crud/crud_character.py +++ b/app/crud/crud_character.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from sqlalchemy.orm import Session from app.crud.base import CRUDBase @@ -11,5 +11,55 @@ class CRUDCharacter(CRUDBase[Character, CharacterCreate, CharacterUpdate]): """Get all characters for a specific anime""" return db.query(Character).filter(Character.anime_id == anime_id).all() + def get_count_by_anime(self, db: Session, *, anime_id: int) -> int: + """Get count of characters for a specific anime""" + return db.query(Character).filter(Character.anime_id == anime_id).count() + + def search( + self, + db: Session, + *, + name: Optional[str] = None, + anime_id: Optional[int] = None, + role: Optional[str] = None, + skip: int = 0, + limit: int = 100 + ) -> List[Character]: + """Search for characters with filters""" + query = db.query(Character) + + if name: + query = query.filter(Character.name.ilike(f"%{name}%")) + + if anime_id: + query = query.filter(Character.anime_id == anime_id) + + if role: + query = query.filter(Character.role == role) + + return query.offset(skip).limit(limit).all() + + def search_count( + self, + db: Session, + *, + name: Optional[str] = None, + anime_id: Optional[int] = None, + role: Optional[str] = None, + ) -> int: + """Get count of characters matching search criteria""" + query = db.query(Character) + + if name: + query = query.filter(Character.name.ilike(f"%{name}%")) + + if anime_id: + query = query.filter(Character.anime_id == anime_id) + + if role: + query = query.filter(Character.role == role) + + return query.count() + character = CRUDCharacter(Character) \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py index b4148e1..1c3eda3 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -2,4 +2,5 @@ from app.db.base_class import Base # noqa from app.models.anime import Anime # noqa from app.models.genre import Genre # noqa -from app.models.anime_genre import AnimeGenre # noqa \ No newline at end of file +from app.models.anime_genre import AnimeGenre # noqa +from app.models.character import Character # noqa \ No newline at end of file diff --git a/app/initial_data/seed.py b/app/initial_data/seed.py index 5ccb87c..126e257 100644 --- a/app/initial_data/seed.py +++ b/app/initial_data/seed.py @@ -80,6 +80,102 @@ INITIAL_ANIME = [ }, ] +# Example characters +INITIAL_CHARACTERS = [ + # Fullmetal Alchemist: Brotherhood characters + { + "name": "Edward Elric", + "role": "Main", + "description": "The primary protagonist of the series, Edward is a young alchemical prodigy who lost his right arm and left leg after a failed attempt to resurrect his mother. He is the youngest State Alchemist in history, joining the military at the age of 12.", + "voice_actor": "Romi Park (Japanese), Vic Mignogna (English)", + "image_url": "https://example.com/edward_elric.jpg", + "anime_title": "Fullmetal Alchemist: Brotherhood", + "age": "15-16", + "gender": "Male", + "birth_date": date(1899, 2, 3), + "height": "165 cm (5'5\")", + "weight": "55 kg (121 lbs)", + "blood_type": "O", + "popularity_rank": 1 + }, + { + "name": "Alphonse Elric", + "role": "Main", + "description": "Edward's younger brother, Alphonse lost his entire body in the failed human transmutation experiment. His soul was bound to a suit of armor by Edward, and he seeks to restore his original body.", + "voice_actor": "Rie Kugimiya (Japanese), Maxey Whitehead (English)", + "image_url": "https://example.com/alphonse_elric.jpg", + "anime_title": "Fullmetal Alchemist: Brotherhood", + "age": "14-15", + "gender": "Male", + "birth_date": date(1900, 5, 7), + "height": "250 cm (in armor)", + "blood_type": "B", + "popularity_rank": 2 + }, + # Death Note characters + { + "name": "Light Yagami", + "role": "Main", + "description": "The main protagonist of Death Note, Light is a high school student who discovers the Death Note. Disgusted with the crime and injustice in the world, he uses the Death Note's power to kill criminals and becomes known as 'Kira'.", + "voice_actor": "Mamoru Miyano (Japanese), Brad Swaile (English)", + "image_url": "https://example.com/light_yagami.jpg", + "anime_title": "Death Note", + "age": "17-23", + "gender": "Male", + "birth_date": date(1986, 2, 28), + "height": "179 cm (5'10\")", + "weight": "64 kg (141 lbs)", + "blood_type": "A", + "popularity_rank": 3 + }, + { + "name": "L Lawliet", + "role": "Main", + "description": "A mysterious detective with an eccentric personality who takes on the challenge of capturing Kira. Considered the world's greatest detective, L opposes Light in a psychological battle of wits.", + "voice_actor": "Kappei Yamaguchi (Japanese), Alessandro Juliani (English)", + "image_url": "https://example.com/l_lawliet.jpg", + "anime_title": "Death Note", + "age": "24-25", + "gender": "Male", + "birth_date": date(1979, 10, 31), + "height": "179 cm (5'10\")", + "weight": "50 kg (110 lbs)", + "blood_type": "Unknown", + "popularity_rank": 4 + }, + # Attack on Titan characters + { + "name": "Eren Yeager", + "role": "Main", + "description": "The main protagonist of Attack on Titan. After his mother is eaten by a Titan, Eren vows to exterminate all Titans. Later, he discovers he has the ability to transform into a Titan.", + "voice_actor": "Yuki Kaji (Japanese), Bryce Papenbrook (English)", + "image_url": "https://example.com/eren_yeager.jpg", + "anime_title": "Attack on Titan", + "age": "15-19", + "gender": "Male", + "birth_date": date(835, 3, 30), + "height": "170 cm (5'7\")", + "weight": "63 kg (139 lbs)", + "blood_type": "Unknown", + "popularity_rank": 5 + }, + { + "name": "Mikasa Ackerman", + "role": "Main", + "description": "Eren's adoptive sister and one of the main protagonists. After her parents were murdered, Eren saved her life and she has been protective of him ever since. She is considered an exceptionally skilled soldier.", + "voice_actor": "Yui Ishikawa (Japanese), Trina Nishimura (English)", + "image_url": "https://example.com/mikasa_ackerman.jpg", + "anime_title": "Attack on Titan", + "age": "15-19", + "gender": "Female", + "birth_date": date(835, 2, 10), + "height": "170 cm (5'7\")", + "weight": "68 kg (150 lbs)", + "blood_type": "Unknown", + "popularity_rank": 6 + } +] + def init_db(db: Session) -> None: # Create genres @@ -91,12 +187,33 @@ def init_db(db: Session) -> None: logger.info(f"Created genre: {genre_data['name']}") # Create anime + anime_id_map = {} # Map anime titles to their IDs for character creation for anime_data in INITIAL_ANIME: anime = crud.anime.get_multi(db, limit=1, skip=0) if not anime or all(a.title != anime_data["title"] for a in anime): anime_in = schemas.anime.AnimeCreate(**anime_data) - crud.anime.create_with_genres(db, obj_in=anime_in) + created_anime = crud.anime.create_with_genres(db, obj_in=anime_in) + anime_id_map[anime_data["title"]] = created_anime.id logger.info(f"Created anime: {anime_data['title']}") + else: + # Get the ID for existing anime + for a in anime: + if a.title == anime_data["title"]: + anime_id_map[anime_data["title"]] = a.id + + # Create characters + for character_data in INITIAL_CHARACTERS: + # Get anime ID from title + anime_title = character_data.pop("anime_title") + if anime_title in anime_id_map: + character_data["anime_id"] = anime_id_map[anime_title] + + # Check if character already exists to avoid duplicates + existing_characters = crud.character.get_by_anime(db, anime_id=character_data["anime_id"]) + if not any(c.name == character_data["name"] for c in existing_characters): + character_in = schemas.character.CharacterCreate(**character_data) + crud.character.create(db, obj_in=character_in) + logger.info(f"Created character: {character_data['name']}") def seed_data() -> None: