Spaces:
Running
Running
Commit ·
8ec8b4a
1
Parent(s): 548dfc9
feat: google sheets api integration
Browse files- app/api/server.py +2 -0
- app/api/v1/router.py +2 -0
- app/api/v1/sheets.py +364 -0
- app/config.py +10 -0
- app/models/schemas.py +76 -0
- app/services/sheets_service.py +563 -0
app/api/server.py
CHANGED
|
@@ -149,6 +149,8 @@ async def lifespan(app: FastAPI):
|
|
| 149 |
await close_gcs_service()
|
| 150 |
from app.api.v1.gmail import close_gmail_service
|
| 151 |
await close_gmail_service()
|
|
|
|
|
|
|
| 152 |
from app.services.media_storage_service import close_storage_service
|
| 153 |
await close_storage_service()
|
| 154 |
from app.utils.http_utils import close_shared_aiohttp_sessions
|
|
|
|
| 149 |
await close_gcs_service()
|
| 150 |
from app.api.v1.gmail import close_gmail_service
|
| 151 |
await close_gmail_service()
|
| 152 |
+
from app.api.v1.sheets import close_sheets_service
|
| 153 |
+
await close_sheets_service()
|
| 154 |
from app.services.media_storage_service import close_storage_service
|
| 155 |
await close_storage_service()
|
| 156 |
from app.utils.http_utils import close_shared_aiohttp_sessions
|
app/api/v1/router.py
CHANGED
|
@@ -25,6 +25,7 @@ from app.api.v1 import (
|
|
| 25 |
scopes,
|
| 26 |
scraper,
|
| 27 |
semantic_router,
|
|
|
|
| 28 |
sql_validator,
|
| 29 |
system,
|
| 30 |
token_counter,
|
|
@@ -62,6 +63,7 @@ api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
|
|
| 62 |
api_v1_router.include_router(scopes.router, tags=["Google Scopes"])
|
| 63 |
api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
|
| 64 |
api_v1_router.include_router(gmail.router, tags=["Gmail"])
|
|
|
|
| 65 |
api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
|
| 66 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 67 |
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
|
|
|
| 25 |
scopes,
|
| 26 |
scraper,
|
| 27 |
semantic_router,
|
| 28 |
+
sheets,
|
| 29 |
sql_validator,
|
| 30 |
system,
|
| 31 |
token_counter,
|
|
|
|
| 63 |
api_v1_router.include_router(scopes.router, tags=["Google Scopes"])
|
| 64 |
api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
|
| 65 |
api_v1_router.include_router(gmail.router, tags=["Gmail"])
|
| 66 |
+
api_v1_router.include_router(sheets.router, tags=["Google Sheets"])
|
| 67 |
api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
|
| 68 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 69 |
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
app/api/v1/sheets.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Header, HTTPException
|
| 7 |
+
|
| 8 |
+
from app.config import get_settings
|
| 9 |
+
from app.core.logger import get_logger
|
| 10 |
+
from app.models.schemas import (
|
| 11 |
+
SheetsAppendRequest,
|
| 12 |
+
SheetsBatchUpdateRequest,
|
| 13 |
+
SheetsCreateSpreadsheetRequest,
|
| 14 |
+
SheetsGenericResponse,
|
| 15 |
+
SheetsRefreshResponse,
|
| 16 |
+
SheetsValueUpdateRequest,
|
| 17 |
+
)
|
| 18 |
+
from app.services.sheets_service import (
|
| 19 |
+
SheetsAPIError,
|
| 20 |
+
SheetsCredentials,
|
| 21 |
+
SheetsService,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/google/sheets", tags=["Google Sheets"])
|
| 25 |
+
_logger = get_logger(__name__)
|
| 26 |
+
_settings = get_settings()
|
| 27 |
+
|
| 28 |
+
_sheets_service = SheetsService()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_sheets_service() -> SheetsService:
|
| 32 |
+
return _sheets_service
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def close_sheets_service() -> None:
|
| 36 |
+
await _sheets_service.close()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _credentials(
|
| 40 |
+
x_access_token: Optional[str] = Header(None, alias="X-Access-Token"),
|
| 41 |
+
x_refresh_token: Optional[str] = Header(None, alias="X-Refresh-Token"),
|
| 42 |
+
x_client_id: Optional[str] = Header(None, alias="X-Client-Id"),
|
| 43 |
+
x_client_secret: Optional[str] = Header(None, alias="X-Client-Secret"),
|
| 44 |
+
x_token_expires_at: Optional[float] = Header(None, alias="X-Token-Expires-At"),
|
| 45 |
+
) -> SheetsCredentials:
|
| 46 |
+
if not x_access_token:
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=401,
|
| 49 |
+
detail="X-Access-Token header is required.",
|
| 50 |
+
)
|
| 51 |
+
return SheetsCredentials(
|
| 52 |
+
access_token=x_access_token,
|
| 53 |
+
refresh_token=x_refresh_token,
|
| 54 |
+
client_id=x_client_id,
|
| 55 |
+
client_secret=x_client_secret,
|
| 56 |
+
expires_at=x_token_expires_at,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _http_error(exc: SheetsAPIError) -> HTTPException:
|
| 61 |
+
return HTTPException(status_code=exc.status_code, detail=exc.message)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _elapsed_ms(start: float) -> float:
|
| 65 |
+
return round((time.perf_counter() - start) * 1000, 2)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _ok(
|
| 69 |
+
start: float,
|
| 70 |
+
creds: SheetsCredentials,
|
| 71 |
+
data: Any = None,
|
| 72 |
+
*,
|
| 73 |
+
error: Optional[str] = None,
|
| 74 |
+
) -> SheetsGenericResponse:
|
| 75 |
+
return SheetsGenericResponse(
|
| 76 |
+
success=error is None,
|
| 77 |
+
time_ms=_elapsed_ms(start),
|
| 78 |
+
data=data,
|
| 79 |
+
refreshed_access_token=creds.refreshed_access_token,
|
| 80 |
+
error=error,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# Service metadata & token lifecycle
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
@router.get("/scopes", response_model=SheetsGenericResponse,
|
| 89 |
+
summary="List all supported Google Sheets OAuth scopes")
|
| 90 |
+
async def list_scopes(
|
| 91 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 92 |
+
):
|
| 93 |
+
start = time.perf_counter()
|
| 94 |
+
scopes = await service.list_available_scopes()
|
| 95 |
+
_logger.info("Listed %d Sheets scopes (%.2fms)", len(scopes), _elapsed_ms(start))
|
| 96 |
+
return SheetsGenericResponse(success=True, time_ms=_elapsed_ms(start), data=scopes)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.post("/token/refresh", response_model=SheetsRefreshResponse,
|
| 100 |
+
summary="Refresh an access token (stateless, returned to the client)")
|
| 101 |
+
async def token_refresh(
|
| 102 |
+
body: Dict[str, str],
|
| 103 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 104 |
+
):
|
| 105 |
+
start = time.perf_counter()
|
| 106 |
+
client_id = body.get("client_id", "")
|
| 107 |
+
client_secret = body.get("client_secret", "")
|
| 108 |
+
refresh_token = body.get("refresh_token", "")
|
| 109 |
+
if not (client_id and client_secret and refresh_token):
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=400,
|
| 112 |
+
detail="client_id, client_secret and refresh_token are required.",
|
| 113 |
+
)
|
| 114 |
+
try:
|
| 115 |
+
tokens = await service.refresh_access_token(
|
| 116 |
+
client_id=client_id,
|
| 117 |
+
client_secret=client_secret,
|
| 118 |
+
refresh_token=refresh_token,
|
| 119 |
+
)
|
| 120 |
+
except SheetsAPIError as exc:
|
| 121 |
+
raise _http_error(exc) from exc
|
| 122 |
+
_logger.info("Sheets token refreshed (%.2fms)", _elapsed_ms(start))
|
| 123 |
+
return SheetsRefreshResponse(
|
| 124 |
+
success=True,
|
| 125 |
+
access_token=tokens.get("access_token"),
|
| 126 |
+
expires_in=int(tokens.get("expires_in", 0)),
|
| 127 |
+
token_type=tokens.get("token_type", "Bearer"),
|
| 128 |
+
scope=tokens.get("scope"),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# Spreadsheets
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
@router.post("/spreadsheets", response_model=SheetsGenericResponse,
|
| 137 |
+
summary="Create a new spreadsheet")
|
| 138 |
+
async def create_spreadsheet(
|
| 139 |
+
body: SheetsCreateSpreadsheetRequest,
|
| 140 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 141 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 142 |
+
):
|
| 143 |
+
start = time.perf_counter()
|
| 144 |
+
try:
|
| 145 |
+
data = await service.create_spreadsheet(
|
| 146 |
+
creds,
|
| 147 |
+
title=body.title,
|
| 148 |
+
sheets=body.sheets,
|
| 149 |
+
properties=body.properties,
|
| 150 |
+
)
|
| 151 |
+
except SheetsAPIError as exc:
|
| 152 |
+
raise _http_error(exc) from exc
|
| 153 |
+
_logger.info("Sheets spreadsheet '%s' created (%.2fms)", body.title, _elapsed_ms(start))
|
| 154 |
+
return _ok(start, creds, data)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.get("/spreadsheets/{spreadsheet_id}", response_model=SheetsGenericResponse,
|
| 158 |
+
summary="Get spreadsheet metadata (spreadsheets.get)")
|
| 159 |
+
async def get_spreadsheet(
|
| 160 |
+
spreadsheet_id: str,
|
| 161 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 162 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 163 |
+
ranges: Optional[str] = None,
|
| 164 |
+
include_grid_data: bool = False,
|
| 165 |
+
):
|
| 166 |
+
start = time.perf_counter()
|
| 167 |
+
try:
|
| 168 |
+
data = await service.get_spreadsheet(
|
| 169 |
+
creds,
|
| 170 |
+
spreadsheet_id,
|
| 171 |
+
ranges=ranges.split(",") if ranges else None,
|
| 172 |
+
include_grid_data=include_grid_data,
|
| 173 |
+
)
|
| 174 |
+
except SheetsAPIError as exc:
|
| 175 |
+
raise _http_error(exc) from exc
|
| 176 |
+
_logger.info("Sheets spreadsheet %s fetched (%.2fms)", spreadsheet_id, _elapsed_ms(start))
|
| 177 |
+
return _ok(start, creds, data)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.get("/spreadsheets/{spreadsheet_id}/sheets/{sheet_id}",
|
| 181 |
+
response_model=SheetsGenericResponse,
|
| 182 |
+
summary="Get a single sheet's metadata (spreadsheets.sheets.get)")
|
| 183 |
+
async def get_sheet(
|
| 184 |
+
spreadsheet_id: str,
|
| 185 |
+
sheet_id: int,
|
| 186 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 187 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 188 |
+
ranges: Optional[str] = None,
|
| 189 |
+
include_grid_data: bool = False,
|
| 190 |
+
):
|
| 191 |
+
start = time.perf_counter()
|
| 192 |
+
try:
|
| 193 |
+
data = await service.get_sheet(
|
| 194 |
+
creds,
|
| 195 |
+
spreadsheet_id,
|
| 196 |
+
sheet_id,
|
| 197 |
+
ranges=ranges.split(",") if ranges else None,
|
| 198 |
+
include_grid_data=include_grid_data,
|
| 199 |
+
)
|
| 200 |
+
except SheetsAPIError as exc:
|
| 201 |
+
raise _http_error(exc) from exc
|
| 202 |
+
_logger.info(
|
| 203 |
+
"Sheets sheet %d of %s fetched (%.2fms)", sheet_id, spreadsheet_id, _elapsed_ms(start)
|
| 204 |
+
)
|
| 205 |
+
return _ok(start, creds, data)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@router.post("/spreadsheets/{spreadsheet_id}:batchUpdate",
|
| 209 |
+
response_model=SheetsGenericResponse,
|
| 210 |
+
summary="Apply batch mutations (addSheet, updateCells, etc.)")
|
| 211 |
+
async def batch_update(
|
| 212 |
+
spreadsheet_id: str,
|
| 213 |
+
body: SheetsBatchUpdateRequest,
|
| 214 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 215 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 216 |
+
):
|
| 217 |
+
start = time.perf_counter()
|
| 218 |
+
try:
|
| 219 |
+
data = await service.batch_update(
|
| 220 |
+
creds, spreadsheet_id, requests=body.requests
|
| 221 |
+
)
|
| 222 |
+
except SheetsAPIError as exc:
|
| 223 |
+
raise _http_error(exc) from exc
|
| 224 |
+
_logger.info("Sheets spreadsheet %s batch-updated (%.2fms)", spreadsheet_id, _elapsed_ms(start))
|
| 225 |
+
return _ok(start, creds, data)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
# Values
|
| 230 |
+
# ---------------------------------------------------------------------------
|
| 231 |
+
|
| 232 |
+
@router.get("/spreadsheets/{spreadsheet_id}/values/{range}",
|
| 233 |
+
response_model=SheetsGenericResponse,
|
| 234 |
+
summary="Read cell values from a range (values.get)")
|
| 235 |
+
async def get_values(
|
| 236 |
+
spreadsheet_id: str,
|
| 237 |
+
range: str,
|
| 238 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 239 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 240 |
+
major_dimension: Optional[str] = None,
|
| 241 |
+
value_render_option: Optional[str] = None,
|
| 242 |
+
date_time_render_option: Optional[str] = None,
|
| 243 |
+
):
|
| 244 |
+
start = time.perf_counter()
|
| 245 |
+
try:
|
| 246 |
+
data = await service.get_values(
|
| 247 |
+
creds,
|
| 248 |
+
spreadsheet_id,
|
| 249 |
+
range,
|
| 250 |
+
major_dimension=major_dimension,
|
| 251 |
+
value_render_option=value_render_option,
|
| 252 |
+
date_time_render_option=date_time_render_option,
|
| 253 |
+
)
|
| 254 |
+
except SheetsAPIError as exc:
|
| 255 |
+
raise _http_error(exc) from exc
|
| 256 |
+
_logger.info("Sheets values %s!%s fetched (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
|
| 257 |
+
return _ok(start, creds, data)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@router.get("/spreadsheets/{spreadsheet_id}/values:batchGet",
|
| 261 |
+
response_model=SheetsGenericResponse,
|
| 262 |
+
summary="Read multiple ranges in one call (values.batchGet)")
|
| 263 |
+
async def batch_get_values(
|
| 264 |
+
spreadsheet_id: str,
|
| 265 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 266 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 267 |
+
ranges: Optional[str] = None,
|
| 268 |
+
major_dimension: Optional[str] = None,
|
| 269 |
+
value_render_option: Optional[str] = None,
|
| 270 |
+
date_time_render_option: Optional[str] = None,
|
| 271 |
+
):
|
| 272 |
+
start = time.perf_counter()
|
| 273 |
+
if not ranges:
|
| 274 |
+
raise HTTPException(status_code=400, detail="At least one 'ranges' value is required.")
|
| 275 |
+
try:
|
| 276 |
+
data = await service.batch_get_values(
|
| 277 |
+
creds,
|
| 278 |
+
spreadsheet_id,
|
| 279 |
+
ranges=ranges.split(","),
|
| 280 |
+
major_dimension=major_dimension,
|
| 281 |
+
value_render_option=value_render_option,
|
| 282 |
+
date_time_render_option=date_time_render_option,
|
| 283 |
+
)
|
| 284 |
+
except SheetsAPIError as exc:
|
| 285 |
+
raise _http_error(exc) from exc
|
| 286 |
+
_logger.info("Sheets batchGet %s fetched (%.2fms)", spreadsheet_id, _elapsed_ms(start))
|
| 287 |
+
return _ok(start, creds, data)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
@router.put("/spreadsheets/{spreadsheet_id}/values/{range}",
|
| 291 |
+
response_model=SheetsGenericResponse,
|
| 292 |
+
summary="Overwrite cell values in a range (values.update)")
|
| 293 |
+
async def update_values(
|
| 294 |
+
spreadsheet_id: str,
|
| 295 |
+
range: str,
|
| 296 |
+
body: SheetsValueUpdateRequest,
|
| 297 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 298 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 299 |
+
value_input_option: str = "RAW",
|
| 300 |
+
):
|
| 301 |
+
start = time.perf_counter()
|
| 302 |
+
try:
|
| 303 |
+
data = await service.update_values(
|
| 304 |
+
creds,
|
| 305 |
+
spreadsheet_id,
|
| 306 |
+
range,
|
| 307 |
+
values=body.values,
|
| 308 |
+
major_dimension=body.major_dimension,
|
| 309 |
+
value_input_option=value_input_option,
|
| 310 |
+
include_values_in_response=body.include_values_in_response,
|
| 311 |
+
value_render_option=body.value_render_option,
|
| 312 |
+
)
|
| 313 |
+
except SheetsAPIError as exc:
|
| 314 |
+
raise _http_error(exc) from exc
|
| 315 |
+
_logger.info("Sheets values %s!%s updated (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
|
| 316 |
+
return _ok(start, creds, data)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@router.post("/spreadsheets/{spreadsheet_id}/values/{range}:append",
|
| 320 |
+
response_model=SheetsGenericResponse,
|
| 321 |
+
summary="Append rows to a range (values.append)")
|
| 322 |
+
async def append_values(
|
| 323 |
+
spreadsheet_id: str,
|
| 324 |
+
range: str,
|
| 325 |
+
body: SheetsAppendRequest,
|
| 326 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 327 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 328 |
+
value_input_option: str = "USER_ENTERED",
|
| 329 |
+
insert_data_option: Optional[str] = None,
|
| 330 |
+
):
|
| 331 |
+
start = time.perf_counter()
|
| 332 |
+
try:
|
| 333 |
+
data = await service.append_values(
|
| 334 |
+
creds,
|
| 335 |
+
spreadsheet_id,
|
| 336 |
+
range,
|
| 337 |
+
values=body.values,
|
| 338 |
+
major_dimension=body.major_dimension,
|
| 339 |
+
value_input_option=value_input_option,
|
| 340 |
+
insert_data_option=insert_data_option,
|
| 341 |
+
include_values_in_response=False,
|
| 342 |
+
)
|
| 343 |
+
except SheetsAPIError as exc:
|
| 344 |
+
raise _http_error(exc) from exc
|
| 345 |
+
_logger.info("Sheets values %s!%s appended (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
|
| 346 |
+
return _ok(start, creds, data)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
@router.post("/spreadsheets/{spreadsheet_id}/values/{range}:clear",
|
| 350 |
+
response_model=SheetsGenericResponse,
|
| 351 |
+
summary="Clear cell values in a range (values.clear)")
|
| 352 |
+
async def clear_values(
|
| 353 |
+
spreadsheet_id: str,
|
| 354 |
+
range: str,
|
| 355 |
+
creds: SheetsCredentials = Depends(_credentials),
|
| 356 |
+
service: SheetsService = Depends(get_sheets_service),
|
| 357 |
+
):
|
| 358 |
+
start = time.perf_counter()
|
| 359 |
+
try:
|
| 360 |
+
data = await service.clear_values(creds, spreadsheet_id, range)
|
| 361 |
+
except SheetsAPIError as exc:
|
| 362 |
+
raise _http_error(exc) from exc
|
| 363 |
+
_logger.info("Sheets values %s!%s cleared (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
|
| 364 |
+
return _ok(start, creds, data)
|
app/config.py
CHANGED
|
@@ -141,6 +141,16 @@ class Settings(BaseSettings):
|
|
| 141 |
gmail_default_scope: str = "https://www.googleapis.com/auth/gmail.modify"
|
| 142 |
gmail_refresh_buffer_seconds: int = 60
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
# Media-to-Media conversion settings
|
| 145 |
media_output_dir: str = "./data/media-convert"
|
| 146 |
media_max_workers: int = 4
|
|
|
|
| 141 |
gmail_default_scope: str = "https://www.googleapis.com/auth/gmail.modify"
|
| 142 |
gmail_refresh_buffer_seconds: int = 60
|
| 143 |
|
| 144 |
+
# Google Sheets API settings
|
| 145 |
+
sheets_api_base_url: str = "https://sheets.googleapis.com/v4"
|
| 146 |
+
sheets_timeout: float = 30.0
|
| 147 |
+
sheets_max_retries: int = 3
|
| 148 |
+
sheets_base_backoff_seconds: float = 1.0
|
| 149 |
+
sheets_max_backoff_seconds: float = 32.0
|
| 150 |
+
sheets_retryable_statuses: str = "429,500,502,503,504"
|
| 151 |
+
sheets_default_scope: str = "https://www.googleapis.com/auth/spreadsheets"
|
| 152 |
+
sheets_refresh_buffer_seconds: int = 60
|
| 153 |
+
|
| 154 |
# Media-to-Media conversion settings
|
| 155 |
media_output_dir: str = "./data/media-convert"
|
| 156 |
media_max_workers: int = 4
|
app/models/schemas.py
CHANGED
|
@@ -1996,3 +1996,79 @@ class GmailRefreshResponse(BaseModel):
|
|
| 1996 |
token_type: Optional[str] = None
|
| 1997 |
scope: Optional[str] = None
|
| 1998 |
error: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1996 |
token_type: Optional[str] = None
|
| 1997 |
scope: Optional[str] = None
|
| 1998 |
error: Optional[str] = None
|
| 1999 |
+
|
| 2000 |
+
|
| 2001 |
+
# ---------------------------------------------------------------------------
|
| 2002 |
+
# Google Sheets API (stateless, client-supplied OAuth credentials)
|
| 2003 |
+
# ---------------------------------------------------------------------------
|
| 2004 |
+
|
| 2005 |
+
class SheetsScope(Enum):
|
| 2006 |
+
"""All Sheets-specific OAuth scopes (developers.google.com/sheets/api/auth).
|
| 2007 |
+
|
| 2008 |
+
Drive scopes are intentionally excluded here — they belong to the separate
|
| 2009 |
+
Google Drive API integration.
|
| 2010 |
+
"""
|
| 2011 |
+
|
| 2012 |
+
READONLY = "https://www.googleapis.com/auth/spreadsheets.readonly"
|
| 2013 |
+
SPREADSHEETS = "https://www.googleapis.com/auth/spreadsheets"
|
| 2014 |
+
|
| 2015 |
+
@property
|
| 2016 |
+
def permission_level(self) -> str:
|
| 2017 |
+
return "sensitive"
|
| 2018 |
+
|
| 2019 |
+
|
| 2020 |
+
class SheetsCreateSpreadsheetRequest(BaseModel):
|
| 2021 |
+
"""Body for creating a spreadsheet (spreadsheets.create)."""
|
| 2022 |
+
|
| 2023 |
+
title: str = Field(..., min_length=1, max_length=255, description="Spreadsheet title")
|
| 2024 |
+
sheets: Optional[List[Dict[str, Any]]] = Field(
|
| 2025 |
+
None,
|
| 2026 |
+
description="Initial sheet definitions, e.g. [{'properties': {'title': 'Data', 'index': 0}}]",
|
| 2027 |
+
)
|
| 2028 |
+
properties: Optional[Dict[str, Any]] = Field(
|
| 2029 |
+
None,
|
| 2030 |
+
description="Spreadsheet properties (locale, autoRecalc, defaultFormat, etc.)",
|
| 2031 |
+
)
|
| 2032 |
+
|
| 2033 |
+
|
| 2034 |
+
class SheetsValueUpdateRequest(BaseModel):
|
| 2035 |
+
"""Body for overwriting a range of cells (values.update)."""
|
| 2036 |
+
|
| 2037 |
+
values: List[List[Any]] = Field(..., min_length=1, description="2D array of cell values (rows of columns)")
|
| 2038 |
+
major_dimension: Optional[str] = Field(None, pattern="^(ROWS|COLUMNS)$", description="Major dimension of the values array")
|
| 2039 |
+
include_values_in_response: Optional[bool] = Field(None, description="Whether the response should include updated values")
|
| 2040 |
+
value_render_option: Optional[str] = Field(None, pattern="^(FORMATTED_VALUE|UNFORMATTED_VALUE|FORMULA)$", description="Render option for returned values")
|
| 2041 |
+
|
| 2042 |
+
|
| 2043 |
+
class SheetsAppendRequest(BaseModel):
|
| 2044 |
+
"""Body for appending rows to a range (values.append)."""
|
| 2045 |
+
|
| 2046 |
+
values: List[List[Any]] = Field(..., min_length=1, description="2D array of cell values to append")
|
| 2047 |
+
major_dimension: Optional[str] = Field(None, pattern="^(ROWS|COLUMNS)$", description="Major dimension of the values array")
|
| 2048 |
+
|
| 2049 |
+
|
| 2050 |
+
class SheetsBatchUpdateRequest(BaseModel):
|
| 2051 |
+
"""Body for batch mutation requests (spreadsheets.batchUpdate)."""
|
| 2052 |
+
|
| 2053 |
+
requests: List[Dict[str, Any]] = Field(..., min_length=1, description="List of request objects (addSheet, updateCells, etc.)")
|
| 2054 |
+
|
| 2055 |
+
|
| 2056 |
+
class SheetsGenericResponse(BaseModel):
|
| 2057 |
+
"""Standard Google Sheets operation envelope."""
|
| 2058 |
+
|
| 2059 |
+
success: bool
|
| 2060 |
+
time_ms: float = 0.0
|
| 2061 |
+
data: Optional[Any] = Field(None, description="Raw Sheets API payload")
|
| 2062 |
+
refreshed_access_token: Optional[str] = Field(None, description="New access token when an automatic refresh occurred; persist it client-side")
|
| 2063 |
+
error: Optional[str] = Field(None, description="Human-readable error when success is false")
|
| 2064 |
+
|
| 2065 |
+
|
| 2066 |
+
class SheetsRefreshResponse(BaseModel):
|
| 2067 |
+
"""Stateless token refresh result returned to the client for its own storage."""
|
| 2068 |
+
|
| 2069 |
+
success: bool
|
| 2070 |
+
access_token: Optional[str] = None
|
| 2071 |
+
expires_in: Optional[int] = None
|
| 2072 |
+
token_type: Optional[str] = None
|
| 2073 |
+
scope: Optional[str] = None
|
| 2074 |
+
error: Optional[str] = None
|
app/services/sheets_service.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import random
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.core.logger import get_logger
|
| 13 |
+
from app.utils.http_utils import SharedAsyncClient
|
| 14 |
+
|
| 15 |
+
_logger = get_logger(__name__)
|
| 16 |
+
_settings = get_settings()
|
| 17 |
+
|
| 18 |
+
_RETRYABLE_STATUS = frozenset({
|
| 19 |
+
int(code)
|
| 20 |
+
for code in _settings.sheets_retryable_statuses.split(",")
|
| 21 |
+
if code.strip().isdigit()
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SheetsAPIError(Exception):
|
| 26 |
+
"""Raised for upstream Google Sheets API failures that map to a client-facing error."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, message: str, status_code: int = 400, *, reason: str = "") -> None:
|
| 29 |
+
super().__init__(message)
|
| 30 |
+
self.message = message
|
| 31 |
+
self.status_code = status_code
|
| 32 |
+
self.reason = reason
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class SheetsCredentials:
|
| 37 |
+
"""Client-supplied OAuth credentials (never persisted by this backend).
|
| 38 |
+
|
| 39 |
+
``refreshed_access_token`` is populated whenever the service transparently
|
| 40 |
+
refreshes the access token, so the stateless client can persist it itself.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
access_token: str
|
| 44 |
+
refresh_token: Optional[str] = None
|
| 45 |
+
client_id: Optional[str] = None
|
| 46 |
+
client_secret: Optional[str] = None
|
| 47 |
+
expires_at: Optional[float] = None
|
| 48 |
+
refreshed_access_token: Optional[str] = field(default=None, repr=False)
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def can_refresh(self) -> bool:
|
| 52 |
+
return bool(self.refresh_token and self.client_id and self.client_secret)
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def needs_proactive_refresh(self) -> bool:
|
| 56 |
+
if self.expires_at is None:
|
| 57 |
+
return False
|
| 58 |
+
return time.time() >= self.expires_at - _settings.sheets_refresh_buffer_seconds
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class SheetsService:
|
| 62 |
+
"""Stateless, fully-async abstraction over the Google Sheets REST API.
|
| 63 |
+
|
| 64 |
+
Credentials are supplied by the caller on every invocation. Access tokens
|
| 65 |
+
are transparently refreshed against Google's token endpoint when a
|
| 66 |
+
``refresh_token`` + ``client_id`` + ``client_secret`` are provided, and the
|
| 67 |
+
resulting token is surfaced via ``SheetsCredentials.refreshed_access_token``.
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
def __init__(self) -> None:
|
| 71 |
+
self._http = SharedAsyncClient(
|
| 72 |
+
timeout=httpx.Timeout(_settings.sheets_timeout),
|
| 73 |
+
follow_redirects=False,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
async def _get_client(self) -> httpx.AsyncClient:
|
| 77 |
+
return await self._http.get()
|
| 78 |
+
|
| 79 |
+
async def close(self) -> None:
|
| 80 |
+
await self._http.close()
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
# Core request pipeline: auth, retry, backoff, error mapping
|
| 84 |
+
# ------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
async def _request(
|
| 87 |
+
self,
|
| 88 |
+
creds: SheetsCredentials,
|
| 89 |
+
method: str,
|
| 90 |
+
path: str,
|
| 91 |
+
*,
|
| 92 |
+
params: Optional[Dict[str, Any]] = None,
|
| 93 |
+
json_body: Optional[Any] = None,
|
| 94 |
+
) -> Any:
|
| 95 |
+
"""Send a Sheets API request with proactive + on-401 refresh and retries."""
|
| 96 |
+
if creds.needs_proactive_refresh:
|
| 97 |
+
await self._refresh(creds, context=f"{method} {path}")
|
| 98 |
+
|
| 99 |
+
refresh_done = False
|
| 100 |
+
attempt = 0
|
| 101 |
+
while True:
|
| 102 |
+
response = await self._send(
|
| 103 |
+
creds, method, path, params=params, json_body=json_body
|
| 104 |
+
)
|
| 105 |
+
if 200 <= response.status_code < 300:
|
| 106 |
+
return self._decode(response)
|
| 107 |
+
|
| 108 |
+
if response.status_code == 401 and creds.can_refresh and not refresh_done:
|
| 109 |
+
_logger.warning(
|
| 110 |
+
"Sheets 401 for %s %s; refreshing access token once",
|
| 111 |
+
method,
|
| 112 |
+
path,
|
| 113 |
+
)
|
| 114 |
+
await self._refresh(creds, context=f"{method} {path}")
|
| 115 |
+
refresh_done = True
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
if response.status_code in _RETRYABLE_STATUS and attempt < _settings.sheets_max_retries:
|
| 119 |
+
await self._backoff(response, attempt)
|
| 120 |
+
attempt += 1
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
raise self._map_error(response, creds)
|
| 124 |
+
|
| 125 |
+
async def _send(
|
| 126 |
+
self,
|
| 127 |
+
creds: SheetsCredentials,
|
| 128 |
+
method: str,
|
| 129 |
+
path: str,
|
| 130 |
+
*,
|
| 131 |
+
params: Optional[Dict[str, Any]] = None,
|
| 132 |
+
json_body: Optional[Any] = None,
|
| 133 |
+
) -> httpx.Response:
|
| 134 |
+
client = await self._get_client()
|
| 135 |
+
url = f"{_settings.sheets_api_base_url}{path}"
|
| 136 |
+
headers = {
|
| 137 |
+
"Authorization": f"Bearer {creds.access_token}",
|
| 138 |
+
"Accept": "application/json",
|
| 139 |
+
}
|
| 140 |
+
try:
|
| 141 |
+
return await client.request(
|
| 142 |
+
method,
|
| 143 |
+
url,
|
| 144 |
+
params=params,
|
| 145 |
+
json=json_body,
|
| 146 |
+
headers=headers,
|
| 147 |
+
)
|
| 148 |
+
except httpx.TimeoutException as exc:
|
| 149 |
+
raise SheetsAPIError(
|
| 150 |
+
f"Sheets API request timed out for {method} {path}.", status_code=504
|
| 151 |
+
) from exc
|
| 152 |
+
except httpx.RequestError as exc:
|
| 153 |
+
raise SheetsAPIError(
|
| 154 |
+
f"Failed to reach Sheets API: {exc}", status_code=502
|
| 155 |
+
) from exc
|
| 156 |
+
|
| 157 |
+
@staticmethod
|
| 158 |
+
def _decode(response: httpx.Response) -> Any:
|
| 159 |
+
if not response.content:
|
| 160 |
+
return None
|
| 161 |
+
try:
|
| 162 |
+
return response.json()
|
| 163 |
+
except ValueError:
|
| 164 |
+
return response.text
|
| 165 |
+
|
| 166 |
+
async def _backoff(self, response: httpx.Response, attempt: int) -> None:
|
| 167 |
+
retry_after = response.headers.get("Retry-After")
|
| 168 |
+
delay: Optional[float] = None
|
| 169 |
+
if retry_after:
|
| 170 |
+
try:
|
| 171 |
+
delay = float(retry_after)
|
| 172 |
+
except (TypeError, ValueError):
|
| 173 |
+
delay = None
|
| 174 |
+
if delay is None:
|
| 175 |
+
base = _settings.sheets_base_backoff_seconds * (2 ** attempt)
|
| 176 |
+
delay = min(base, _settings.sheets_max_backoff_seconds) * (0.5 + random.random())
|
| 177 |
+
delay = min(delay, _settings.sheets_max_backoff_seconds + 5.0)
|
| 178 |
+
_logger.warning(
|
| 179 |
+
"Sheets transient HTTP %s; retrying in %.2fs (attempt %d/%d)",
|
| 180 |
+
response.status_code,
|
| 181 |
+
delay,
|
| 182 |
+
attempt + 1,
|
| 183 |
+
_settings.sheets_max_retries,
|
| 184 |
+
)
|
| 185 |
+
await asyncio.sleep(delay)
|
| 186 |
+
|
| 187 |
+
def _map_error(self, response: httpx.Response, creds: SheetsCredentials) -> SheetsAPIError:
|
| 188 |
+
status = response.status_code
|
| 189 |
+
reason = ""
|
| 190 |
+
detail = ""
|
| 191 |
+
try:
|
| 192 |
+
body = response.json()
|
| 193 |
+
error = body.get("error") or {}
|
| 194 |
+
if isinstance(error, dict):
|
| 195 |
+
detail = error.get("message", "")
|
| 196 |
+
errors = error.get("errors") or []
|
| 197 |
+
if errors and isinstance(errors[0], dict):
|
| 198 |
+
reason = errors[0].get("reason", "")
|
| 199 |
+
except Exception:
|
| 200 |
+
body = {}
|
| 201 |
+
detail = response.text[:500]
|
| 202 |
+
|
| 203 |
+
_logger.error(
|
| 204 |
+
"Sheets API error: HTTP %s reason=%s detail=%s", status, reason, detail
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
if status == 401:
|
| 208 |
+
if creds.can_refresh:
|
| 209 |
+
return SheetsAPIError(
|
| 210 |
+
"Access token is invalid or expired and could not be refreshed. "
|
| 211 |
+
"Re-authorize via POST /api/v1/google/oauth/auth-url.",
|
| 212 |
+
status_code=401,
|
| 213 |
+
reason=reason,
|
| 214 |
+
)
|
| 215 |
+
return SheetsAPIError(
|
| 216 |
+
"Access token is invalid or expired. Refresh it via "
|
| 217 |
+
"POST /api/v1/google/oauth/refresh (or /api/v1/google/sheets/token/refresh) "
|
| 218 |
+
"and retry, or supply a fresh access token.",
|
| 219 |
+
status_code=401,
|
| 220 |
+
reason=reason,
|
| 221 |
+
)
|
| 222 |
+
if status == 403 and reason == "PERMISSION_DENIED":
|
| 223 |
+
return SheetsAPIError(
|
| 224 |
+
"Permission denied. The access token may be missing the required "
|
| 225 |
+
"Sheets scope (see /api/v1/google/sheets/scopes), or the spreadsheet "
|
| 226 |
+
"is not shared with the authenticated account.",
|
| 227 |
+
status_code=403,
|
| 228 |
+
reason=reason,
|
| 229 |
+
)
|
| 230 |
+
if status == 404:
|
| 231 |
+
return SheetsAPIError(
|
| 232 |
+
detail or "Spreadsheet or range not found.",
|
| 233 |
+
status_code=404,
|
| 234 |
+
reason=reason,
|
| 235 |
+
)
|
| 236 |
+
if status == 429:
|
| 237 |
+
return SheetsAPIError(
|
| 238 |
+
"Sheets API rate limit exceeded. Please retry after a short delay.",
|
| 239 |
+
status_code=429,
|
| 240 |
+
reason=reason,
|
| 241 |
+
)
|
| 242 |
+
if status in _RETRYABLE_STATUS:
|
| 243 |
+
return SheetsAPIError(
|
| 244 |
+
detail or f"Sheets API service error (HTTP {status}).",
|
| 245 |
+
status_code=status,
|
| 246 |
+
reason=reason,
|
| 247 |
+
)
|
| 248 |
+
return SheetsAPIError(
|
| 249 |
+
detail or f"Sheets API error (HTTP {status}).",
|
| 250 |
+
status_code=status,
|
| 251 |
+
reason=reason,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# ------------------------------------------------------------------
|
| 255 |
+
# Token lifecycle
|
| 256 |
+
# ------------------------------------------------------------------
|
| 257 |
+
|
| 258 |
+
async def refresh_access_token(
|
| 259 |
+
self,
|
| 260 |
+
*,
|
| 261 |
+
client_id: str,
|
| 262 |
+
client_secret: str,
|
| 263 |
+
refresh_token: str,
|
| 264 |
+
) -> Dict[str, Any]:
|
| 265 |
+
"""Refresh an access token without touching any spreadsheet (standalone)."""
|
| 266 |
+
tokens = await self._refresh(
|
| 267 |
+
creds=None,
|
| 268 |
+
client_id=client_id,
|
| 269 |
+
client_secret=client_secret,
|
| 270 |
+
refresh_token=refresh_token,
|
| 271 |
+
context="standalone refresh",
|
| 272 |
+
)
|
| 273 |
+
return tokens
|
| 274 |
+
|
| 275 |
+
async def _refresh(
|
| 276 |
+
self,
|
| 277 |
+
creds: Optional[SheetsCredentials],
|
| 278 |
+
*,
|
| 279 |
+
context: str,
|
| 280 |
+
client_id: Optional[str] = None,
|
| 281 |
+
client_secret: Optional[str] = None,
|
| 282 |
+
refresh_token: Optional[str] = None,
|
| 283 |
+
) -> Dict[str, Any]:
|
| 284 |
+
if creds is not None:
|
| 285 |
+
client_id = creds.client_id
|
| 286 |
+
client_secret = creds.client_secret
|
| 287 |
+
refresh_token = creds.refresh_token
|
| 288 |
+
|
| 289 |
+
if not (client_id and client_secret and refresh_token):
|
| 290 |
+
raise SheetsAPIError(
|
| 291 |
+
"Token refresh requires refresh_token, client_id and client_secret.",
|
| 292 |
+
status_code=400,
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
client = await self._get_client()
|
| 296 |
+
data = {
|
| 297 |
+
"client_id": client_id,
|
| 298 |
+
"client_secret": client_secret,
|
| 299 |
+
"refresh_token": refresh_token,
|
| 300 |
+
"grant_type": "refresh_token",
|
| 301 |
+
}
|
| 302 |
+
try:
|
| 303 |
+
response = await client.post(_settings.google_oauth_token_url, data=data)
|
| 304 |
+
except httpx.TimeoutException as exc:
|
| 305 |
+
raise SheetsAPIError(
|
| 306 |
+
"Google token refresh timed out.", status_code=504
|
| 307 |
+
) from exc
|
| 308 |
+
except httpx.RequestError as exc:
|
| 309 |
+
raise SheetsAPIError(
|
| 310 |
+
f"Failed to reach Google token endpoint: {exc}", status_code=502
|
| 311 |
+
) from exc
|
| 312 |
+
|
| 313 |
+
if response.status_code == 200:
|
| 314 |
+
tokens = response.json()
|
| 315 |
+
new_token = tokens.get("access_token")
|
| 316 |
+
if not new_token:
|
| 317 |
+
raise SheetsAPIError(
|
| 318 |
+
"Google did not return an access token.", status_code=502
|
| 319 |
+
)
|
| 320 |
+
expires_in = int(tokens.get("expires_in", 3600))
|
| 321 |
+
if creds is not None:
|
| 322 |
+
creds.access_token = new_token
|
| 323 |
+
creds.expires_at = time.time() + expires_in
|
| 324 |
+
creds.refreshed_access_token = new_token
|
| 325 |
+
_logger.info("Sheets access token refreshed (%s)", context)
|
| 326 |
+
return tokens
|
| 327 |
+
|
| 328 |
+
try:
|
| 329 |
+
body = response.json()
|
| 330 |
+
error = body.get("error", "")
|
| 331 |
+
description = body.get("error_description", "")
|
| 332 |
+
except Exception:
|
| 333 |
+
error = ""
|
| 334 |
+
description = ""
|
| 335 |
+
body = {}
|
| 336 |
+
|
| 337 |
+
if error == "invalid_grant":
|
| 338 |
+
raise SheetsAPIError(
|
| 339 |
+
description or "Refresh token is invalid, expired, or revoked. "
|
| 340 |
+
"Re-authorize via POST /api/v1/google/oauth/auth-url.",
|
| 341 |
+
status_code=401,
|
| 342 |
+
reason=error,
|
| 343 |
+
)
|
| 344 |
+
if error == "invalid_client":
|
| 345 |
+
raise SheetsAPIError(
|
| 346 |
+
description or "Invalid client_id or client_secret.",
|
| 347 |
+
status_code=401,
|
| 348 |
+
reason=error,
|
| 349 |
+
)
|
| 350 |
+
raise SheetsAPIError(
|
| 351 |
+
description or f"Google token refresh failed (HTTP {response.status_code}).",
|
| 352 |
+
status_code=response.status_code if response.status_code >= 400 else 502,
|
| 353 |
+
reason=error,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
# ------------------------------------------------------------------
|
| 357 |
+
# Scopes
|
| 358 |
+
# ------------------------------------------------------------------
|
| 359 |
+
|
| 360 |
+
async def list_available_scopes(self) -> List[Dict[str, Any]]:
|
| 361 |
+
from app.models.schemas import SheetsScope
|
| 362 |
+
|
| 363 |
+
return [
|
| 364 |
+
{
|
| 365 |
+
"name": scope.name,
|
| 366 |
+
"uri": scope.value,
|
| 367 |
+
"permission_level": scope.permission_level,
|
| 368 |
+
}
|
| 369 |
+
for scope in SheetsScope
|
| 370 |
+
]
|
| 371 |
+
|
| 372 |
+
# ------------------------------------------------------------------
|
| 373 |
+
# Spreadsheets
|
| 374 |
+
# ------------------------------------------------------------------
|
| 375 |
+
|
| 376 |
+
async def create_spreadsheet(
|
| 377 |
+
self,
|
| 378 |
+
creds: SheetsCredentials,
|
| 379 |
+
*,
|
| 380 |
+
title: str,
|
| 381 |
+
sheets: Optional[List[Dict[str, Any]]] = None,
|
| 382 |
+
properties: Optional[Dict[str, Any]] = None,
|
| 383 |
+
) -> Dict[str, Any]:
|
| 384 |
+
body: Dict[str, Any] = {"properties": {"title": title}}
|
| 385 |
+
if properties:
|
| 386 |
+
body["properties"].update(properties)
|
| 387 |
+
if sheets:
|
| 388 |
+
body["sheets"] = sheets
|
| 389 |
+
return await self._request(creds, "POST", "/spreadsheets", json_body=body)
|
| 390 |
+
|
| 391 |
+
async def get_spreadsheet(
|
| 392 |
+
self,
|
| 393 |
+
creds: SheetsCredentials,
|
| 394 |
+
spreadsheet_id: str,
|
| 395 |
+
*,
|
| 396 |
+
ranges: Optional[List[str]] = None,
|
| 397 |
+
include_grid_data: bool = False,
|
| 398 |
+
) -> Dict[str, Any]:
|
| 399 |
+
params: Dict[str, Any] = {}
|
| 400 |
+
if ranges:
|
| 401 |
+
params["ranges"] = ranges
|
| 402 |
+
if include_grid_data:
|
| 403 |
+
params["includeGridData"] = "true"
|
| 404 |
+
return await self._request(
|
| 405 |
+
creds, "GET", f"/spreadsheets/{spreadsheet_id}", params=params or None
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
async def get_sheet(
|
| 409 |
+
self,
|
| 410 |
+
creds: SheetsCredentials,
|
| 411 |
+
spreadsheet_id: str,
|
| 412 |
+
sheet_id: int,
|
| 413 |
+
*,
|
| 414 |
+
ranges: Optional[List[str]] = None,
|
| 415 |
+
include_grid_data: bool = False,
|
| 416 |
+
) -> Dict[str, Any]:
|
| 417 |
+
params: Dict[str, Any] = {}
|
| 418 |
+
if ranges:
|
| 419 |
+
params["ranges"] = ranges
|
| 420 |
+
if include_grid_data:
|
| 421 |
+
params["includeGridData"] = "true"
|
| 422 |
+
return await self._request(
|
| 423 |
+
creds,
|
| 424 |
+
"GET",
|
| 425 |
+
f"/spreadsheets/{spreadsheet_id}/sheets/{sheet_id}",
|
| 426 |
+
params=params or None,
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
async def batch_update(
|
| 430 |
+
self,
|
| 431 |
+
creds: SheetsCredentials,
|
| 432 |
+
spreadsheet_id: str,
|
| 433 |
+
*,
|
| 434 |
+
requests: List[Dict[str, Any]],
|
| 435 |
+
response_include_grid_data: Optional[bool] = None,
|
| 436 |
+
) -> Dict[str, Any]:
|
| 437 |
+
body: Dict[str, Any] = {"requests": requests}
|
| 438 |
+
if response_include_grid_data is not None:
|
| 439 |
+
body["responseIncludeGridData"] = response_include_grid_data
|
| 440 |
+
return await self._request(
|
| 441 |
+
creds, "POST", f"/spreadsheets/{spreadsheet_id}:batchUpdate", json_body=body
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# ------------------------------------------------------------------
|
| 445 |
+
# Values
|
| 446 |
+
# ------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
async def get_values(
|
| 449 |
+
self,
|
| 450 |
+
creds: SheetsCredentials,
|
| 451 |
+
spreadsheet_id: str,
|
| 452 |
+
range_: str,
|
| 453 |
+
*,
|
| 454 |
+
major_dimension: Optional[str] = None,
|
| 455 |
+
value_render_option: Optional[str] = None,
|
| 456 |
+
date_time_render_option: Optional[str] = None,
|
| 457 |
+
) -> Dict[str, Any]:
|
| 458 |
+
params: Dict[str, Any] = {}
|
| 459 |
+
if major_dimension:
|
| 460 |
+
params["majorDimension"] = major_dimension
|
| 461 |
+
if value_render_option:
|
| 462 |
+
params["valueRenderOption"] = value_render_option
|
| 463 |
+
if date_time_render_option:
|
| 464 |
+
params["dateTimeRenderOption"] = date_time_render_option
|
| 465 |
+
return await self._request(
|
| 466 |
+
creds,
|
| 467 |
+
"GET",
|
| 468 |
+
f"/spreadsheets/{spreadsheet_id}/values/{range_}",
|
| 469 |
+
params=params or None,
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
async def batch_get_values(
|
| 473 |
+
self,
|
| 474 |
+
creds: SheetsCredentials,
|
| 475 |
+
spreadsheet_id: str,
|
| 476 |
+
*,
|
| 477 |
+
ranges: List[str],
|
| 478 |
+
major_dimension: Optional[str] = None,
|
| 479 |
+
value_render_option: Optional[str] = None,
|
| 480 |
+
date_time_render_option: Optional[str] = None,
|
| 481 |
+
) -> Dict[str, Any]:
|
| 482 |
+
params: Dict[str, Any] = {"ranges": ranges}
|
| 483 |
+
if major_dimension:
|
| 484 |
+
params["majorDimension"] = major_dimension
|
| 485 |
+
if value_render_option:
|
| 486 |
+
params["valueRenderOption"] = value_render_option
|
| 487 |
+
if date_time_render_option:
|
| 488 |
+
params["dateTimeRenderOption"] = date_time_render_option
|
| 489 |
+
return await self._request(
|
| 490 |
+
creds,
|
| 491 |
+
"GET",
|
| 492 |
+
f"/spreadsheets/{spreadsheet_id}/values:batchGet",
|
| 493 |
+
params=params,
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
async def update_values(
|
| 497 |
+
self,
|
| 498 |
+
creds: SheetsCredentials,
|
| 499 |
+
spreadsheet_id: str,
|
| 500 |
+
range_: str,
|
| 501 |
+
*,
|
| 502 |
+
values: List[List[Any]],
|
| 503 |
+
major_dimension: Optional[str] = None,
|
| 504 |
+
value_input_option: str = "RAW",
|
| 505 |
+
include_values_in_response: Optional[bool] = None,
|
| 506 |
+
value_render_option: Optional[str] = None,
|
| 507 |
+
) -> Dict[str, Any]:
|
| 508 |
+
body: Dict[str, Any] = {"values": values}
|
| 509 |
+
if major_dimension:
|
| 510 |
+
body["majorDimension"] = major_dimension
|
| 511 |
+
params: Dict[str, Any] = {"valueInputOption": value_input_option}
|
| 512 |
+
if include_values_in_response is not None:
|
| 513 |
+
params["includeValuesInResponse"] = "true" if include_values_in_response else "false"
|
| 514 |
+
if value_render_option:
|
| 515 |
+
params["valueRenderOption"] = value_render_option
|
| 516 |
+
return await self._request(
|
| 517 |
+
creds,
|
| 518 |
+
"PUT",
|
| 519 |
+
f"/spreadsheets/{spreadsheet_id}/values/{range_}",
|
| 520 |
+
params=params,
|
| 521 |
+
json_body=body,
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
async def append_values(
|
| 525 |
+
self,
|
| 526 |
+
creds: SheetsCredentials,
|
| 527 |
+
spreadsheet_id: str,
|
| 528 |
+
range_: str,
|
| 529 |
+
*,
|
| 530 |
+
values: List[List[Any]],
|
| 531 |
+
major_dimension: Optional[str] = None,
|
| 532 |
+
value_input_option: str = "USER_ENTERED",
|
| 533 |
+
insert_data_option: Optional[str] = None,
|
| 534 |
+
include_values_in_response: Optional[bool] = None,
|
| 535 |
+
) -> Dict[str, Any]:
|
| 536 |
+
body: Dict[str, Any] = {"values": values}
|
| 537 |
+
if major_dimension:
|
| 538 |
+
body["majorDimension"] = major_dimension
|
| 539 |
+
params: Dict[str, Any] = {"valueInputOption": value_input_option}
|
| 540 |
+
if insert_data_option:
|
| 541 |
+
params["insertDataOption"] = insert_data_option
|
| 542 |
+
if include_values_in_response is not None:
|
| 543 |
+
params["includeValuesInResponse"] = "true" if include_values_in_response else "false"
|
| 544 |
+
return await self._request(
|
| 545 |
+
creds,
|
| 546 |
+
"POST",
|
| 547 |
+
f"/spreadsheets/{spreadsheet_id}/values/{range_}:append",
|
| 548 |
+
params=params,
|
| 549 |
+
json_body=body,
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
async def clear_values(
|
| 553 |
+
self,
|
| 554 |
+
creds: SheetsCredentials,
|
| 555 |
+
spreadsheet_id: str,
|
| 556 |
+
range_: str,
|
| 557 |
+
) -> Dict[str, Any]:
|
| 558 |
+
return await self._request(
|
| 559 |
+
creds,
|
| 560 |
+
"POST",
|
| 561 |
+
f"/spreadsheets/{spreadsheet_id}/values/{range_}:clear",
|
| 562 |
+
json_body={},
|
| 563 |
+
)
|