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:
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user