Add character management system

- Enhance character model with additional fields
- Create relationship between anime and characters
- Implement character search functionality
- Create Alembic migration for character table
- Add sample character data for seeding
- Update README with character endpoint information
This commit is contained in:
Automated Action 2025-05-17 22:02:13 +00:00
parent bf28ab6f8e
commit 9623558845
8 changed files with 262 additions and 11 deletions

View File

@ -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:

View File

@ -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')

View File

@ -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"])
api_router.include_router(genres.router, prefix="/api/v1/genres", tags=["genres"])
api_router.include_router(characters.router, prefix="/api/v1/characters", tags=["characters"])

View File

@ -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)

View File

@ -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"]
__all__ = ["anime", "genre", "character"]

View File

@ -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)

View File

@ -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
from app.models.anime_genre import AnimeGenre # noqa
from app.models.character import Character # noqa

View File

@ -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: