diff --git a/requirements.txt b/requirements.txt index 6339a07..e2ffd98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,6 @@ gunicorn==22.0.0 uvicorn==0.23.2 fastapi==0.111.1 -psycopg2 +psycopg2-binary SQLAlchemy==2.0.31 sqlmodel==0.0.20 diff --git a/src/fastapi_app/app.py b/src/fastapi_app/app.py index 7e5ff3f..84360ad 100644 --- a/src/fastapi_app/app.py +++ b/src/fastapi_app/app.py @@ -21,7 +21,9 @@ # Setup FastAPI app: -app = FastAPI() +from .mcp_server import mcp, mcp_lifespan +app = FastAPI(lifespan=mcp_lifespan) +app.mount("/mcp", mcp.streamable_http_app()) parent_path = pathlib.Path(__file__).parent.parent app.mount("/mount", StaticFiles(directory=parent_path / "static"), name="static") templates = Jinja2Templates(directory=parent_path / "templates") @@ -54,14 +56,20 @@ async def index(request: Request, session: Session = Depends(get_db_session)): restaurant_dict["stars_percent"] = round((float(avg_rating) / 5.0) * 100) if review_count > 0 else 0 restaurants.append(restaurant_dict) - return templates.TemplateResponse("index.html", {"request": request, "restaurants": restaurants}) - + return templates.TemplateResponse( + request=request, + name="index.html", + context={"restaurants": restaurants}, + ) @app.get("/create", response_class=HTMLResponse) async def create_restaurant(request: Request): logger.info("Request for add restaurant page received") - return templates.TemplateResponse("create_restaurant.html", {"request": request}) - + return templates.TemplateResponse( + request=request, + name="create_restaurant.html", + context={"request": request}, + ) @app.post("/add", response_class=RedirectResponse) async def add_restaurant( @@ -97,10 +105,11 @@ async def details(request: Request, id: int, session: Session = Depends(get_db_s restaurant_dict["stars_percent"] = round((float(avg_rating) / 5.0) * 100) if review_count > 0 else 0 return templates.TemplateResponse( - "details.html", {"request": request, "restaurant": restaurant_dict, "reviews": reviews} + request=request, + name="details.html", + context={"restaurant": restaurant_dict, "reviews": reviews}, ) - @app.post("/review/{id}", response_class=RedirectResponse) async def add_review( request: Request, @@ -120,3 +129,25 @@ async def add_review( session.commit() return RedirectResponse(url=app.url_path_for("details", id=id), status_code=status.HTTP_303_SEE_OTHER) + +@app.post("/delete/{id}", response_class=RedirectResponse) +async def delete_restaurant( + id: int, + session: Session = Depends(get_db_session), +): + # 先に Review を削除(外部キー制約回避) + reviews = session.exec(select(Review).where(Review.restaurant == id)).all() + for review in reviews: + session.delete(review) + + # Restaurant を削除 + restaurant = session.exec(select(Restaurant).where(Restaurant.id == id)).first() + if restaurant: + session.delete(restaurant) + + session.commit() + + return RedirectResponse( + url=app.url_path_for("index"), + status_code=status.HTTP_303_SEE_OTHER, + ) \ No newline at end of file diff --git a/src/fastapi_app/mcp_server.py b/src/fastapi_app/mcp_server.py new file mode 100644 index 0000000..897cae6 --- /dev/null +++ b/src/fastapi_app/mcp_server.py @@ -0,0 +1,102 @@ +import asyncio +import contextlib +from contextlib import asynccontextmanager + +from mcp.server.fastmcp import FastMCP +from sqlalchemy.sql import func +from sqlmodel import Session, select + +from .models import Restaurant, Review, engine + +# Create a FastMCP server. Use stateless_http=True for simple mounting. Default path is .../mcp +mcp = FastMCP("RestaurantReviewsMCP", stateless_http=True) + +# Lifespan context manager to start/stop the MCP session manager with the FastAPI app +@asynccontextmanager +async def mcp_lifespan(app): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(mcp.session_manager.run()) + yield + +# MCP tool: List all restaurants with their average rating and review count +@mcp.tool() +async def list_restaurants_mcp() -> list[dict]: + """List restaurants with their average rating and review count.""" + + def sync(): + with Session(engine) as session: + statement = ( + select( + Restaurant, + func.avg(Review.rating).label("avg_rating"), + func.count(Review.id).label("review_count"), + ) + .outerjoin(Review, Review.restaurant == Restaurant.id) + .group_by(Restaurant.id) + ) + results = session.exec(statement).all() + rows = [] + for restaurant, avg_rating, review_count in results: + r = restaurant.dict() + r["avg_rating"] = float(avg_rating) if avg_rating is not None else None + r["review_count"] = review_count + r["stars_percent"] = ( + round((float(avg_rating) / 5.0) * 100) if review_count > 0 and avg_rating is not None else 0 + ) + rows.append(r) + return rows + + return await asyncio.to_thread(sync) + +# MCP tool: Get a restaurant and all its reviews by restaurant_id +@mcp.tool() +async def get_details_mcp(restaurant_id: int) -> dict: + """Return the restaurant and its related reviews as objects.""" + + def sync(): + with Session(engine) as session: + restaurant = session.exec(select(Restaurant).where(Restaurant.id == restaurant_id)).first() + if restaurant is None: + return None + reviews = session.exec(select(Review).where(Review.restaurant == restaurant_id)).all() + return {"restaurant": restaurant.dict(), "reviews": [r.dict() for r in reviews]} + + return await asyncio.to_thread(sync) + +# MCP tool: Create a new review for a restaurant +@mcp.tool() +async def create_review_mcp(restaurant_id: int, user_name: str, rating: int, review_text: str) -> dict: + """Create a new review for a restaurant and return the created review dict.""" + + def sync(): + with Session(engine) as session: + review = Review() + review.restaurant = restaurant_id + review.review_date = __import__("datetime").datetime.now() + review.user_name = user_name + review.rating = int(rating) + review.review_text = review_text + session.add(review) + session.commit() + session.refresh(review) + return review.dict() + + return await asyncio.to_thread(sync) + +# MCP tool: Create a new restaurant +@mcp.tool() +async def create_restaurant_mcp(restaurant_name: str, street_address: str, description: str) -> dict: + """Create a new restaurant and return the created restaurant dict.""" + + def sync(): + with Session(engine) as session: + restaurant = Restaurant() + restaurant.name = restaurant_name + restaurant.street_address = street_address + restaurant.description = description + session.add(restaurant) + session.commit() + session.refresh(restaurant) + return restaurant.dict() + + return await asyncio.to_thread(sync) \ No newline at end of file diff --git a/src/pyproject.toml b/src/pyproject.toml index 3cc2094..7b97e9e 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -9,8 +9,9 @@ dependencies = [ "uvicorn[standard]", "uvicorn-worker", "python-multipart", - "psycopg2", + "psycopg2-binary", "sqlmodel", + "mcp[cli]", ] [build-system] diff --git a/src/templates/details.html b/src/templates/details.html index 6ab7cc9..e6e3ed7 100644 --- a/src/templates/details.html +++ b/src/templates/details.html @@ -7,6 +7,36 @@ min-height: 75rem; padding-top: 4.5rem; } + + .score { + display: block; + font-size: 16px; + position: relative; + overflow: hidden; + } + + .score-wrap { + display: inline-block; + position: relative; + height: 19px; + } + + .score .stars-active { + color: #EEBD01; + position: relative; + z-index: 10; + display: inline-block; + overflow: hidden; + white-space: nowrap; + } + + .score .stars-inactive { + color: grey; + position: absolute; + top: 0; + left: 0; + -webkit-text-stroke: initial; + } {% endblock %} {% block content %} @@ -21,7 +51,7 @@