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:
@@ -18,7 +18,10 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=noreply@space-booking.local
|
||||
SMTP_ENABLED=false
|
||||
|
||||
# Frontend URL (used for OAuth callback redirects)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Google Calendar Integration
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
GOOGLE_REDIRECT_URI=https://your-domain.com/api/integrations/google/callback
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/api/integrations/google/callback
|
||||
|
||||
@@ -68,9 +68,10 @@ def get_space_bookings(
|
||||
detail="Space not found",
|
||||
)
|
||||
|
||||
# Query bookings in the time range
|
||||
# Query bookings in the time range (only active bookings)
|
||||
query = db.query(Booking).filter(
|
||||
Booking.space_id == space_id,
|
||||
Booking.status.in_(["approved", "pending"]),
|
||||
Booking.start_datetime < end,
|
||||
Booking.end_datetime > start,
|
||||
)
|
||||
@@ -292,6 +293,9 @@ def create_booking(
|
||||
detail=errors[0], # Return first error
|
||||
)
|
||||
|
||||
# Auto-approve if admin, otherwise pending
|
||||
is_admin = current_user.role == "admin"
|
||||
|
||||
# Create booking (with UTC times)
|
||||
booking = Booking(
|
||||
user_id=user_id,
|
||||
@@ -300,7 +304,8 @@ def create_booking(
|
||||
end_datetime=end_datetime_utc,
|
||||
title=booking_data.title,
|
||||
description=booking_data.description,
|
||||
status="pending",
|
||||
status="approved" if is_admin else "pending",
|
||||
approved_by=current_user.id if is_admin else None, # type: ignore[assignment]
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
@@ -308,26 +313,27 @@ def create_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Notify all admins about the new booking request
|
||||
admins = db.query(User).filter(User.role == "admin").all()
|
||||
for admin in admins:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=admin.id, # type: ignore[arg-type]
|
||||
type="booking_created",
|
||||
title="Noua Cerere de Rezervare",
|
||||
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
# Send email notification to admin
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"created",
|
||||
admin.email,
|
||||
current_user.full_name,
|
||||
None,
|
||||
)
|
||||
if not is_admin:
|
||||
# Notify all admins about the new booking request
|
||||
admins = db.query(User).filter(User.role == "admin").all()
|
||||
for admin in admins:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=admin.id, # type: ignore[arg-type]
|
||||
type="booking_created",
|
||||
title="Noua Cerere de Rezervare",
|
||||
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||
booking_id=booking.id,
|
||||
)
|
||||
# Send email notification to admin
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"created",
|
||||
admin.email,
|
||||
current_user.full_name,
|
||||
None,
|
||||
)
|
||||
|
||||
# Return with timezone conversion
|
||||
return BookingResponse.from_booking_with_timezone(booking, user_timezone)
|
||||
@@ -637,6 +643,56 @@ def cancel_booking(
|
||||
admin_router = APIRouter(prefix="/admin/bookings", tags=["admin"])
|
||||
|
||||
|
||||
@admin_router.get("/all", response_model=list[BookingPendingDetail])
|
||||
def get_all_bookings(
|
||||
status_filter: Annotated[str | None, Query(alias="status")] = None,
|
||||
space_id: Annotated[int | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
|
||||
) -> list[BookingPendingDetail]:
|
||||
"""
|
||||
Get all bookings across all users (admin only).
|
||||
|
||||
Returns bookings with user and space details.
|
||||
|
||||
Query parameters:
|
||||
- **status** (optional): Filter by status (pending/approved/rejected/canceled)
|
||||
- **space_id** (optional): Filter by space ID
|
||||
- **user_id** (optional): Filter by user ID
|
||||
- **start** (optional): Only bookings starting from this datetime
|
||||
- **limit** (optional): Max results (1-100, default 20)
|
||||
"""
|
||||
query = (
|
||||
db.query(Booking)
|
||||
.join(Space, Booking.space_id == Space.id)
|
||||
.join(User, Booking.user_id == User.id)
|
||||
)
|
||||
|
||||
if status_filter is not None:
|
||||
query = query.filter(Booking.status == status_filter)
|
||||
|
||||
if space_id is not None:
|
||||
query = query.filter(Booking.space_id == space_id)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.filter(Booking.user_id == user_id)
|
||||
|
||||
if start is not None:
|
||||
# Use end_datetime to include bookings still in progress (started but not ended)
|
||||
query = query.filter(Booking.end_datetime > start)
|
||||
|
||||
bookings = (
|
||||
query.order_by(Booking.start_datetime.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [BookingPendingDetail.model_validate(b) for b in bookings]
|
||||
|
||||
|
||||
@admin_router.get("/pending", response_model=list[BookingPendingDetail])
|
||||
def get_pending_bookings(
|
||||
space_id: Annotated[int | None, Query()] = None,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""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
|
||||
|
||||
@@ -9,9 +11,35 @@ 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(
|
||||
@@ -20,7 +48,9 @@ def connect_google(
|
||||
"""
|
||||
Start Google OAuth flow.
|
||||
|
||||
Returns authorization URL that user should visit to grant access.
|
||||
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(
|
||||
@@ -28,29 +58,23 @@ def connect_google(
|
||||
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(
|
||||
{
|
||||
"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],
|
||||
}
|
||||
},
|
||||
scopes=[
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
],
|
||||
_get_client_config(),
|
||||
scopes=GOOGLE_SCOPES,
|
||||
redirect_uri=settings.google_redirect_uri,
|
||||
)
|
||||
|
||||
authorization_url, state = flow.authorization_url(
|
||||
access_type="offline", include_granted_scopes="true", prompt="consent"
|
||||
authorization_url, _ = flow.authorization_url(
|
||||
access_type="offline",
|
||||
include_granted_scopes="true",
|
||||
prompt="consent",
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Note: In production, store state in session/cache and validate it in callback
|
||||
return {"authorization_url": authorization_url, "state": state}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -61,85 +85,113 @@ def connect_google(
|
||||
|
||||
@router.get("/integrations/google/callback")
|
||||
def google_callback(
|
||||
code: Annotated[str, Query()],
|
||||
state: Annotated[str, Query()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, str]:
|
||||
code: Annotated[str | None, Query()] = None,
|
||||
state: Annotated[str | None, Query()] = None,
|
||||
error: Annotated[str | None, Query()] = None,
|
||||
) -> RedirectResponse:
|
||||
"""
|
||||
Handle Google OAuth callback.
|
||||
|
||||
Exchange authorization code for tokens and store them.
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Google Calendar integration not configured",
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_settings}?google_calendar=error&message=Not+configured"
|
||||
)
|
||||
|
||||
try:
|
||||
flow = Flow.from_client_config(
|
||||
{
|
||||
"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],
|
||||
}
|
||||
},
|
||||
scopes=[
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
],
|
||||
_get_client_config(),
|
||||
scopes=GOOGLE_SCOPES,
|
||||
redirect_uri=settings.google_redirect_uri,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
# Exchange authorization code for tokens
|
||||
flow.fetch_token(code=code)
|
||||
|
||||
credentials = flow.credentials
|
||||
|
||||
# Store tokens
|
||||
# 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 == current_user.id)
|
||||
.filter(GoogleCalendarToken.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if token_record:
|
||||
token_record.access_token = credentials.token # type: ignore[assignment]
|
||||
token_record.refresh_token = credentials.refresh_token # type: ignore[assignment]
|
||||
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=current_user.id, # type: ignore[arg-type]
|
||||
access_token=credentials.token,
|
||||
refresh_token=credentials.refresh_token,
|
||||
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 {"message": "Google Calendar connected successfully"}
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_settings}?google_calendar=connected"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"OAuth failed: {str(e)}",
|
||||
msg = urllib.parse.quote(str(e))
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_settings}?google_calendar=error&message={msg}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/integrations/google/disconnect")
|
||||
@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.
|
||||
Disconnect Google Calendar and revoke access.
|
||||
|
||||
Removes stored tokens for the current user.
|
||||
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)
|
||||
@@ -148,12 +200,56 @@ def disconnect_google(
|
||||
)
|
||||
|
||||
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)],
|
||||
@@ -162,7 +258,8 @@ def google_status(
|
||||
"""
|
||||
Check Google Calendar connection status.
|
||||
|
||||
Returns whether user has connected their Google Calendar account.
|
||||
Returns whether user has connected their Google Calendar account
|
||||
and when the current token expires.
|
||||
"""
|
||||
token_record = (
|
||||
db.query(GoogleCalendarToken)
|
||||
@@ -172,5 +269,9 @@ def google_status(
|
||||
|
||||
return {
|
||||
"connected": token_record is not None,
|
||||
"expires_at": token_record.token_expiry.isoformat() if token_record and token_record.token_expiry else None,
|
||||
"expires_at": (
|
||||
token_record.token_expiry.isoformat()
|
||||
if token_record and token_record.token_expiry
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
@@ -93,10 +93,11 @@ def validate_booking_rules(
|
||||
f"Rezervările sunt permise doar între {wh_start}:00 și {wh_end}:00"
|
||||
)
|
||||
|
||||
# c) Check for overlapping bookings
|
||||
# c) Check for overlapping bookings (only approved block new bookings;
|
||||
# admin re-validates at approval time to catch conflicts)
|
||||
query = db.query(Booking).filter(
|
||||
Booking.space_id == space_id,
|
||||
Booking.status.in_(["approved", "pending"]),
|
||||
Booking.status == "approved",
|
||||
and_(
|
||||
Booking.start_datetime < end_datetime,
|
||||
Booking.end_datetime > start_datetime,
|
||||
|
||||
@@ -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