Add Anime Merchandise Catalog API with FastAPI and SQLite
This commit is contained in:
parent
c6e55fcc9f
commit
f2f97cdc27
106
README.md
106
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
|
83
alembic.ini
Normal file
83
alembic.ini
Normal file
@ -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
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/routes/__init__.py
Normal file
0
app/api/routes/__init__.py
Normal file
101
app/api/routes/categories.py
Normal file
101
app/api/routes/categories.py
Normal file
@ -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
|
15
app/api/routes/health.py
Normal file
15
app/api/routes/health.py
Normal file
@ -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"}
|
91
app/api/routes/products.py
Normal file
91
app/api/routes/products.py
Normal file
@ -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
|
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
24
app/core/config.py
Normal file
24
app/core/config.py
Normal file
@ -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)
|
0
app/dependencies/__init__.py
Normal file
0
app/dependencies/__init__.py
Normal file
30
app/dependencies/db.py
Normal file
30
app/dependencies/db.py
Normal file
@ -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()
|
5
app/models/__init__.py
Normal file
5
app/models/__init__.py
Normal file
@ -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"]
|
19
app/models/base.py
Normal file
19
app/models/base.py
Normal file
@ -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
|
||||||
|
)
|
20
app/models/category.py
Normal file
20
app/models/category.py
Normal file
@ -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"<Category {self.name}>"
|
32
app/models/product.py
Normal file
32
app/models/product.py
Normal file
@ -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"<Product {self.name}>"
|
29
app/schemas/__init__.py
Normal file
29
app/schemas/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
52
app/schemas/category.py
Normal file
52
app/schemas/category.py
Normal file
@ -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
|
54
app/schemas/product.py
Normal file
54
app/schemas/product.py
Normal file
@ -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
|
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
60
app/services/category.py
Normal file
60
app/services/category.py
Normal file
@ -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()
|
100
app/services/product.py
Normal file
100
app/services/product.py
Normal file
@ -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()
|
21
main.py
Normal file
21
main.py
Normal file
@ -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)
|
0
migrations/__init__.py
Normal file
0
migrations/__init__.py
Normal file
89
migrations/env.py
Normal file
89
migrations/env.py
Normal file
@ -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())
|
26
migrations/script.py.mako
Normal file
26
migrations/script.py.mako
Normal file
@ -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"}
|
87
migrations/versions/20231216_000000_initial_schema.py
Normal file
87
migrations/versions/20231216_000000_initial_schema.py
Normal file
@ -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")
|
0
migrations/versions/__init__.py
Normal file
0
migrations/versions/__init__.py
Normal file
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user