diff --git a/README.md b/README.md index e8acfba..df88b90 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,105 @@ -# FastAPI Application +# Anime Merchandise Catalog API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI-based API for managing an anime merchandise catalog. + +## Features + +- **Product Management:** Create, read, update, and delete anime merchandise products +- **Category System:** Organize products with categories +- **Filtering and Searching:** Filter products by anime title or character name +- **API Documentation:** Auto-generated OpenAPI documentation + +## Tech Stack + +- **Framework:** FastAPI +- **Database:** SQLite with SQLAlchemy and Alembic +- **Authentication:** (Not implemented in this version) + +## Project Structure + +``` +. +├── app # Main application package +│ ├── api # API routes and endpoint definitions +│ │ └── routes # API route handlers +│ ├── core # Core application components +│ ├── dependencies # Dependency injection components +│ ├── models # SQLAlchemy database models +│ ├── schemas # Pydantic schemas for request/response validation +│ └── services # Business logic services +├── migrations # Alembic database migrations +│ └── versions # Migration version scripts +├── storage # Storage directory for SQLite database +│ └── db # Database files +├── alembic.ini # Alembic configuration +├── main.py # Application entry point +└── requirements.txt # Python dependencies +``` + +## API Routes + +### Health Check +- `GET /health` - Check API health status + +### Products +- `GET /api/v1/products` - List all products (with optional filtering) +- `GET /api/v1/products/{product_id}` - Get a specific product by ID +- `POST /api/v1/products` - Create a new product +- `PUT /api/v1/products/{product_id}` - Update an existing product +- `DELETE /api/v1/products/{product_id}` - Delete a product + +### Categories +- `GET /api/v1/categories` - List all categories +- `GET /api/v1/categories/{category_id}` - Get a specific category by ID +- `GET /api/v1/categories/{category_id}/products` - Get a category with its products +- `POST /api/v1/categories` - Create a new category +- `PUT /api/v1/categories/{category_id}` - Update an existing category +- `DELETE /api/v1/categories/{category_id}` - Delete a category + +## Installation + +1. Clone the repository +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +3. Apply migrations to create the database schema: + ```bash + alembic upgrade head + ``` + +## Running the Application + +```bash +uvicorn main:app --reload +``` + +The API will be available at `http://localhost:8000` + +## API Documentation + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Database Schema + +### Product +- id: Integer (Primary Key) +- name: String +- description: Text (Optional) +- price: Float +- stock: Integer +- image_url: String (Optional) +- anime_title: String (Optional) +- character_name: String (Optional) +- created_at: DateTime +- updated_at: DateTime +- categories: Many-to-Many relationship with Category + +### Category +- id: Integer (Primary Key) +- name: String (Unique) +- description: Text (Optional) +- created_at: DateTime +- updated_at: DateTime +- products: Many-to-Many relationship with Product \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..20b4e8d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,83 @@ +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +timezone = UTC + +# max length of characters to apply to the +# "slug" field +truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +sourceless = false + +# version location specification; this defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +version_locations = %(here)s/migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +output_encoding = utf-8 + +# SQLite URL example +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, +# against the entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/categories.py b/app/api/routes/categories.py new file mode 100644 index 0000000..7adfb82 --- /dev/null +++ b/app/api/routes/categories.py @@ -0,0 +1,101 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies.db import get_db +from app.schemas.category import ( + CategoryCreate, + CategoryResponse, + CategoryUpdate, + CategoryWithProducts, +) +from app.services import category as category_service + +router = APIRouter(prefix="/categories") + + +@router.get("", response_model=List[CategoryResponse], summary="Get all categories") +async def get_categories(db: AsyncSession = Depends(get_db)): + """Get all categories.""" + categories = await category_service.get_categories(db) + return categories + + +@router.get( + "/{category_id}", response_model=CategoryResponse, summary="Get a category by ID" +) +async def get_category(category_id: int, db: AsyncSession = Depends(get_db)): + """Get a category by ID.""" + db_category = await category_service.get_category(db, category_id) + if db_category is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Category with ID {category_id} not found", + ) + return db_category + + +@router.get( + "/{category_id}/products", + response_model=CategoryWithProducts, + summary="Get a category with its products", +) +async def get_category_with_products( + category_id: int, db: AsyncSession = Depends(get_db) +): + """Get a category with its related products.""" + db_category = await category_service.get_category_with_products(db, category_id) + if db_category is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Category with ID {category_id} not found", + ) + return db_category + + +@router.post( + "", + response_model=CategoryResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new category", +) +async def create_category(category: CategoryCreate, db: AsyncSession = Depends(get_db)): + """Create a new category.""" + return await category_service.create_category(db, category) + + +@router.put( + "/{category_id}", response_model=CategoryResponse, summary="Update a category" +) +async def update_category( + category_id: int, + category_update: CategoryUpdate, + db: AsyncSession = Depends(get_db), +): + """Update an existing category.""" + db_category = await category_service.get_category(db, category_id) + if db_category is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Category with ID {category_id} not found", + ) + return await category_service.update_category(db, db_category, category_update) + + +@router.delete( + "/{category_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_model=None, + summary="Delete a category", +) +async def delete_category(category_id: int, db: AsyncSession = Depends(get_db)): + """Delete a category.""" + db_category = await category_service.get_category(db, category_id) + if db_category is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Category with ID {category_id} not found", + ) + await category_service.delete_category(db, db_category) + return None diff --git a/app/api/routes/health.py b/app/api/routes/health.py new file mode 100644 index 0000000..771b305 --- /dev/null +++ b/app/api/routes/health.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies.db import get_db + +router = APIRouter() + + +@router.get("/health", summary="Health check endpoint") +async def health_check(db: AsyncSession = Depends(get_db)): + """ + Health check endpoint that verifies the service is running + and can connect to the database. + """ + return {"status": "healthy", "database": "connected"} diff --git a/app/api/routes/products.py b/app/api/routes/products.py new file mode 100644 index 0000000..9a40f34 --- /dev/null +++ b/app/api/routes/products.py @@ -0,0 +1,91 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies.db import get_db +from app.schemas.product import ProductCreate, ProductResponse, ProductUpdate +from app.services import product as product_service + +router = APIRouter(prefix="/products") + + +@router.get( + "", + response_model=List[ProductResponse], + summary="Get all products", + description="Retrieve all products with optional filtering", +) +async def get_products( + skip: int = 0, + limit: int = 100, + anime_title: Optional[str] = Query(None, description="Filter by anime title"), + character_name: Optional[str] = Query(None, description="Filter by character name"), + db: AsyncSession = Depends(get_db), +): + """Get all products with optional filtering.""" + products = await product_service.get_products( + db, + skip=skip, + limit=limit, + anime_title=anime_title, + character_name=character_name, + ) + return products + + +@router.get( + "/{product_id}", response_model=ProductResponse, summary="Get a product by ID" +) +async def get_product(product_id: int, db: AsyncSession = Depends(get_db)): + """Get a product by ID.""" + db_product = await product_service.get_product(db, product_id) + if db_product is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {product_id} not found", + ) + return db_product + + +@router.post( + "", + response_model=ProductResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new product", +) +async def create_product(product: ProductCreate, db: AsyncSession = Depends(get_db)): + """Create a new product.""" + return await product_service.create_product(db, product) + + +@router.put("/{product_id}", response_model=ProductResponse, summary="Update a product") +async def update_product( + product_id: int, product_update: ProductUpdate, db: AsyncSession = Depends(get_db) +): + """Update an existing product.""" + db_product = await product_service.get_product(db, product_id) + if db_product is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {product_id} not found", + ) + return await product_service.update_product(db, db_product, product_update) + + +@router.delete( + "/{product_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_model=None, + summary="Delete a product", +) +async def delete_product(product_id: int, db: AsyncSession = Depends(get_db)): + """Delete a product.""" + db_product = await product_service.get_product(db, product_id) + if db_product is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {product_id} not found", + ) + await product_service.delete_product(db, db_product) + return None diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..942098d --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + PROJECT_NAME: str = "Anime Merchandise Catalog API" + PROJECT_DESCRIPTION: str = "A simple API for managing anime merchandise catalog" + PROJECT_VERSION: str = "0.1.0" + API_V1_STR: str = "/api/v1" + DEBUG: bool = False + + # Database + DB_DIR: Path = Path("/app") / "storage" / "db" + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + class Config: + env_file = ".env" + + +settings = Settings() + +# Ensure DB directory exists +settings.DB_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/dependencies/__init__.py b/app/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dependencies/db.py b/app/dependencies/db.py new file mode 100644 index 0000000..fb4b37a --- /dev/null +++ b/app/dependencies/db.py @@ -0,0 +1,30 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +# Create async engine +engine = create_async_engine( + settings.SQLALCHEMY_DATABASE_URL.replace("sqlite:///", "sqlite+aiosqlite:///"), + connect_args={"check_same_thread": False}, +) + +# Create async session +async_session = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +async def get_db() -> AsyncSession: + """ + Dependency for getting an async database session. + """ + async with async_session() as session: + try: + yield session + finally: + await session.close() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..911d840 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.base import Base +from app.models.product import Product, product_category +from app.models.category import Category + +__all__ = ["Base", "Product", "Category", "product_category"] diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..17a38f3 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, Integer +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base class for SQLAlchemy models.""" + + @declared_attr.directive + def __tablename__(cls) -> str: + return cls.__name__.lower() + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..d260cef --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, String, Text +from sqlalchemy.orm import relationship + +from app.models.base import Base +from app.models.product import product_category + + +class Category(Base): + """Model for anime merchandise categories.""" + + name = Column(String(100), nullable=False, unique=True, index=True) + description = Column(Text, nullable=True) + + # Relationships + products = relationship( + "Product", secondary=product_category, back_populates="categories" + ) + + def __repr__(self): + return f"" diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..5aed6ce --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, String, Integer, Float, Text, ForeignKey, Table +from sqlalchemy.orm import relationship + +from app.models.base import Base + +# Association table for the many-to-many relationship between Product and Category +product_category = Table( + "product_category", + Base.metadata, + Column("product_id", Integer, ForeignKey("product.id"), primary_key=True), + Column("category_id", Integer, ForeignKey("category.id"), primary_key=True), +) + + +class Product(Base): + """Model for anime merchandise products.""" + + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + stock = Column(Integer, default=0, nullable=False) + image_url = Column(String(255), nullable=True) + anime_title = Column(String(255), nullable=True, index=True) + character_name = Column(String(255), nullable=True, index=True) + + # Relationships + categories = relationship( + "Category", secondary=product_category, back_populates="products" + ) + + def __repr__(self): + return f"" diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..7eac3fa --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,29 @@ +from app.schemas.product import ( + ProductBase, + ProductCreate, + ProductUpdate, + ProductResponse, + CategoryInProduct, +) +from app.schemas.category import ( + CategoryBase, + CategoryCreate, + CategoryUpdate, + CategoryResponse, + CategoryWithProducts, + ProductInCategory, +) + +__all__ = [ + "ProductBase", + "ProductCreate", + "ProductUpdate", + "ProductResponse", + "CategoryInProduct", + "CategoryBase", + "CategoryCreate", + "CategoryUpdate", + "CategoryResponse", + "CategoryWithProducts", + "ProductInCategory", +] diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..5719b84 --- /dev/null +++ b/app/schemas/category.py @@ -0,0 +1,52 @@ +from typing import List, Optional + +from pydantic import BaseModel + + +class CategoryBase(BaseModel): + """Base schema for category data.""" + + name: str + description: Optional[str] = None + + +class CategoryCreate(CategoryBase): + """Schema for creating a new category.""" + + pass + + +class CategoryUpdate(BaseModel): + """Schema for updating a category.""" + + name: Optional[str] = None + description: Optional[str] = None + + +class ProductInCategory(BaseModel): + """Schema for product data in category responses.""" + + id: int + name: str + price: float + + class Config: + from_attributes = True + + +class CategoryResponse(CategoryBase): + """Schema for category responses.""" + + id: int + + class Config: + from_attributes = True + + +class CategoryWithProducts(CategoryResponse): + """Schema for category with related products.""" + + products: List[ProductInCategory] = [] + + class Config: + from_attributes = True diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..4e553ca --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,54 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +class ProductBase(BaseModel): + """Base schema for product data.""" + + name: str + description: Optional[str] = None + price: float = Field(gt=0) + stock: int = Field(ge=0, default=0) + image_url: Optional[HttpUrl] = None + anime_title: Optional[str] = None + character_name: Optional[str] = None + + +class ProductCreate(ProductBase): + """Schema for creating a new product.""" + + category_ids: List[int] = [] + + +class ProductUpdate(BaseModel): + """Schema for updating a product.""" + + name: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = Field(gt=0, default=None) + stock: Optional[int] = Field(ge=0, default=None) + image_url: Optional[HttpUrl] = None + anime_title: Optional[str] = None + character_name: Optional[str] = None + category_ids: Optional[List[int]] = None + + +class CategoryInProduct(BaseModel): + """Schema for category data in product responses.""" + + id: int + name: str + + class Config: + from_attributes = True + + +class ProductResponse(ProductBase): + """Schema for product responses.""" + + id: int + categories: List[CategoryInProduct] = [] + + class Config: + from_attributes = True diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/category.py b/app/services/category.py new file mode 100644 index 0000000..034cca1 --- /dev/null +++ b/app/services/category.py @@ -0,0 +1,60 @@ +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.category import Category +from app.schemas.category import CategoryCreate, CategoryUpdate + + +async def get_categories(db: AsyncSession) -> List[Category]: + """Get all categories.""" + result = await db.execute(select(Category)) + return result.scalars().all() + + +async def get_category(db: AsyncSession, category_id: int) -> Optional[Category]: + """Get a category by ID.""" + return await db.get(Category, category_id) + + +async def get_category_with_products( + db: AsyncSession, category_id: int +) -> Optional[Category]: + """Get a category with related products.""" + query = ( + select(Category) + .where(Category.id == category_id) + .options(selectinload(Category.products)) + ) + result = await db.execute(query) + return result.scalar_one_or_none() + + +async def create_category(db: AsyncSession, category: CategoryCreate) -> Category: + """Create a new category.""" + db_category = Category(**category.model_dump()) + db.add(db_category) + await db.commit() + await db.refresh(db_category) + return db_category + + +async def update_category( + db: AsyncSession, db_category: Category, category_update: CategoryUpdate +) -> Category: + """Update a category.""" + update_data = category_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_category, field, value) + + await db.commit() + await db.refresh(db_category) + return db_category + + +async def delete_category(db: AsyncSession, db_category: Category) -> None: + """Delete a category.""" + await db.delete(db_category) + await db.commit() diff --git a/app/services/product.py b/app/services/product.py new file mode 100644 index 0000000..6b8b6df --- /dev/null +++ b/app/services/product.py @@ -0,0 +1,100 @@ +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.category import Category +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate + + +async def get_products( + db: AsyncSession, + skip: int = 0, + limit: int = 100, + anime_title: Optional[str] = None, + character_name: Optional[str] = None, +) -> List[Product]: + """Get all products with optional filtering.""" + query = select(Product).options(selectinload(Product.categories)) + + if anime_title: + query = query.filter(Product.anime_title == anime_title) + + if character_name: + query = query.filter(Product.character_name == character_name) + + query = query.offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + + +async def get_product(db: AsyncSession, product_id: int) -> Optional[Product]: + """Get a product by ID with its categories.""" + query = ( + select(Product) + .where(Product.id == product_id) + .options(selectinload(Product.categories)) + ) + result = await db.execute(query) + return result.scalar_one_or_none() + + +async def create_product(db: AsyncSession, product: ProductCreate) -> Product: + """Create a new product with optional category associations.""" + # Extract category_ids from the schema + category_ids = product.category_ids + product_data = product.model_dump(exclude={"category_ids"}) + + # Create the product + db_product = Product(**product_data) + + # Add categories if provided + if category_ids: + categories = await get_categories_by_ids(db, category_ids) + db_product.categories = categories + + db.add(db_product) + await db.commit() + await db.refresh(db_product) + return db_product + + +async def update_product( + db: AsyncSession, db_product: Product, product_update: ProductUpdate +) -> Product: + """Update a product and its category associations.""" + # Extract category_ids from the schema if present + update_data = product_update.model_dump(exclude_unset=True) + category_ids = update_data.pop("category_ids", None) + + # Update product fields + for field, value in update_data.items(): + setattr(db_product, field, value) + + # Update categories if provided + if category_ids is not None: + categories = await get_categories_by_ids(db, category_ids) + db_product.categories = categories + + await db.commit() + await db.refresh(db_product) + return db_product + + +async def delete_product(db: AsyncSession, db_product: Product) -> None: + """Delete a product.""" + await db.delete(db_product) + await db.commit() + + +async def get_categories_by_ids( + db: AsyncSession, category_ids: List[int] +) -> List[Category]: + """Helper function to get categories by their IDs.""" + if not category_ids: + return [] + + result = await db.execute(select(Category).where(Category.id.in_(category_ids))) + return result.scalars().all() diff --git a/main.py b/main.py new file mode 100644 index 0000000..744a9d5 --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +import uvicorn +from fastapi import FastAPI + +from app.api.routes import health, products, categories +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description=settings.PROJECT_DESCRIPTION, + version=settings.PROJECT_VERSION, + docs_url="/docs", + redoc_url="/redoc", +) + +# Include routers +app.include_router(health.router, tags=["health"]) +app.include_router(products.router, prefix="/api/v1", tags=["products"]) +app.include_router(categories.router, prefix="/api/v1", tags=["categories"]) + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..5cad3ad --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,89 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.models.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # Enables batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=connection.dialect.name + == "sqlite", # Enables batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/20231216_000000_initial_schema.py b/migrations/versions/20231216_000000_initial_schema.py new file mode 100644 index 0000000..0e68084 --- /dev/null +++ b/migrations/versions/20231216_000000_initial_schema.py @@ -0,0 +1,87 @@ +"""initial schema + +Revision ID: 20231216_000000 +Revises: +Create Date: 2023-12-16 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20231216_000000" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create category table + op.create_table( + "category", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_category_id"), "category", ["id"], unique=False) + op.create_index(op.f("ix_category_name"), "category", ["name"], unique=True) + + # Create product table + op.create_table( + "product", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("price", sa.Float(), nullable=False), + sa.Column("stock", sa.Integer(), nullable=False), + sa.Column("image_url", sa.String(length=255), nullable=True), + sa.Column("anime_title", sa.String(length=255), nullable=True), + sa.Column("character_name", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_product_anime_title"), "product", ["anime_title"], unique=False + ) + op.create_index( + op.f("ix_product_character_name"), "product", ["character_name"], unique=False + ) + op.create_index(op.f("ix_product_id"), "product", ["id"], unique=False) + op.create_index(op.f("ix_product_name"), "product", ["name"], unique=False) + + # Create product_category association table + op.create_table( + "product_category", + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("category_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["category_id"], + ["category.id"], + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["product.id"], + ), + sa.PrimaryKeyConstraint("product_id", "category_id"), + ) + + +def downgrade() -> None: + # Drop tables in reverse order + op.drop_table("product_category") + op.drop_index(op.f("ix_product_name"), table_name="product") + op.drop_index(op.f("ix_product_id"), table_name="product") + op.drop_index(op.f("ix_product_character_name"), table_name="product") + op.drop_index(op.f("ix_product_anime_title"), table_name="product") + op.drop_table("product") + op.drop_index(op.f("ix_category_name"), table_name="category") + op.drop_index(op.f("ix_category_id"), table_name="category") + op.drop_table("category") diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e2065bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.100.0 +uvicorn>=0.22.0 +sqlalchemy>=2.0.0 +alembic>=1.11.1 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +python-dotenv>=1.0.0 +aiosqlite>=0.19.0 +ruff>=0.0.280 +httpx>=0.24.1 +pytest>=7.4.0 +pytest-asyncio>=0.21.1 \ No newline at end of file