migrated all pawprint work

This commit is contained in:
buenosairesam
2025-12-31 08:34:18 -03:00
parent fc63e9010c
commit 680969ca42
63 changed files with 4687 additions and 5 deletions

View File

View File

@@ -0,0 +1,24 @@
"""
Google OAuth2 configuration loaded from .env file.
"""
from pathlib import Path
from pydantic_settings import BaseSettings
ENV_FILE = Path(__file__).parent.parent / ".env"
class GoogleConfig(BaseSettings):
google_client_id: str
google_client_secret: str
google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback
google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
api_port: int = 8003
model_config = {
"env_file": ENV_FILE,
"env_file_encoding": "utf-8",
}
settings = GoogleConfig()

View File

@@ -0,0 +1,147 @@
"""
Google OAuth2 flow implementation.
Isolated OAuth2 client that can run without FastAPI.
"""
from typing import Optional
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from .config import settings
class GoogleOAuth:
"""
Google OAuth2 client.
Handles authorization flow, token exchange, and token refresh.
"""
def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
redirect_uri: Optional[str] = None,
scopes: Optional[list[str]] = None,
):
"""
Initialize OAuth client.
Falls back to settings if parameters not provided.
"""
self.client_id = client_id or settings.google_client_id
self.client_secret = client_secret or settings.google_client_secret
self.redirect_uri = redirect_uri or settings.google_redirect_uri
self.scopes = scopes or settings.google_scopes.split()
def _create_flow(self) -> Flow:
"""Create OAuth flow object."""
client_config = {
"web": {
"client_id": self.client_id,
"client_secret": self.client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
flow = Flow.from_client_config(
client_config,
scopes=self.scopes,
redirect_uri=self.redirect_uri,
)
return flow
def get_authorization_url(self, state: Optional[str] = None) -> str:
"""
Generate OAuth2 authorization URL.
Args:
state: Optional state parameter for CSRF protection
Returns:
URL to redirect user for Google authorization
"""
flow = self._create_flow()
auth_url, _ = flow.authorization_url(
access_type="offline", # Request refresh token
include_granted_scopes="true",
state=state,
)
return auth_url
def exchange_code_for_tokens(self, code: str) -> dict:
"""
Exchange authorization code for tokens.
Args:
code: Authorization code from callback
Returns:
Token dict containing:
- access_token
- refresh_token
- expires_in
- scope
- token_type
"""
flow = self._create_flow()
flow.fetch_token(code=code)
credentials = flow.credentials
return {
"access_token": credentials.token,
"refresh_token": credentials.refresh_token,
"expires_in": 3600, # Google tokens typically 1 hour
"scope": " ".join(credentials.scopes or []),
"token_type": "Bearer",
}
def refresh_access_token(self, refresh_token: str) -> dict:
"""
Refresh an expired access token.
Args:
refresh_token: The refresh token
Returns:
New token dict with fresh access_token
"""
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=self.client_id,
client_secret=self.client_secret,
)
request = Request()
credentials.refresh(request)
return {
"access_token": credentials.token,
"refresh_token": refresh_token, # Keep original refresh token
"expires_in": 3600,
"scope": " ".join(credentials.scopes or []),
"token_type": "Bearer",
}
def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials:
"""
Create Google Credentials object from tokens.
Args:
access_token: OAuth2 access token
refresh_token: Optional refresh token
Returns:
Google Credentials object for API calls
"""
return Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
)

View File

@@ -0,0 +1,130 @@
"""
Google Sheets API client.
Isolated client that can run without FastAPI.
"""
from typing import Optional
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
class GoogleSheetsError(Exception):
"""Sheets API error."""
pass
class GoogleSheetsClient:
"""
Google Sheets API client.
Provides methods to read spreadsheet data.
"""
def __init__(self, credentials: Credentials):
"""
Initialize Sheets client.
Args:
credentials: Google OAuth2 credentials
"""
self.credentials = credentials
self.service = build("sheets", "v4", credentials=credentials)
def get_spreadsheet_metadata(self, spreadsheet_id: str) -> dict:
"""
Get spreadsheet metadata (title, sheets, etc.).
Args:
spreadsheet_id: The spreadsheet ID
Returns:
Spreadsheet metadata
"""
try:
result = self.service.spreadsheets().get(
spreadsheetId=spreadsheet_id
).execute()
return result
except HttpError as e:
raise GoogleSheetsError(f"Failed to get spreadsheet: {e}")
def get_sheet_values(
self,
spreadsheet_id: str,
range_name: str,
value_render_option: str = "FORMATTED_VALUE",
) -> list[list]:
"""
Get values from a sheet range.
Args:
spreadsheet_id: The spreadsheet ID
range_name: A1 notation range (e.g., 'Sheet1!A1:D10')
value_render_option: How values should be rendered
- FORMATTED_VALUE: Values formatted as strings (default)
- UNFORMATTED_VALUE: Values in calculated format
- FORMULA: Formulas
Returns:
List of rows, each row is a list of cell values
"""
try:
result = self.service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=range_name,
valueRenderOption=value_render_option,
).execute()
return result.get("values", [])
except HttpError as e:
raise GoogleSheetsError(f"Failed to get values: {e}")
def get_all_sheets(self, spreadsheet_id: str) -> list[dict]:
"""
Get list of all sheets in a spreadsheet.
Args:
spreadsheet_id: The spreadsheet ID
Returns:
List of sheet metadata (title, id, index, etc.)
"""
metadata = self.get_spreadsheet_metadata(spreadsheet_id)
return [
{
"title": sheet["properties"]["title"],
"sheet_id": sheet["properties"]["sheetId"],
"index": sheet["properties"]["index"],
"row_count": sheet["properties"]["gridProperties"].get("rowCount", 0),
"column_count": sheet["properties"]["gridProperties"].get("columnCount", 0),
}
for sheet in metadata.get("sheets", [])
]
def batch_get_values(
self,
spreadsheet_id: str,
ranges: list[str],
value_render_option: str = "FORMATTED_VALUE",
) -> dict:
"""
Get multiple ranges in a single request.
Args:
spreadsheet_id: The spreadsheet ID
ranges: List of A1 notation ranges
value_render_option: How values should be rendered
Returns:
Dict with spreadsheetId and valueRanges list
"""
try:
result = self.service.spreadsheets().values().batchGet(
spreadsheetId=spreadsheet_id,
ranges=ranges,
valueRenderOption=value_render_option,
).execute()
return result
except HttpError as e:
raise GoogleSheetsError(f"Failed to batch get values: {e}")