diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ba2f3d5..9c13468 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,6 @@ FROM mcr.microsoft.com/devcontainers/python:3.12-bullseye -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +RUN rm -f /etc/apt/sources.list.d/yarn.list \ + && apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends postgresql-client \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/.env b/.env deleted file mode 100644 index 94618f1..0000000 --- a/.env +++ /dev/null @@ -1,4 +0,0 @@ -DBNAME=app -DBHOST=localhost -DBUSER=app_user -DBPASS=app_password \ No newline at end of file diff --git a/.gitignore b/.gitignore index d95f270..df13bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -243,3 +243,21 @@ Thumbs.db #thumbnail cache on Windows # Azurite stuff __azurite_*.json + +# Work/debug artifacts (not for version control) +*.zip +azd-deploy-output*.txt +chat_*.json +chat-log*.md +chat_log*.md +setup_db.py +entrypoint.sh +entrypoint_azure.sh +app-error-logs/ +app-logs/ +app-logs-mcp/ +deploy-flat/ +deploy-logs-extracted/ +latest-logs/ +webapp_logs/ +app-deploy-fixed-temp/ diff --git a/infra/main.bicep b/infra/main.bicep index 6c865da..dd0756e 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -18,10 +18,11 @@ param databasePassword string param secretKey string var resourceToken = toLower(uniqueString(subscription().id, name, location)) +var safeName = toLower(replace(name, '_', '-')) var tags = { 'azd-env-name': name } resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: '${name}-rg' + name: '${safeName}-rg' location: location tags: tags } @@ -44,4 +45,4 @@ output APPLICATIONINSIGHTS_CONNECTION_STRING string = resources.outputs.APPLICAT output WEB_URI string = resources.outputs.WEB_URI output WEB_APP_LOG_STREAM string = resources.outputs.WEB_APP_LOG_STREAM output WEB_APP_SSH string = resources.outputs.WEB_APP_SSH -output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG \ No newline at end of file +output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG diff --git a/infra/resources.bicep b/infra/resources.bicep index 849c302..4ae39ef 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -7,7 +7,7 @@ param databasePassword string @secure() param secretKey string -var prefix = '${name}-${resourceToken}' +var prefix = toLower(replace('${name}-${resourceToken}', '_', '-')) var pgServerName = '${prefix}-postgres-server' var databaseSubnetName = 'database-subnet' 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..55862b2 100644 --- a/src/fastapi_app/app.py +++ b/src/fastapi_app/app.py @@ -7,10 +7,11 @@ from fastapi import Depends, FastAPI, Form, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from jinja2 import Environment, FileSystemLoader, select_autoescape from sqlalchemy.sql import func from sqlmodel import Session, select +from .mcp_server import mcp, mcp_lifespan from .models import Restaurant, Review, engine # Setup logger and Azure Monitor: @@ -21,13 +22,19 @@ # Setup FastAPI app: -app = FastAPI() + parent_path = pathlib.Path(__file__).parent.parent -app.mount("/mount", StaticFiles(directory=parent_path / "static"), name="static") -templates = Jinja2Templates(directory=parent_path / "templates") -templates.env.globals["prod"] = os.environ.get("RUNNING_IN_PRODUCTION", False) -# Use relative path for url_for, so that it works behind a proxy like Codespaces -templates.env.globals["url_for"] = app.url_path_for +app = FastAPI(lifespan=mcp_lifespan) +app.mount("/api", mcp.streamable_http_app()) +app.mount("/static", StaticFiles(directory=parent_path / "static"), name="static") + +# Create Jinja2 environment with caching disabled to avoid issues +jinja_env = Environment( + loader=FileSystemLoader(parent_path / "templates"), + autoescape=select_autoescape(["html", "xml"]), + cache_size=0, # Disable caching to avoid 'unhashable type' error +) +jinja_env.globals["prod"] = bool(os.environ.get("RUNNING_IN_PRODUCTION", False)) # Dependency to get the database session @@ -48,25 +55,34 @@ async def index(request: Request, session: Session = Depends(get_db_session)): restaurants = [] for restaurant, avg_rating, review_count in results: - restaurant_dict = restaurant.dict() + restaurant_dict = restaurant.model_dump() restaurant_dict["avg_rating"] = avg_rating restaurant_dict["review_count"] = review_count 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}) + template = jinja_env.get_template("index.html") + context = {"request": request, "restaurants": restaurants, "url_for": app.url_path_for} + html_content = template.render(context) + return HTMLResponse(content=html_content) @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}) + template = jinja_env.get_template("create_restaurant.html") + context = {"request": request, "url_for": app.url_path_for} + html_content = template.render(context) + return HTMLResponse(content=html_content) @app.post("/add", response_class=RedirectResponse) async def add_restaurant( - request: Request, restaurant_name: str = Form(...), street_address: str = Form(...), description: str = Form(...), - session: Session = Depends(get_db_session) + request: Request, + restaurant_name: str = Form(...), + street_address: str = Form(...), + description: str = Form(...), + session: Session = Depends(get_db_session), ): logger.info("name: %s address: %s description: %s", restaurant_name, street_address, description) restaurant = Restaurant() @@ -91,14 +107,15 @@ async def details(request: Request, id: int, session: Session = Depends(get_db_s if review_count > 0: avg_rating = sum(review.rating for review in reviews if review.rating is not None) / review_count - restaurant_dict = restaurant.dict() + restaurant_dict = restaurant.model_dump() restaurant_dict["avg_rating"] = avg_rating restaurant_dict["review_count"] = review_count 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} - ) + template = jinja_env.get_template("details.html") + context = {"request": request, "restaurant": restaurant_dict, "reviews": reviews, "url_for": app.url_path_for} + html_content = template.render(context) + return HTMLResponse(content=html_content) @app.post("/review/{id}", response_class=RedirectResponse) @@ -120,3 +137,24 @@ 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( + request: Request, + id: int, + session: Session = Depends(get_db_session), +): + restaurant = session.exec(select(Restaurant).where(Restaurant.id == id)).first() + if restaurant is None: + return RedirectResponse(url=app.url_path_for("index"), status_code=status.HTTP_303_SEE_OTHER) + + reviews = session.exec(select(Review).where(Review.restaurant == id)).all() + for review in reviews: + session.delete(review) + session.commit() + + session.delete(restaurant) + session.commit() + + return RedirectResponse(url=app.url_path_for("index"), status_code=status.HTTP_303_SEE_OTHER) diff --git a/src/fastapi_app/mcp_server.py b/src/fastapi_app/mcp_server.py new file mode 100644 index 0000000..3df5a60 --- /dev/null +++ b/src/fastapi_app/mcp_server.py @@ -0,0 +1,103 @@ +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/fastapi_app/models.py b/src/fastapi_app/models.py index fcbaa53..0e865d2 100644 --- a/src/fastapi_app/models.py +++ b/src/fastapi_app/models.py @@ -1,6 +1,5 @@ import logging import os -import typing from datetime import datetime from urllib.parse import quote_plus @@ -18,7 +17,7 @@ logger.info("Missing environment variable AZURE_POSTGRESQL_CONNECTIONSTRING") else: # Parse the connection string - details = dict(item.split('=') for item in env_connection_string.split()) + details = dict(item.split("=") for item in env_connection_string.split()) # Properly format the URL for SQLAlchemy sql_url = ( @@ -35,7 +34,9 @@ POSTGRES_DATABASE = os.environ.get("DBNAME") POSTGRES_PORT = os.environ.get("DBPORT", 5432) - sql_url = f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}" + sql_url = ( + f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}" + ) engine = create_engine(sql_url) @@ -43,8 +44,9 @@ def create_db_and_tables(): return SQLModel.metadata.create_all(engine) + class Restaurant(SQLModel, table=True): - id: typing.Optional[int] = Field(default=None, primary_key=True) + id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=50) street_address: str = Field(max_length=50) description: str = Field(max_length=250) @@ -52,13 +54,14 @@ class Restaurant(SQLModel, table=True): def __str__(self): return f"{self.name}" + class Review(SQLModel, table=True): - id: typing.Optional[int] = Field(default=None, primary_key=True) + id: int | None = Field(default=None, primary_key=True) restaurant: int = Field(foreign_key="restaurant.id") user_name: str = Field(max_length=50) - rating: typing.Optional[int] + rating: int | None review_text: str = Field(max_length=500) review_date: datetime def __str__(self): - return f"{self.name}" + return f"{self.user_name}" diff --git a/src/my_uvicorn_worker.py b/src/my_uvicorn_worker.py index 18409d9..937c30f 100644 --- a/src/my_uvicorn_worker.py +++ b/src/my_uvicorn_worker.py @@ -45,6 +45,6 @@ class MyUvicornWorker(UvicornWorker): CONFIG_KWARGS = { "loop": "asyncio", "http": "auto", - "lifespan": "off", + "lifespan": "on", "log_config": logconfig_dict, } 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..7dc2518 100644 --- a/src/templates/details.html +++ b/src/templates/details.html @@ -112,4 +112,10 @@