invy/backend/main.py
2025-12-29 11:09:20 +02:00

256 lines
8.7 KiB
Python

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)