Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/*
4 changes: 0 additions & 4 deletions .env

This file was deleted.

18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 3 additions & 2 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG
2 changes: 1 addition & 1 deletion infra/resources.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 54 additions & 16 deletions src/fastapi_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
103 changes: 103 additions & 0 deletions src/fastapi_app/mcp_server.py
Original file line number Diff line number Diff line change
@@ -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)

17 changes: 10 additions & 7 deletions src/fastapi_app/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging
import os
import typing
from datetime import datetime
from urllib.parse import quote_plus

Expand All @@ -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 = (
Expand All @@ -35,30 +34,34 @@
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)


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)

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}"
2 changes: 1 addition & 1 deletion src/my_uvicorn_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ class MyUvicornWorker(UvicornWorker):
CONFIG_KWARGS = {
"loop": "asyncio",
"http": "auto",
"lifespan": "off",
"lifespan": "on",
"log_config": logconfig_dict,
}
3 changes: 2 additions & 1 deletion src/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ dependencies = [
"uvicorn[standard]",
"uvicorn-worker",
"python-multipart",
"psycopg2",
"psycopg2-binary",
"sqlmodel",
"mcp[cli]",
]

[build-system]
Expand Down
6 changes: 6 additions & 0 deletions src/templates/details.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,10 @@ <h5 class="modal-title" id="addReviewModalLabel">Add Review</h5>
</div>
</div>

<div class="d-flex justify-content-end mt-5">
<form method="POST" action="{{ url_for('delete_restaurant', id=restaurant.id) }}" onsubmit="return confirm('Delete this restaurant and all reviews?');">
<button type="submit" class="btn btn-danger">Delete Restaurant</button>
</form>
</div>

{% endblock %}