From 541a8484a7111b6c43883fb3f1a47f7fab3d7adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20AS?= Date: Wed, 28 Jan 2026 01:30:15 +0000 Subject: [PATCH] feat(phase-1): Complete foundation setup - Add User model and authentication system with JWT cookies - Implement login/logout routes and protected dashboard - Add Alembic database migration configuration - Add create_admin.py script for initial user setup - Make ContentGenerator and ImageGenerator lazy-initialized - Add comprehensive API keys setup documentation - Fix startup errors when services unavailable Co-Authored-By: Claude Opus 4.5 --- alembic.ini | 93 +++++++++ alembic/README | 1 + alembic/env.py | 86 +++++++++ alembic/script.py.mako | 26 +++ app/api/routes/auth.py | 236 +++++++++++++++++++++++ app/api/routes/dashboard.py | 52 ++++- app/main.py | 18 +- app/models/__init__.py | 2 + app/models/user.py | 49 +++++ app/services/content_generator.py | 17 +- app/services/image_generator.py | 20 +- dashboard/templates/login.html | 111 +++++++++++ docs/API_KEYS_SETUP.md | 309 ++++++++++++++++++++++++++++++ scripts/create_admin.py | 90 +++++++++ 14 files changed, 1092 insertions(+), 18 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 app/api/routes/auth.py create mode 100644 app/models/user.py create mode 100644 dashboard/templates/login.html create mode 100644 docs/API_KEYS_SETUP.md create mode 100644 scripts/create_admin.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0ef784b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,93 @@ +# Alembic Configuration File + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator +# version_path_separator = : + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLAlchemy URL - will be overridden by env.py +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - set to true to enable +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -q + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..307a8c7 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with async-ready approach. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..3567549 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,86 @@ +""" +Alembic environment configuration. +""" + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Import models and config +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.core.config import settings +from app.core.database import Base +from app.models import ( + User, Product, Service, TipTemplate, + Post, ContentCalendar, ImageTemplate, Interaction +) + +# this is the Alembic Config object +config = context.config + +# Override sqlalchemy.url with our settings +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..0c04829 --- /dev/null +++ b/app/api/routes/auth.py @@ -0,0 +1,236 @@ +""" +API Routes para autenticación. +""" + +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.core.security import ( + verify_password, + get_password_hash, + create_access_token, + decode_token, + ACCESS_TOKEN_EXPIRE_MINUTES +) +from app.models.user import User + +router = APIRouter() +templates = Jinja2Templates(directory="dashboard/templates") + +# Cookie name for JWT token +TOKEN_COOKIE_NAME = "access_token" + + +# =========================================== +# SCHEMAS +# =========================================== + +class LoginRequest(BaseModel): + username: str + password: str + + +class UserCreate(BaseModel): + username: str + email: str + password: str + full_name: Optional[str] = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +# =========================================== +# HELPER FUNCTIONS +# =========================================== + +def get_current_user_from_cookie(request: Request, db: Session) -> Optional[User]: + """Obtener usuario actual desde la cookie JWT.""" + token = request.cookies.get(TOKEN_COOKIE_NAME) + if not token: + return None + + try: + # Remover "Bearer " si existe + if token.startswith("Bearer "): + token = token[7:] + + payload = decode_token(token) + username = payload.get("sub") + if not username: + return None + + user = db.query(User).filter(User.username == username).first() + return user if user and user.is_active else None + + except Exception: + return None + + +def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: + """Autenticar usuario con username y password.""" + user = db.query(User).filter(User.username == username).first() + if not user: + # Intentar con email + user = db.query(User).filter(User.email == username).first() + + if not user or not verify_password(password, user.hashed_password): + return None + + return user + + +# =========================================== +# PAGES (HTML) +# =========================================== + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, db: Session = Depends(get_db)): + """Página de login.""" + # Si ya está autenticado, redirigir al dashboard + user = get_current_user_from_cookie(request, db) + if user: + return RedirectResponse(url="/", status_code=302) + + return templates.TemplateResponse("login.html", { + "request": request, + "error": None + }) + + +@router.post("/login", response_class=HTMLResponse) +async def login_submit( + request: Request, + db: Session = Depends(get_db) +): + """Procesar formulario de login.""" + form = await request.form() + username = form.get("username") + password = form.get("password") + + user = authenticate_user(db, username, password) + + if not user: + return templates.TemplateResponse("login.html", { + "request": request, + "error": "Usuario o contraseña incorrectos" + }) + + # Crear token + access_token = create_access_token( + data={"sub": user.username}, + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + + # Actualizar last_login + user.last_login = datetime.utcnow() + db.commit() + + # Crear respuesta con cookie + response = RedirectResponse(url="/", status_code=302) + response.set_cookie( + key=TOKEN_COOKIE_NAME, + value=f"Bearer {access_token}", + httponly=True, + max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + samesite="lax" + ) + + return response + + +@router.get("/logout") +async def logout(): + """Cerrar sesión.""" + response = RedirectResponse(url="/login", status_code=302) + response.delete_cookie(TOKEN_COOKIE_NAME) + return response + + +# =========================================== +# API ENDPOINTS (JSON) +# =========================================== + +@router.post("/api/auth/login", response_model=TokenResponse) +async def api_login( + login_data: LoginRequest, + db: Session = Depends(get_db) +): + """Login via API (devuelve JWT).""" + user = authenticate_user(db, login_data.username, login_data.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuario o contraseña incorrectos", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = create_access_token( + data={"sub": user.username}, + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + + # Actualizar last_login + user.last_login = datetime.utcnow() + db.commit() + + return TokenResponse(access_token=access_token) + + +@router.post("/api/auth/register") +async def api_register( + user_data: UserCreate, + db: Session = Depends(get_db) +): + """Registrar nuevo usuario (solo para setup inicial).""" + # Verificar si ya existe + existing = db.query(User).filter( + (User.username == user_data.username) | (User.email == user_data.email) + ).first() + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Usuario o email ya existe" + ) + + # Crear usuario + user = User( + username=user_data.username, + email=user_data.email, + hashed_password=get_password_hash(user_data.password), + full_name=user_data.full_name, + is_active=True + ) + + db.add(user) + db.commit() + db.refresh(user) + + return {"message": "Usuario creado", "user": user.to_dict()} + + +@router.get("/api/auth/me") +async def api_get_current_user( + request: Request, + db: Session = Depends(get_db) +): + """Obtener información del usuario actual.""" + user = get_current_user_from_cookie(request, db) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No autenticado" + ) + + return user.to_dict() diff --git a/app/api/routes/dashboard.py b/app/api/routes/dashboard.py index 94b3c77..274d95f 100644 --- a/app/api/routes/dashboard.py +++ b/app/api/routes/dashboard.py @@ -3,8 +3,9 @@ API Routes para el Dashboard. """ from datetime import datetime, timedelta +from typing import Optional from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from sqlalchemy import func @@ -12,15 +13,26 @@ from sqlalchemy import func from app.core.database import get_db from app.models.post import Post from app.models.interaction import Interaction +from app.models.user import User +from app.api.routes.auth import get_current_user_from_cookie router = APIRouter() templates = Jinja2Templates(directory="dashboard/templates") +def require_auth(request: Request, db: Session) -> Optional[User]: + """Verificar autenticación. Retorna None si no está autenticado.""" + return get_current_user_from_cookie(request, db) + + @router.get("/", response_class=HTMLResponse) async def dashboard_home(request: Request, db: Session = Depends(get_db)): """Página principal del dashboard.""" + user = require_auth(request, db) + if not user: + return RedirectResponse(url="/login", status_code=302) + # Estadísticas now = datetime.utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -63,6 +75,7 @@ async def dashboard_home(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("index.html", { "request": request, + "user": user.to_dict(), "stats": stats, "pending_posts": [p.to_dict() for p in pending_posts], "scheduled_posts": [p.to_dict() for p in scheduled_posts], @@ -73,46 +86,71 @@ async def dashboard_home(request: Request, db: Session = Depends(get_db)): @router.get("/posts", response_class=HTMLResponse) async def dashboard_posts(request: Request, db: Session = Depends(get_db)): """Página de gestión de posts.""" + user = require_auth(request, db) + if not user: + return RedirectResponse(url="/login", status_code=302) + posts = db.query(Post).order_by(Post.created_at.desc()).limit(50).all() return templates.TemplateResponse("posts.html", { "request": request, + "user": user.to_dict(), "posts": [p.to_dict() for p in posts] }) @router.get("/calendar", response_class=HTMLResponse) -async def dashboard_calendar(request: Request): +async def dashboard_calendar(request: Request, db: Session = Depends(get_db)): """Página de calendario.""" + user = require_auth(request, db) + if not user: + return RedirectResponse(url="/login", status_code=302) + return templates.TemplateResponse("calendar.html", { - "request": request + "request": request, + "user": user.to_dict() }) @router.get("/interactions", response_class=HTMLResponse) async def dashboard_interactions(request: Request, db: Session = Depends(get_db)): """Página de interacciones.""" + user = require_auth(request, db) + if not user: + return RedirectResponse(url="/login", status_code=302) + interactions = db.query(Interaction).filter( Interaction.is_archived == False ).order_by(Interaction.interaction_at.desc()).limit(50).all() return templates.TemplateResponse("interactions.html", { "request": request, + "user": user.to_dict(), "interactions": [i.to_dict() for i in interactions] }) @router.get("/products", response_class=HTMLResponse) -async def dashboard_products(request: Request): +async def dashboard_products(request: Request, db: Session = Depends(get_db)): """Página de productos.""" + user = require_auth(request, db) + if not user: + return RedirectResponse(url="/login", status_code=302) + return templates.TemplateResponse("products.html", { - "request": request + "request": request, + "user": user.to_dict() }) @router.get("/services", response_class=HTMLResponse) -async def dashboard_services(request: Request): +async def dashboard_services(request: Request, db: Session = Depends(get_db)): """Página de servicios.""" + user = require_auth(request, db) + if not user: + return RedirectResponse(url="/login", status_code=302) + return templates.TemplateResponse("services.html", { - "request": request + "request": request, + "user": user.to_dict() }) diff --git a/app/main.py b/app/main.py index 66262cc..9958f32 100644 --- a/app/main.py +++ b/app/main.py @@ -5,17 +5,27 @@ Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook). """ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import posts, products, services, calendar, dashboard, interactions +from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth from app.core.config import settings from app.core.database import engine from app.models import Base -# Crear tablas en la base de datos -Base.metadata.create_all(bind=engine) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifecycle manager - crea tablas al iniciar si hay conexión a BD.""" + try: + Base.metadata.create_all(bind=engine) + except Exception as e: + print(f"⚠️ No se pudo conectar a BD: {e}") + print(" La app funcionará pero sin persistencia hasta configurar BD") + yield # Inicializar aplicación FastAPI app = FastAPI( @@ -24,6 +34,7 @@ app = FastAPI( version="1.0.0", docs_url="/api/docs", redoc_url="/api/redoc", + lifespan=lifespan, ) # Configurar CORS @@ -39,6 +50,7 @@ app.add_middleware( app.mount("/static", StaticFiles(directory="dashboard/static"), name="static") # Registrar rutas +app.include_router(auth.router, prefix="", tags=["Auth"]) app.include_router(dashboard.router, prefix="", tags=["Dashboard"]) app.include_router(posts.router, prefix="/api/posts", tags=["Posts"]) app.include_router(products.router, prefix="/api/products", tags=["Products"]) diff --git a/app/models/__init__.py b/app/models/__init__.py index 3cae731..090cae0 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,6 +4,7 @@ Modelos de base de datos SQLAlchemy. from app.core.database import Base +from app.models.user import User from app.models.product import Product from app.models.service import Service from app.models.tip_template import TipTemplate @@ -14,6 +15,7 @@ from app.models.interaction import Interaction __all__ = [ "Base", + "User", "Product", "Service", "TipTemplate", diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..6f89970 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,49 @@ +""" +Modelo de Usuario para autenticación del dashboard. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Boolean, DateTime + +from app.core.database import Base + + +class User(Base): + """Modelo para usuarios del dashboard.""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + + # Credenciales + username = Column(String(50), unique=True, nullable=False, index=True) + email = Column(String(255), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + # Información + full_name = Column(String(255), nullable=True) + + # Estado + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + + # Timestamps + last_login = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario (sin password).""" + return { + "id": self.id, + "username": self.username, + "email": self.email, + "full_name": self.full_name, + "is_active": self.is_active, + "is_superuser": self.is_superuser, + "last_login": self.last_login.isoformat() if self.last_login else None, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/services/content_generator.py b/app/services/content_generator.py index a6dc5dd..03f9c67 100644 --- a/app/services/content_generator.py +++ b/app/services/content_generator.py @@ -13,12 +13,21 @@ class ContentGenerator: """Generador de contenido usando DeepSeek API.""" def __init__(self): - self.client = OpenAI( - api_key=settings.DEEPSEEK_API_KEY, - base_url=settings.DEEPSEEK_BASE_URL - ) + self._client = None self.model = "deepseek-chat" + @property + def client(self): + """Lazy initialization del cliente OpenAI.""" + if self._client is None: + if not settings.DEEPSEEK_API_KEY: + raise ValueError("DEEPSEEK_API_KEY no configurada. Configura la variable de entorno.") + self._client = OpenAI( + api_key=settings.DEEPSEEK_API_KEY, + base_url=settings.DEEPSEEK_BASE_URL + ) + return self._client + def _get_system_prompt(self) -> str: """Obtener el prompt del sistema con la personalidad de la marca.""" return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}. diff --git a/app/services/image_generator.py b/app/services/image_generator.py index e92676b..5c9da8f 100644 --- a/app/services/image_generator.py +++ b/app/services/image_generator.py @@ -24,10 +24,22 @@ class ImageGenerator: loader=FileSystemLoader(self.templates_dir) ) - self.hti = Html2Image( - output_path=str(self.output_dir), - custom_flags=['--no-sandbox', '--disable-gpu'] - ) + self._hti = None + + @property + def hti(self): + """Lazy initialization de Html2Image.""" + if self._hti is None: + try: + self._hti = Html2Image( + output_path=str(self.output_dir), + custom_flags=['--no-sandbox', '--disable-gpu'] + ) + except FileNotFoundError: + raise RuntimeError( + "Chrome/Chromium no encontrado. Instala Chrome o ejecuta en Docker." + ) + return self._hti def _render_template(self, template_name: str, variables: Dict) -> str: """Renderizar una plantilla HTML con variables.""" diff --git a/dashboard/templates/login.html b/dashboard/templates/login.html new file mode 100644 index 0000000..f1bb7d4 --- /dev/null +++ b/dashboard/templates/login.html @@ -0,0 +1,111 @@ + + + + + + Login - Social Media Automation + + + + +
+ +
+
+ AS +
+

