Files
space-booking/backend/app/api/google_calendar.py
Claude Agent d245c72757 feat: complete UI/UX overhaul - dashboard unification, calendar UX, mobile optimization
- Dashboard redesign as command center with filters, quick actions, inline approve/reject
- Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal
- Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle
- Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout
- Collapsible filters with active count badge
- Smart menu positioning with Teleport
- Calendar/list bidirectional data sync
- Navigation: unified History page, removed AdminPending
- Google Calendar OAuth integration
- Dark mode contrast improvements, breadcrumb navigation
- useLocalStorage composable for state persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:34:47 +00:00

278 lines
8.7 KiB
Python

"""Google Calendar integration endpoints."""
import urllib.parse
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_current_user, get_db
from app.models.google_calendar_token import GoogleCalendarToken
from app.models.user import User
from app.services.google_calendar_service import (
create_oauth_state,
decrypt_token,
encrypt_token,
revoke_google_token,
sync_all_bookings,
verify_oauth_state,
)
router = APIRouter()
GOOGLE_SCOPES = [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
]
def _get_client_config() -> dict:
"""Build Google OAuth client configuration."""
return {
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
}
@router.get("/integrations/google/connect")
def connect_google(
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
"""
Start Google OAuth flow.
Returns an authorization URL with a signed state parameter for CSRF prevention.
The state encodes the user's identity so the callback can identify the user
without requiring an auth header (since it's a browser redirect from Google).
"""
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google Calendar integration not configured",
)
# Create signed state with user_id for CSRF prevention
state = create_oauth_state(int(current_user.id)) # type: ignore[arg-type]
try:
flow = Flow.from_client_config(
_get_client_config(),
scopes=GOOGLE_SCOPES,
redirect_uri=settings.google_redirect_uri,
)
authorization_url, _ = flow.authorization_url(
access_type="offline",
include_granted_scopes="true",
prompt="consent",
state=state,
)
return {"authorization_url": authorization_url, "state": state}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start OAuth flow: {str(e)}",
)
@router.get("/integrations/google/callback")
def google_callback(
db: Annotated[Session, Depends(get_db)],
code: Annotated[str | None, Query()] = None,
state: Annotated[str | None, Query()] = None,
error: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""
Handle Google OAuth callback.
This endpoint receives the browser redirect from Google after authorization.
User identity is verified via the signed state parameter (no auth header needed).
After processing, redirects to the frontend settings page with status.
"""
frontend_settings = f"{settings.frontend_url}/settings"
# Handle user denial or Google error
if error:
msg = urllib.parse.quote(error)
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message={msg}"
)
if not code or not state:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Missing+code+or+state"
)
# Verify state and extract user_id (CSRF protection)
user_id = verify_oauth_state(state)
if user_id is None:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Invalid+or+expired+state"
)
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=User+not+found"
)
if not settings.google_client_id or not settings.google_client_secret:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Not+configured"
)
try:
flow = Flow.from_client_config(
_get_client_config(),
scopes=GOOGLE_SCOPES,
redirect_uri=settings.google_redirect_uri,
state=state,
)
# Exchange authorization code for tokens
flow.fetch_token(code=code)
credentials = flow.credentials
# Encrypt tokens for secure database storage
encrypted_access = encrypt_token(credentials.token)
encrypted_refresh = (
encrypt_token(credentials.refresh_token)
if credentials.refresh_token
else ""
)
# Store or update tokens
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == user_id)
.first()
)
if token_record:
token_record.access_token = encrypted_access # type: ignore[assignment]
token_record.refresh_token = encrypted_refresh # type: ignore[assignment]
token_record.token_expiry = credentials.expiry # type: ignore[assignment]
else:
token_record = GoogleCalendarToken(
user_id=user_id, # type: ignore[arg-type]
access_token=encrypted_access,
refresh_token=encrypted_refresh,
token_expiry=credentials.expiry,
)
db.add(token_record)
db.commit()
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=connected"
)
except Exception as e:
msg = urllib.parse.quote(str(e))
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message={msg}"
)
@router.post("/integrations/google/disconnect")
def disconnect_google(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
"""
Disconnect Google Calendar and revoke access.
Attempts to revoke the token with Google, then removes stored tokens.
Token revocation is best-effort - local tokens are always deleted.
"""
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.first()
)
if token_record:
# Attempt to revoke the token with Google (best-effort)
try:
access_token = decrypt_token(token_record.access_token)
revoke_google_token(access_token)
except Exception:
pass # Delete locally even if revocation fails
db.delete(token_record)
db.commit()
return {"message": "Google Calendar disconnected"}
@router.post("/integrations/google/sync")
def sync_google_calendar(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict:
"""
Manually sync all approved future bookings to Google Calendar.
Creates calendar events for bookings that don't have one yet,
and updates existing events for bookings that do.
Returns a summary of sync results (created/updated/failed counts).
"""
# Check if connected
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.first()
)
if not token_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google Calendar not connected. Please connect first.",
)
result = sync_all_bookings(db, int(current_user.id)) # type: ignore[arg-type]
if "error" in result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"],
)
return result
@router.get("/integrations/google/status")
def google_status(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, bool | str | None]:
"""
Check Google Calendar connection status.
Returns whether user has connected their Google Calendar account
and when the current token expires.
"""
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.first()
)
return {
"connected": token_record is not None,
"expires_at": (
token_record.token_expiry.isoformat()
if token_record and token_record.token_expiry
else None
),
}