- 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>
304 lines
8.7 KiB
Python
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),
|
|
}
|