Social Media Automation

+

Consultoría AS

+
+ + + {% if error %} +
+ {{ error }} +
+ {% endif %} + + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+

Sistema de automatización de redes sociales

+

+ + consultoria-as.com + +

+
+
+ + diff --git a/docs/API_KEYS_SETUP.md b/docs/API_KEYS_SETUP.md new file mode 100644 index 0000000..0ed1992 --- /dev/null +++ b/docs/API_KEYS_SETUP.md @@ -0,0 +1,309 @@ +# Guía de Configuración de API Keys + +Esta guía detalla el proceso para obtener las credenciales necesarias para cada plataforma. + +## Tabla de Contenidos + +1. [X (Twitter) Developer](#x-twitter-developer) +2. [Meta Developer (Facebook, Instagram, Threads)](#meta-developer) +3. [DeepSeek API](#deepseek-api) +4. [Configuración en el Sistema](#configuración-en-el-sistema) + +--- + +## X (Twitter) Developer + +### Paso 1: Crear cuenta de desarrollador + +1. Visita [developer.twitter.com](https://developer.twitter.com/) +2. Inicia sesión con tu cuenta de X/Twitter +3. Haz clic en "Sign up for Free Account" o "Apply for access" + +### Paso 2: Crear un Proyecto y App + +1. En el Dashboard, ve a **Projects & Apps** > **Overview** +2. Crea un nuevo proyecto: + - Nombre: `Consultoria-AS-Social-Automation` + - Caso de uso: `Making a bot` o `Building tools for Twitter users` +3. Dentro del proyecto, crea una App: + - Nombre: `social-media-automation` + +### Paso 3: Configurar permisos + +1. En la configuración de la App, ve a **User authentication settings** +2. Configura: + - **App permissions**: Read and Write + - **Type of App**: Web App, Automated App or Bot + - **Callback URL**: `https://tu-dominio.com/callback` (o `http://localhost:8000/callback` para desarrollo) + - **Website URL**: `https://consultoria-as.com` + +### Paso 4: Obtener credenciales + +En la sección **Keys and Tokens**, genera y guarda: + +| Credencial | Variable de Entorno | +|------------|---------------------| +| API Key | `TWITTER_API_KEY` | +| API Key Secret | `TWITTER_API_SECRET` | +| Access Token | `TWITTER_ACCESS_TOKEN` | +| Access Token Secret | `TWITTER_ACCESS_TOKEN_SECRET` | +| Bearer Token | `TWITTER_BEARER_TOKEN` | + +> **Importante**: El plan gratuito permite 1,500 tweets/mes. Para más volumen, considera el plan Basic ($100/mes) con 3,000 tweets/mes. + +--- + +## Meta Developer + +Meta maneja Facebook, Instagram y Threads desde el mismo portal de desarrolladores. + +### Paso 1: Crear cuenta de desarrollador + +1. Visita [developers.facebook.com](https://developers.facebook.com/) +2. Inicia sesión con tu cuenta de Facebook +3. Acepta los términos de desarrollador + +### Paso 2: Crear una App + +1. Ve a **My Apps** > **Create App** +2. Selecciona tipo de app: **Business** +3. Configura: + - Nombre: `Consultoria-AS-Social-Automation` + - Contacto: tu email + - Business Account: selecciona o crea una + +### Paso 3: Agregar productos + +En el dashboard de tu app, agrega estos productos: + +#### Para Facebook Pages: +1. Añade el producto **Facebook Login for Business** +2. Añade **Pages API** +3. Configura los permisos: + - `pages_manage_posts` + - `pages_read_engagement` + - `pages_show_list` + +#### Para Instagram: +1. Añade el producto **Instagram Graph API** +2. Permisos necesarios: + - `instagram_basic` + - `instagram_content_publish` + - `instagram_manage_comments` + - `instagram_manage_insights` + +#### Para Threads: +1. Añade el producto **Threads API** +2. Permisos necesarios: + - `threads_basic` + - `threads_content_publish` + - `threads_manage_replies` + +### Paso 4: Conectar cuentas + +1. Ve a **Settings** > **Basic** para obtener: + - App ID → `META_APP_ID` + - App Secret → `META_APP_SECRET` + +2. Para Facebook Page: + - Ve a **Tools** > **Graph API Explorer** + - Selecciona tu app y página + - Genera un Page Access Token con los permisos necesarios + - Convierte a Long-Lived Token (60 días) + +3. Para Instagram: + - La cuenta de Instagram debe estar conectada a una Facebook Page + - Ve a Graph API Explorer + - Obtén el Instagram Business Account ID + - Genera Access Token con permisos de Instagram + +### Paso 5: Obtener tokens de larga duración + +```bash +# Convertir token de corta a larga duración (60 días) +curl -X GET "https://graph.facebook.com/v18.0/oauth/access_token?grant_type=fb_exchange_token&client_id={APP_ID}&client_secret={APP_SECRET}&fb_exchange_token={SHORT_LIVED_TOKEN}" +``` + +| Credencial | Variable de Entorno | +|------------|---------------------| +| App ID | `META_APP_ID` | +| App Secret | `META_APP_SECRET` | +| Page Access Token | `FACEBOOK_PAGE_ACCESS_TOKEN` | +| Page ID | `FACEBOOK_PAGE_ID` | +| Instagram User ID | `INSTAGRAM_USER_ID` | +| Threads User ID | `THREADS_USER_ID` | + +> **Nota**: Los tokens de Meta expiran. Implementa un sistema de refresh o regenera manualmente cada 60 días. + +--- + +## DeepSeek API + +### Paso 1: Crear cuenta + +1. Visita [platform.deepseek.com](https://platform.deepseek.com/) +2. Regístrate con email o Google +3. Verifica tu cuenta + +### Paso 2: Obtener API Key + +1. Ve a **API Keys** en el dashboard +2. Crea una nueva API Key +3. Copia y guarda la key (solo se muestra una vez) + +### Paso 3: Agregar créditos + +1. Ve a **Billing** > **Top Up** +2. Agrega créditos (mínimo $5 USD recomendado para empezar) +3. Los precios son muy económicos (~$0.14/1M tokens input, ~$0.28/1M tokens output) + +| Credencial | Variable de Entorno | +|------------|---------------------| +| API Key | `DEEPSEEK_API_KEY` | +| Base URL | `https://api.deepseek.com` (ya configurado por defecto) | + +--- + +## Configuración en el Sistema + +### Archivo `.env` + +Crea el archivo `.env` en la raíz del proyecto: + +```env +# ============================================== +# BASE DE DATOS +# ============================================== +DATABASE_URL=postgresql://postgres:tu_password@localhost:5432/social_automation +REDIS_URL=redis://localhost:6379/0 + +# ============================================== +# SEGURIDAD +# ============================================== +SECRET_KEY=genera-una-clave-segura-de-32-caracteres-minimo +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# ============================================== +# X (TWITTER) API +# ============================================== +TWITTER_API_KEY=tu_api_key +TWITTER_API_SECRET=tu_api_secret +TWITTER_ACCESS_TOKEN=tu_access_token +TWITTER_ACCESS_TOKEN_SECRET=tu_access_token_secret +TWITTER_BEARER_TOKEN=tu_bearer_token + +# ============================================== +# META APIs (Facebook, Instagram, Threads) +# ============================================== +META_APP_ID=tu_app_id +META_APP_SECRET=tu_app_secret + +# Facebook +FACEBOOK_PAGE_ACCESS_TOKEN=tu_page_access_token +FACEBOOK_PAGE_ID=tu_page_id + +# Instagram +INSTAGRAM_USER_ID=tu_instagram_user_id +INSTAGRAM_ACCESS_TOKEN=tu_instagram_access_token + +# Threads +THREADS_USER_ID=tu_threads_user_id +THREADS_ACCESS_TOKEN=tu_threads_access_token + +# ============================================== +# DEEPSEEK API (Generación de contenido) +# ============================================== +DEEPSEEK_API_KEY=tu_deepseek_api_key +DEEPSEEK_BASE_URL=https://api.deepseek.com + +# ============================================== +# CONFIGURACIÓN DE NEGOCIO +# ============================================== +BUSINESS_NAME=Consultoría AS +BUSINESS_DESCRIPTION=Consultoría tecnológica y venta de equipos de cómputo e impresión 3D +BUSINESS_WEBSITE=https://consultoria-as.com +BUSINESS_LOCATION=Tijuana, México +``` + +### Generar SECRET_KEY + +```bash +# Usando Python +python -c "import secrets; print(secrets.token_urlsafe(32))" + +# Usando OpenSSL +openssl rand -base64 32 +``` + +### Verificar configuración + +Después de configurar el `.env`, verifica que todo esté correcto: + +```bash +# Activar entorno virtual +source venv/bin/activate + +# Verificar que las variables se cargan +python -c "from app.core.config import settings; print('DB:', settings.DATABASE_URL[:20] + '...'); print('DeepSeek:', 'OK' if settings.DEEPSEEK_API_KEY else 'NO CONFIGURADO')" +``` + +--- + +## Límites de API y Costos Estimados + +### X (Twitter) + +| Plan | Tweets/mes | Costo | +|------|-----------|-------| +| Free | 1,500 | $0 | +| Basic | 3,000 | $100/mes | +| Pro | 300,000 | $5,000/mes | + +### Meta (Facebook/Instagram/Threads) + +- Sin límite de publicaciones para cuentas propias +- Rate limits: ~200 llamadas/hora por usuario +- Costo: Gratuito + +### DeepSeek + +| Modelo | Input | Output | +|--------|-------|--------| +| deepseek-chat | $0.14/1M tokens | $0.28/1M tokens | +| deepseek-coder | $0.14/1M tokens | $0.28/1M tokens | + +**Estimación mensual**: ~$5-10 USD para 300 posts/mes + +--- + +## Solución de Problemas + +### Error: "Invalid API Key" en X + +1. Verifica que copiaste la key completa +2. Regenera las credenciales si es necesario +3. Asegúrate de que la app tiene permisos de escritura + +### Error: "Token expired" en Meta + +1. Los tokens de página expiran cada 60 días +2. Usa el Graph API Explorer para generar uno nuevo +3. Considera implementar refresh automático + +### Error: "Insufficient balance" en DeepSeek + +1. Agrega más créditos en el dashboard +2. Verifica el consumo en la sección de billing + +--- + +## Próximos Pasos + +1. Configura las credenciales en `.env` +2. Ejecuta `docker-compose up -d` para iniciar servicios +3. Crea el usuario admin: `python scripts/create_admin.py` +4. Accede al dashboard: `http://localhost:8000/dashboard` +5. Configura el calendario de contenido + +Para más información, consulta la [documentación principal](./README.md). diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000..3ca69a7 --- /dev/null +++ b/scripts/create_admin.py @@ -0,0 +1,90 @@ +""" +Script para crear el usuario administrador inicial. +""" + +import sys +from pathlib import Path + +# Agregar el directorio raíz al path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.core.database import SessionLocal +from app.core.security import get_password_hash +from app.models.user import User + + +def create_admin(username: str, email: str, password: str, full_name: str = None): + """Crear usuario administrador.""" + db = SessionLocal() + + try: + # Verificar si ya existe + existing = db.query(User).filter( + (User.username == username) | (User.email == email) + ).first() + + if existing: + print(f"⚠️ Usuario '{username}' o email '{email}' ya existe") + return False + + # Crear usuario + user = User( + username=username, + email=email, + hashed_password=get_password_hash(password), + full_name=full_name or username, + is_active=True, + is_superuser=True + ) + + db.add(user) + db.commit() + + print(f"✅ Usuario administrador creado: {username}") + print(f" Email: {email}") + print(f" Password: {'*' * len(password)}") + return True + + except Exception as e: + print(f"❌ Error: {e}") + db.rollback() + return False + + finally: + db.close() + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Crear usuario administrador") + parser.add_argument("--username", "-u", default="admin", help="Nombre de usuario") + parser.add_argument("--email", "-e", required=True, help="Email del usuario") + parser.add_argument("--password", "-p", required=True, help="Contraseña") + parser.add_argument("--name", "-n", help="Nombre completo") + + args = parser.parse_args() + + create_admin( + username=args.username, + email=args.email, + password=args.password, + full_name=args.name + ) + + +if __name__ == "__main__": + # Si no hay argumentos, usar valores por defecto para desarrollo + if len(sys.argv) == 1: + print("Uso: python create_admin.py -e email@example.com -p password") + print() + print("Creando usuario admin por defecto para desarrollo...") + create_admin( + username="admin", + email="admin@consultoria-as.com", + password="admin123", + full_name="Administrador" + ) + else: + main()