from fastapi import FastAPI, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, HTMLResponse from sqlalchemy.orm import Session import uvicorn from typing import List import os from dotenv import load_dotenv import httpx import models import schemas import crud from database import engine, get_db # Load environment variables load_dotenv() # Create database tables models.Base.metadata.create_all(bind=engine) app = FastAPI(title="Wedding Guest List API") # Configure CORS app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], # Vite default port allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/") def read_root(): return {"message": "Wedding Guest List API"} # Guest endpoints @app.post("/guests/", response_model=schemas.Guest) def create_guest(guest: schemas.GuestCreate, db: Session = Depends(get_db)): return crud.create_guest(db=db, guest=guest) @app.get("/guests/", response_model=List[schemas.Guest]) def read_guests(skip: int = 0, limit: int = 1000, db: Session = Depends(get_db)): guests = crud.get_guests(db, skip=skip, limit=limit) return guests @app.get("/guests/{guest_id}", response_model=schemas.Guest) def read_guest(guest_id: int, db: Session = Depends(get_db)): db_guest = crud.get_guest(db, guest_id=guest_id) if db_guest is None: raise HTTPException(status_code=404, detail="Guest not found") return db_guest @app.put("/guests/{guest_id}", response_model=schemas.Guest) def update_guest(guest_id: int, guest: schemas.GuestUpdate, db: Session = Depends(get_db)): db_guest = crud.update_guest(db, guest_id=guest_id, guest=guest) if db_guest is None: raise HTTPException(status_code=404, detail="Guest not found") return db_guest @app.delete("/guests/{guest_id}") def delete_guest(guest_id: int, db: Session = Depends(get_db)): success = crud.delete_guest(db, guest_id=guest_id) if not success: raise HTTPException(status_code=404, detail="Guest not found") return {"message": "Guest deleted successfully"} @app.post("/guests/bulk-delete") def delete_guests_bulk(guest_ids: List[int], db: Session = Depends(get_db)): deleted_count = crud.delete_guests_bulk(db, guest_ids=guest_ids) return {"message": f"Successfully deleted {deleted_count} guests"} @app.delete("/guests/undo-import/{owner}") def undo_import(owner: str, db: Session = Depends(get_db)): """ Delete all guests imported by a specific owner """ deleted_count = crud.delete_guests_by_owner(db, owner=owner) return {"message": f"Successfully deleted {deleted_count} guests from {owner}"} @app.get("/guests/owners/") def get_owners(db: Session = Depends(get_db)): """ Get list of unique owners """ owners = crud.get_unique_owners(db) return {"owners": owners} # Search and filter endpoints @app.get("/guests/search/", response_model=List[schemas.Guest]) def search_guests( query: str = "", rsvp_status: str = None, meal_preference: str = None, owner: str = None, db: Session = Depends(get_db) ): guests = crud.search_guests( db, query=query, rsvp_status=rsvp_status, meal_preference=meal_preference, owner=owner ) return guests # Google OAuth configuration GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") GOOGLE_REDIRECT_URI = "http://localhost:8000/auth/google/callback" # Google OAuth endpoints @app.get("/auth/google") async def google_auth(): """ Initiate Google OAuth flow - redirects to Google """ auth_url = ( f"https://accounts.google.com/o/oauth2/v2/auth?" f"client_id={GOOGLE_CLIENT_ID}&" f"redirect_uri={GOOGLE_REDIRECT_URI}&" f"response_type=code&" f"scope=https://www.googleapis.com/auth/contacts.readonly%20https://www.googleapis.com/auth/userinfo.email&" f"access_type=offline&" f"prompt=consent" ) return RedirectResponse(url=auth_url) @app.get("/auth/google/callback") async def google_callback(code: str, db: Session = Depends(get_db)): """ Handle Google OAuth callback and import contacts Owner will be extracted from the user's email """ try: # Exchange code for access token async with httpx.AsyncClient() as client: token_response = await client.post( "https://oauth2.googleapis.com/token", data={ "code": code, "client_id": GOOGLE_CLIENT_ID, "client_secret": GOOGLE_CLIENT_SECRET, "redirect_uri": GOOGLE_REDIRECT_URI, "grant_type": "authorization_code", }, ) if token_response.status_code != 200: raise HTTPException(status_code=400, detail="Failed to get access token") token_data = token_response.json() access_token = token_data.get("access_token") # Get user info to extract email user_info_response = await client.get( "https://www.googleapis.com/oauth2/v2/userinfo", headers={"Authorization": f"Bearer {access_token}"} ) if user_info_response.status_code != 200: raise HTTPException(status_code=400, detail="Failed to get user info") user_info = user_info_response.json() user_email = user_info.get("email", "unknown") # Use full email as owner owner = user_email # Import contacts from google_contacts import import_contacts_from_google imported_count = await import_contacts_from_google(access_token, db, owner) # Redirect back to frontend with success message return RedirectResponse( url=f"http://localhost:5173?imported={imported_count}&owner={owner}", status_code=302 ) except Exception as e: # Redirect back with error return RedirectResponse( url=f"http://localhost:5173?error={str(e)}", status_code=302 ) # Public endpoint for guests to update their info @app.get("/public/guest/{phone_number}") def get_guest_by_phone(phone_number: str, db: Session = Depends(get_db)): """ Public endpoint: Get guest info by phone number Returns guest if found, or None to allow new registration """ guest = db.query(models.Guest).filter(models.Guest.phone_number == phone_number).first() if not guest: # Return structure indicating not found, but don't raise error return {"found": False, "phone_number": phone_number} return {"found": True, **guest.__dict__} @app.put("/public/guest/{phone_number}") def update_guest_by_phone( phone_number: str, guest_update: schemas.GuestPublicUpdate, db: Session = Depends(get_db) ): """ Public endpoint: Allow guests to update their own info using phone number Creates new guest if not found (marked as 'self-service') """ guest = db.query(models.Guest).filter(models.Guest.phone_number == phone_number).first() if not guest: # Create new guest from link (not imported from contacts) guest = models.Guest( first_name=guest_update.first_name or "Guest", last_name=guest_update.last_name or "", phone_number=phone_number, rsvp_status=guest_update.rsvp_status or "pending", meal_preference=guest_update.meal_preference, has_plus_one=guest_update.has_plus_one or False, plus_one_name=guest_update.plus_one_name, owner="self-service" # Mark as self-registered via link ) db.add(guest) else: # Update existing guest # Always update names if provided (override contact names) if guest_update.first_name is not None: guest.first_name = guest_update.first_name if guest_update.last_name is not None: guest.last_name = guest_update.last_name # Update other fields if guest_update.rsvp_status is not None: guest.rsvp_status = guest_update.rsvp_status if guest_update.meal_preference is not None: guest.meal_preference = guest_update.meal_preference if guest_update.has_plus_one is not None: guest.has_plus_one = guest_update.has_plus_one if guest_update.plus_one_name is not None: guest.plus_one_name = guest_update.plus_one_name db.commit() db.refresh(guest) return guest if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)