"""Google Calendar integration service.""" import base64 import hashlib import urllib.parse import urllib.request from datetime import datetime, timedelta from typing import Optional from cryptography.fernet import Fernet, InvalidToken from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from jose import JWTError, jwt from sqlalchemy.orm import Session from app.core.config import settings from app.models.booking import Booking from app.models.google_calendar_token import GoogleCalendarToken # --- Token Encryption --- def _get_fernet() -> Fernet: """Get Fernet instance derived from SECRET_KEY for token encryption.""" key = hashlib.sha256(settings.secret_key.encode()).digest() return Fernet(base64.urlsafe_b64encode(key)) def encrypt_token(token: str) -> str: """Encrypt a token string for secure database storage.""" return _get_fernet().encrypt(token.encode()).decode() def decrypt_token(encrypted_token: str) -> str: """Decrypt a token string from database storage.""" try: return _get_fernet().decrypt(encrypted_token.encode()).decode() except InvalidToken: # Fallback for legacy unencrypted tokens return encrypted_token # --- OAuth State (CSRF Prevention) --- def create_oauth_state(user_id: int) -> str: """Create a signed JWT state parameter for CSRF prevention. Encodes user_id in a short-lived JWT token that will be validated on the OAuth callback to prevent CSRF attacks. """ expire = datetime.utcnow() + timedelta(minutes=10) payload = { "sub": str(user_id), "type": "google_oauth_state", "exp": expire, } return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) def verify_oauth_state(state: str) -> Optional[int]: """Verify and decode the OAuth state parameter. Returns user_id if valid, None otherwise. """ try: payload = jwt.decode( state, settings.secret_key, algorithms=[settings.algorithm] ) if payload.get("type") != "google_oauth_state": return None return int(payload["sub"]) except (JWTError, KeyError, ValueError): return None # --- Token Revocation --- def revoke_google_token(token: str) -> bool: """Revoke a Google OAuth token via Google's revocation endpoint.""" try: params = urllib.parse.urlencode({"token": token}) request = urllib.request.Request( f"https://oauth2.googleapis.com/revoke?{params}", method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) urllib.request.urlopen(request, timeout=10) return True except Exception: return False # --- Google Calendar Service --- def get_google_calendar_service(db: Session, user_id: int): """ Get authenticated Google Calendar service for user. Handles token decryption and automatic refresh of expired tokens. Returns: Google Calendar service object or None if not connected/failed """ token_record = ( db.query(GoogleCalendarToken) .filter(GoogleCalendarToken.user_id == user_id) .first() ) if not token_record: return None # Decrypt tokens from database access_token = decrypt_token(token_record.access_token) refresh_token = decrypt_token(token_record.refresh_token) # Create credentials creds = Credentials( token=access_token, refresh_token=refresh_token, token_uri="https://oauth2.googleapis.com/token", client_id=settings.google_client_id, client_secret=settings.google_client_secret, ) # Refresh if expired if creds.expired and creds.refresh_token: try: creds.refresh(Request()) # Update encrypted tokens in DB token_record.access_token = encrypt_token(creds.token) # type: ignore[assignment] if creds.expiry: token_record.token_expiry = creds.expiry # type: ignore[assignment] db.commit() except Exception as e: print(f"Failed to refresh Google token for user {user_id}: {e}") return None # Build service try: service = build("calendar", "v3", credentials=creds) return service except Exception as e: print(f"Failed to build Google Calendar service: {e}") return None def create_calendar_event( db: Session, booking: Booking, user_id: int ) -> Optional[str]: """ Create Google Calendar event for booking. Returns: Google Calendar event ID or None if failed """ try: service = get_google_calendar_service(db, user_id) if not service: return None event = { "summary": f"{booking.space.name}: {booking.title}", "description": booking.description or "", "start": { "dateTime": booking.start_datetime.isoformat(), # type: ignore[union-attr] "timeZone": "UTC", }, "end": { "dateTime": booking.end_datetime.isoformat(), # type: ignore[union-attr] "timeZone": "UTC", }, } created_event = service.events().insert(calendarId="primary", body=event).execute() return created_event.get("id") except Exception as e: print(f"Failed to create Google Calendar event: {e}") return None def update_calendar_event( db: Session, booking: Booking, user_id: int, event_id: str ) -> bool: """ Update Google Calendar event for booking. Returns: True if successful, False otherwise """ try: service = get_google_calendar_service(db, user_id) if not service: return False event = { "summary": f"{booking.space.name}: {booking.title}", "description": booking.description or "", "start": { "dateTime": booking.start_datetime.isoformat(), # type: ignore[union-attr] "timeZone": "UTC", }, "end": { "dateTime": booking.end_datetime.isoformat(), # type: ignore[union-attr] "timeZone": "UTC", }, } service.events().update( calendarId="primary", eventId=event_id, body=event ).execute() return True except Exception as e: print(f"Failed to update Google Calendar event: {e}") return False def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool: """ Delete Google Calendar event. Returns: True if successful, False otherwise """ try: service = get_google_calendar_service(db, user_id) if not service: return False service.events().delete(calendarId="primary", eventId=event_id).execute() return True except Exception as e: print(f"Failed to delete Google Calendar event: {e}") return False def sync_all_bookings(db: Session, user_id: int) -> dict: """ Sync all approved future bookings to Google Calendar. Creates events for bookings without a google_calendar_event_id, and updates events for bookings that already have one. Returns: Summary dict with created/updated/failed counts """ service = get_google_calendar_service(db, user_id) if not service: return {"error": "Google Calendar not connected or token expired"} # Get all approved future bookings for the user bookings = ( db.query(Booking) .filter( Booking.user_id == user_id, Booking.status == "approved", Booking.start_datetime >= datetime.utcnow(), ) .all() ) created = 0 updated = 0 failed = 0 for booking in bookings: try: if booking.google_calendar_event_id: # Update existing event success = update_calendar_event( db, booking, user_id, booking.google_calendar_event_id ) if success: updated += 1 else: failed += 1 else: # Create new event event_id = create_calendar_event(db, booking, user_id) if event_id: booking.google_calendar_event_id = event_id # type: ignore[assignment] created += 1 else: failed += 1 except Exception: failed += 1 db.commit() return { "synced": created + updated, "created": created, "updated": updated, "failed": failed, "total_bookings": len(bookings), }