first commit

master
waready 21 hours ago
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"

7
.gitignore vendored

@ -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…
Cancel
Save