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>
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
"""Google Calendar integration service."""
|
||||
from datetime import datetime
|
||||
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
|
||||
@@ -12,16 +18,92 @@ 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.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID
|
||||
Handles token decryption and automatic refresh of expired tokens.
|
||||
|
||||
Returns:
|
||||
Google Calendar service object or None if not connected
|
||||
Google Calendar service object or None if not connected/failed
|
||||
"""
|
||||
token_record = (
|
||||
db.query(GoogleCalendarToken)
|
||||
@@ -32,10 +114,14 @@ def get_google_calendar_service(db: Session, user_id: int):
|
||||
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=token_record.access_token,
|
||||
refresh_token=token_record.refresh_token,
|
||||
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,
|
||||
@@ -46,13 +132,13 @@ def get_google_calendar_service(db: Session, user_id: int):
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
|
||||
# Update tokens in DB
|
||||
token_record.access_token = creds.token # type: ignore[assignment]
|
||||
# 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: {e}")
|
||||
print(f"Failed to refresh Google token for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
# Build service
|
||||
@@ -70,11 +156,6 @@ def create_calendar_event(
|
||||
"""
|
||||
Create Google Calendar event for booking.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
booking: Booking object
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Google Calendar event ID or None if failed
|
||||
"""
|
||||
@@ -83,7 +164,6 @@ def create_calendar_event(
|
||||
if not service:
|
||||
return None
|
||||
|
||||
# Create event
|
||||
event = {
|
||||
"summary": f"{booking.space.name}: {booking.title}",
|
||||
"description": booking.description or "",
|
||||
@@ -111,12 +191,6 @@ def update_calendar_event(
|
||||
"""
|
||||
Update Google Calendar event for booking.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
booking: Booking object
|
||||
user_id: User ID
|
||||
event_id: Google Calendar event ID
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
@@ -125,7 +199,6 @@ def update_calendar_event(
|
||||
if not service:
|
||||
return False
|
||||
|
||||
# Update event
|
||||
event = {
|
||||
"summary": f"{booking.space.name}: {booking.title}",
|
||||
"description": booking.description or "",
|
||||
@@ -153,11 +226,6 @@ def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
|
||||
"""
|
||||
Delete Google Calendar event.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
event_id: Google Calendar event ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
@@ -171,3 +239,65 @@ def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
|
||||
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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user