first commit
commit
bcbd6ef9a9
@ -0,0 +1,11 @@
|
||||
# App
|
||||
APP_NAME="Google Workspace Hub"
|
||||
DATABASE_URL="sqlite:///./app.db"
|
||||
|
||||
# Google OAuth (archivo en credentials/client_secret.json)
|
||||
GOOGLE_CLIENT_SECRET_FILE="credentials/client_secret.json"
|
||||
GOOGLE_REDIRECT_URI="http://127.0.0.1:8000/auth/google/callback"
|
||||
|
||||
# Scopes separados por espacio
|
||||
# Ajusta según lo que necesites (menos scopes = mejor)
|
||||
GOOGLE_SCOPES="openid email profile https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/classroom.courses.readonly https://www.googleapis.com/auth/calendar.events"
|
||||
@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
app.db
|
||||
credentials/
|
||||
*.log
|
||||
@ -0,0 +1,97 @@
|
||||
# Google Workspace Hub (FastAPI + SQLite)
|
||||
|
||||
Proyecto base en **FastAPI** para centralizar acciones con **Google Workspace** (Gmail, Classroom, Calendar/Meet, etc.)
|
||||
y guardar tokens/usuarios en **SQLite**.
|
||||
|
||||
## Qué incluye
|
||||
- FastAPI listo para correr
|
||||
- SQLite (con SQLModel)
|
||||
- Flujo OAuth web:
|
||||
- `GET /auth/google/start` genera el link de autorización
|
||||
- `GET /auth/google/callback` recibe el `code` y guarda el token en SQLite
|
||||
- Ejemplos de endpoints:
|
||||
- Gmail: `GET /gmail/profile`, `POST /gmail/send`
|
||||
- Classroom: `GET /classroom/courses`
|
||||
- Calendar/Meet: `POST /calendar/events` (crea evento con Meet)
|
||||
|
||||
> Nota: Para que funcione necesitas crear un **OAuth Client (Web application)** en Google Cloud Console
|
||||
> y descargar el `client_secret.json`.
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
- Python 3.11+ (recomendado)
|
||||
- Una cuenta Google (o Workspace) y un proyecto en Google Cloud con APIs habilitadas:
|
||||
- Gmail API
|
||||
- Google Classroom API
|
||||
- Google Calendar API
|
||||
|
||||
## 1) Setup rápido (Windows PowerShell)
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
## 2) Coloca tus credenciales
|
||||
1. Crea carpeta: `credentials/`
|
||||
2. Copia tu archivo OAuth aquí:
|
||||
- `credentials/client_secret.json`
|
||||
|
||||
**No lo subas a git** (ya está en `.gitignore`).
|
||||
|
||||
## 3) Configura el .env
|
||||
Edita `.env` y pon:
|
||||
- `GOOGLE_REDIRECT_URI` (ej: `http://127.0.0.1:8000/auth/google/callback`)
|
||||
- `GOOGLE_SCOPES` (ya viene uno útil por defecto)
|
||||
|
||||
## 4) Corre el servidor
|
||||
```bash
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Abre:
|
||||
- http://127.0.0.1:8000/docs
|
||||
|
||||
## 5) Autoriza tu cuenta Google
|
||||
1. Ve a:
|
||||
- `GET /auth/google/start`
|
||||
2. Abre el `auth_url` que te devuelve
|
||||
3. Acepta permisos
|
||||
4. Google te redirige a `GOOGLE_REDIRECT_URI`
|
||||
5. Eso guarda el token en SQLite (`app.db`)
|
||||
|
||||
## 6) Probar endpoints
|
||||
- Gmail profile:
|
||||
- `GET /gmail/profile?account_email=TU_CORREO@gmail.com`
|
||||
- Enviar correo:
|
||||
- `POST /gmail/send?account_email=TU_CORREO@gmail.com`
|
||||
- Listar cursos:
|
||||
- `GET /classroom/courses?account_email=TU_CORREO@gmail.com`
|
||||
- Crear evento con Meet:
|
||||
- `POST /calendar/events?account_email=TU_CORREO@gmail.com`
|
||||
|
||||
---
|
||||
|
||||
## Estructura
|
||||
```
|
||||
app/
|
||||
core/ config + DB
|
||||
google/ auth + clientes Google
|
||||
routers/ endpoints (oauth, gmail, classroom, calendar)
|
||||
models.py tablas SQLite
|
||||
```
|
||||
|
||||
## Notas importantes
|
||||
- Si tu cuenta es Workspace y quieres administrar usuarios/dominio (acciones “de admin”), eso normalmente se hace con
|
||||
**Service Account + Domain-wide delegation**. Aquí te dejo el proyecto listo para OAuth (usuario) porque es lo más simple
|
||||
para empezar.
|
||||
- Si algo “se cae” por permisos: revisa los **scopes** y vuelve a autorizar (borrando el token del usuario en SQLite).
|
||||
|
||||
---
|
||||
|
||||
## Próximos pasos sugeridos
|
||||
- Roles/usuarios internos (login propio)
|
||||
- Cola de tareas (para correos masivos): Celery/RQ/Arq
|
||||
- Logs en DB (auditoría)
|
||||
@ -0,0 +1,22 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
APP_NAME: str = "Google Workspace Hub"
|
||||
DATABASE_URL: str = "sqlite:///./app.db"
|
||||
|
||||
GOOGLE_CLIENT_SECRET_FILE: str = "credentials/client_secret.json"
|
||||
GOOGLE_REDIRECT_URI: str = "http://127.0.0.1:8000/auth/google/callback"
|
||||
GOOGLE_SCOPES: str = (
|
||||
"openid email profile "
|
||||
"https://www.googleapis.com/auth/gmail.send "
|
||||
"https://www.googleapis.com/auth/gmail.readonly "
|
||||
"https://www.googleapis.com/auth/classroom.courses.readonly "
|
||||
"https://www.googleapis.com/auth/calendar.events"
|
||||
)
|
||||
|
||||
def scopes_list(self) -> list[str]:
|
||||
return [s for s in self.GOOGLE_SCOPES.split() if s.strip()]
|
||||
|
||||
settings = Settings()
|
||||
@ -0,0 +1,12 @@
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
from app.core.config import settings
|
||||
|
||||
connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.DATABASE_URL, echo=False, connect_args=connect_args)
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
@ -0,0 +1,36 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
def build_flow(state: Optional[str] = None) -> Flow:
|
||||
flow = Flow.from_client_secrets_file(
|
||||
settings.GOOGLE_CLIENT_SECRET_FILE,
|
||||
scopes=settings.scopes_list(),
|
||||
state=state,
|
||||
)
|
||||
flow.redirect_uri = settings.GOOGLE_REDIRECT_URI
|
||||
return flow
|
||||
|
||||
def creds_from_token_json(token_json: str) -> Credentials:
|
||||
data = json.loads(token_json)
|
||||
# google.oauth2.credentials.Credentials acepta kwargs del token JSON
|
||||
return Credentials(**data)
|
||||
|
||||
def token_json_from_creds(creds: Credentials) -> str:
|
||||
# Convertimos a dict estándar
|
||||
data = {
|
||||
"token": creds.token,
|
||||
"refresh_token": creds.refresh_token,
|
||||
"token_uri": creds.token_uri,
|
||||
"client_id": creds.client_id,
|
||||
"client_secret": creds.client_secret,
|
||||
"scopes": creds.scopes,
|
||||
"id_token": getattr(creds, "id_token", None),
|
||||
}
|
||||
# Algunos campos pueden ser None; igual está ok.
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
@ -0,0 +1,8 @@
|
||||
from googleapiclient.discovery import build
|
||||
from google.oauth2.credentials import Credentials
|
||||
from app.google.auth import creds_from_token_json
|
||||
|
||||
def get_service(api_name: str, api_version: str, token_json: str):
|
||||
creds: Credentials = creds_from_token_json(token_json)
|
||||
# cache_discovery=False evita warnings en algunos entornos
|
||||
return build(api_name, api_version, credentials=creds, cache_discovery=False)
|
||||
@ -0,0 +1,19 @@
|
||||
from fastapi import FastAPI
|
||||
from app.core.config import settings
|
||||
from app.core.db import create_db_and_tables
|
||||
from app.routers import oauth, gmail, classroom, calendar
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME)
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
create_db_and_tables()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "app": settings.APP_NAME}
|
||||
|
||||
app.include_router(oauth.router, prefix="/auth/google", tags=["auth"])
|
||||
app.include_router(gmail.router, prefix="/gmail", tags=["gmail"])
|
||||
app.include_router(classroom.router, prefix="/classroom", tags=["classroom"])
|
||||
app.include_router(calendar.router, prefix="/calendar", tags=["calendar"])
|
||||
@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
class GoogleAccount(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
||||
# email del usuario autorizado (ej: tu@gmail.com)
|
||||
email: str = Field(index=True, unique=True)
|
||||
|
||||
# token OAuth completo (json) guardado como texto
|
||||
token_json: str
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class AuditLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
account_email: str = Field(index=True)
|
||||
action: str
|
||||
detail: str = ""
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.models import GoogleAccount, AuditLog
|
||||
from app.google.client import get_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CreateEventIn(BaseModel):
|
||||
title: str = "Reunión"
|
||||
minutes_from_now: int = 10
|
||||
duration_minutes: int = 60
|
||||
timezone: str = "America/Lima"
|
||||
attendees: list[str] = []
|
||||
|
||||
def get_account(session: Session, email: str) -> GoogleAccount:
|
||||
acc = session.exec(select(GoogleAccount).where(GoogleAccount.email == email)).first()
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="Cuenta no autorizada. Primero usa /auth/google/start.")
|
||||
return acc
|
||||
|
||||
@router.post("/events")
|
||||
def create_event(payload: CreateEventIn, account_email: str, session: Session = Depends(get_session)):
|
||||
acc = get_account(session, account_email)
|
||||
cal = get_service("calendar", "v3", acc.token_json)
|
||||
|
||||
# Usamos ISO con timezone local (Google acepta timeZone separado)
|
||||
start_dt = datetime.now(timezone.utc) + timedelta(minutes=payload.minutes_from_now)
|
||||
end_dt = start_dt + timedelta(minutes=payload.duration_minutes)
|
||||
|
||||
event = {
|
||||
"summary": payload.title,
|
||||
"start": {"dateTime": start_dt.isoformat(), "timeZone": payload.timezone},
|
||||
"end": {"dateTime": end_dt.isoformat(), "timeZone": payload.timezone},
|
||||
"attendees": [{"email": a} for a in payload.attendees],
|
||||
"conferenceData": {
|
||||
"createRequest": {
|
||||
"requestId": f"meet-{int(datetime.utcnow().timestamp())}",
|
||||
"conferenceSolutionKey": {"type": "hangoutsMeet"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
created = cal.events().insert(
|
||||
calendarId="primary",
|
||||
body=event,
|
||||
conferenceDataVersion=1,
|
||||
sendUpdates="all" if payload.attendees else "none",
|
||||
).execute()
|
||||
|
||||
meet_link = created.get("hangoutLink")
|
||||
session.add(AuditLog(account_email=account_email, action="calendar_create_event", detail=f"meet={meet_link}"))
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"status": "created",
|
||||
"eventId": created.get("id"),
|
||||
"htmlLink": created.get("htmlLink"),
|
||||
"meetLink": meet_link,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.models import GoogleAccount, AuditLog
|
||||
from app.google.client import get_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def get_account(session: Session, email: str) -> GoogleAccount:
|
||||
acc = session.exec(select(GoogleAccount).where(GoogleAccount.email == email)).first()
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="Cuenta no autorizada. Primero usa /auth/google/start.")
|
||||
return acc
|
||||
|
||||
@router.get("/courses")
|
||||
def list_courses(account_email: str, session: Session = Depends(get_session)):
|
||||
acc = get_account(session, account_email)
|
||||
classroom = get_service("classroom", "v1", acc.token_json)
|
||||
try:
|
||||
res = classroom.courses().list(pageSize=20).execute()
|
||||
session.add(AuditLog(account_email=account_email, action="classroom_courses", detail="ok"))
|
||||
session.commit()
|
||||
return res
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@ -0,0 +1,55 @@
|
||||
import base64
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.models import GoogleAccount, AuditLog
|
||||
from app.google.client import get_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SendEmailIn(BaseModel):
|
||||
to: EmailStr
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
def get_account(session: Session, email: str) -> GoogleAccount:
|
||||
acc = session.exec(select(GoogleAccount).where(GoogleAccount.email == email)).first()
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="Cuenta no autorizada. Primero usa /auth/google/start.")
|
||||
return acc
|
||||
|
||||
@router.get("/profile")
|
||||
def gmail_profile(account_email: str, session: Session = Depends(get_session)):
|
||||
acc = get_account(session, account_email)
|
||||
gmail = get_service("gmail", "v1", acc.token_json)
|
||||
try:
|
||||
profile = gmail.users().getProfile(userId="me").execute()
|
||||
session.add(AuditLog(account_email=account_email, action="gmail_profile", detail="ok"))
|
||||
session.commit()
|
||||
return profile
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/send")
|
||||
def gmail_send(payload: SendEmailIn, account_email: str, session: Session = Depends(get_session)):
|
||||
acc = get_account(session, account_email)
|
||||
gmail = get_service("gmail", "v1", acc.token_json)
|
||||
|
||||
message = MIMEText(payload.body, "plain", "utf-8")
|
||||
message["to"] = payload.to
|
||||
message["subject"] = payload.subject
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
|
||||
body = {"raw": raw}
|
||||
|
||||
try:
|
||||
sent = gmail.users().messages().send(userId="me", body=body).execute()
|
||||
session.add(AuditLog(account_email=account_email, action="gmail_send", detail=f"to={payload.to}"))
|
||||
session.commit()
|
||||
return {"status": "sent", "id": sent.get("id")}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.models import GoogleAccount
|
||||
from app.google.auth import build_flow, token_json_from_creds
|
||||
from app.google.client import get_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/start")
|
||||
def start_oauth():
|
||||
"""Devuelve el auth_url para que el usuario lo abra y autorice."""
|
||||
flow = build_flow()
|
||||
auth_url, state = flow.authorization_url(
|
||||
access_type="offline",
|
||||
include_granted_scopes="true",
|
||||
prompt="consent",
|
||||
)
|
||||
return {"auth_url": auth_url, "state": state}
|
||||
|
||||
@router.get("/callback")
|
||||
def oauth_callback(request: Request, session: Session = Depends(get_session)):
|
||||
"""Recibe code/state y guarda token en SQLite."""
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="Falta 'code' en el callback")
|
||||
|
||||
flow = build_flow(state=state)
|
||||
try:
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"No se pudo obtener token: {e}")
|
||||
|
||||
creds = flow.credentials
|
||||
token_json = token_json_from_creds(creds)
|
||||
|
||||
# Obtenemos el email con OAuth2 userinfo
|
||||
try:
|
||||
oauth2 = get_service("oauth2", "v2", token_json)
|
||||
info = oauth2.userinfo().get().execute()
|
||||
email = info.get("email")
|
||||
if not email:
|
||||
raise ValueError("No se pudo leer email")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Token ok pero no pude obtener email: {e}")
|
||||
|
||||
existing = session.exec(select(GoogleAccount).where(GoogleAccount.email == email)).first()
|
||||
now = datetime.utcnow()
|
||||
if existing:
|
||||
existing.token_json = token_json
|
||||
existing.updated_at = now
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
return {"status": "updated", "email": email}
|
||||
else:
|
||||
acc = GoogleAccount(email=email, token_json=token_json, created_at=now, updated_at=now)
|
||||
session.add(acc)
|
||||
session.commit()
|
||||
return {"status": "created", "email": email}
|
||||
|
||||
@router.get("/accounts")
|
||||
def list_accounts(session: Session = Depends(get_session)):
|
||||
rows = session.exec(select(GoogleAccount)).all()
|
||||
return [{"email": r.email, "created_at": r.created_at, "updated_at": r.updated_at} for r in rows]
|
||||
|
||||
@router.delete("/accounts/{email}")
|
||||
def delete_account(email: str, session: Session = Depends(get_session)):
|
||||
acc = session.exec(select(GoogleAccount).where(GoogleAccount.email == email)).first()
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="Cuenta no encontrada")
|
||||
session.delete(acc)
|
||||
session.commit()
|
||||
return {"status": "deleted", "email": email}
|
||||
@ -0,0 +1,12 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic-settings==2.7.1
|
||||
sqlmodel==0.0.22
|
||||
|
||||
google-api-python-client==2.164.0
|
||||
google-auth==2.38.0
|
||||
google-auth-oauthlib==1.2.1
|
||||
google-auth-httplib2==0.2.0
|
||||
|
||||
email-validator==2.2.0
|
||||
python-multipart==0.0.20
|
||||
Loading…
Reference in New Issue