commit bcbd6ef9a9aa9436e0c5012f6889abd7fbd8d050 Author: waready Date: Thu Feb 19 00:24:56 2026 -0500 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e7957e --- /dev/null +++ b/.env.example @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78aef0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +.env +app.db +credentials/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ceab1b --- /dev/null +++ b/README.md @@ -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) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..7f87520 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/db.py b/app/core/db.py new file mode 100644 index 0000000..2ee3f66 --- /dev/null +++ b/app/core/db.py @@ -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 diff --git a/app/google/__init__.py b/app/google/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/google/auth.py b/app/google/auth.py new file mode 100644 index 0000000..f92912c --- /dev/null +++ b/app/google/auth.py @@ -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) diff --git a/app/google/client.py b/app/google/client.py new file mode 100644 index 0000000..784ce59 --- /dev/null +++ b/app/google/client.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d3ad578 --- /dev/null +++ b/app/main.py @@ -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"]) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..70a77f5 --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/calendar.py b/app/routers/calendar.py new file mode 100644 index 0000000..0207677 --- /dev/null +++ b/app/routers/calendar.py @@ -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)) diff --git a/app/routers/classroom.py b/app/routers/classroom.py new file mode 100644 index 0000000..7f83fb4 --- /dev/null +++ b/app/routers/classroom.py @@ -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)) diff --git a/app/routers/gmail.py b/app/routers/gmail.py new file mode 100644 index 0000000..31597a1 --- /dev/null +++ b/app/routers/gmail.py @@ -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)) diff --git a/app/routers/oauth.py b/app/routers/oauth.py new file mode 100644 index 0000000..e114ab2 --- /dev/null +++ b/app/routers/oauth.py @@ -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} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43adcc0 --- /dev/null +++ b/requirements.txt @@ -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