Files
space-booking/backend/app/services/google_calendar_service.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

304 lines
8.7 KiB
Python

"""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),
}