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 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 01:30:15 +00:00
parent 049d2133f9
commit 541a8484a7
14 changed files with 1092 additions and 18 deletions

93
alembic.ini Normal file
View File

@@ -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

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with async-ready approach.

86
alembic/env.py Normal file
View File

@@ -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()

26
alembic/script.py.mako Normal file
View File

@@ -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"}

236
app/api/routes/auth.py Normal file
View File

@@ -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()

View File

@@ -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()
})

View File

@@ -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
@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"])

View File

@@ -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",

49
app/models/user.py Normal file
View File

@@ -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"<User {self.username}>"
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
}

View File

@@ -13,11 +13,20 @@ class ContentGenerator:
"""Generador de contenido usando DeepSeek API."""
def __init__(self):
self.client = OpenAI(
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
)
self.model = "deepseek-chat"
return self._client
def _get_system_prompt(self) -> str:
"""Obtener el prompt del sistema con la personalidad de la marca."""

View File

@@ -24,10 +24,22 @@ class ImageGenerator:
loader=FileSystemLoader(self.templates_dir)
)
self.hti = Html2Image(
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."""

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Social Media Automation</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
}
.card {
background-color: #16213e;
border-radius: 12px;
}
.accent { color: #d4a574; }
.btn-primary {
background-color: #d4a574;
color: #1a1a2e;
transition: all 0.3s;
}
.btn-primary:hover {
background-color: #c49564;
transform: translateY(-2px);
}
.input-field {
background-color: #1a1a2e;
border: 1px solid #2d3748;
color: #fff;
transition: border-color 0.3s;
}
.input-field:focus {
border-color: #d4a574;
outline: none;
}
.input-field::placeholder {
color: #6b7280;
}
</style>
</head>
<body class="flex items-center justify-center p-4">
<div class="card p-8 w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-xl bg-gradient-to-br from-yellow-600 to-yellow-800 mb-4">
<span class="text-2xl font-bold text-white">AS</span>
</div>
<h1 class="text-2xl font-bold text-white">Social Media Automation</h1>
<p class="text-gray-400 mt-1">Consultoría AS</p>
</div>
<!-- Error message -->
{% if error %}
<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
<!-- Login Form -->
<form method="POST" action="/login">
<div class="mb-4">
<label for="username" class="block text-gray-400 text-sm mb-2">
Usuario o Email
</label>
<input
type="text"
id="username"
name="username"
required
class="input-field w-full px-4 py-3 rounded-lg"
placeholder="tu.usuario"
autocomplete="username"
>
</div>
<div class="mb-6">
<label for="password" class="block text-gray-400 text-sm mb-2">
Contraseña
</label>
<input
type="password"
id="password"
name="password"
required
class="input-field w-full px-4 py-3 rounded-lg"
placeholder="••••••••"
autocomplete="current-password"
>
</div>
<button
type="submit"
class="btn-primary w-full py-3 rounded-lg font-semibold text-lg"
>
Iniciar Sesión
</button>
</form>
<!-- Footer -->
<div class="mt-8 text-center text-gray-500 text-sm">
<p>Sistema de automatización de redes sociales</p>
<p class="mt-1">
<a href="https://consultoria-as.com" target="_blank" class="accent hover:underline">
consultoria-as.com
</a>
</p>
</div>
</div>
</body>
</html>

309
docs/API_KEYS_SETUP.md Normal file
View File

@@ -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).

90
scripts/create_admin.py Normal file
View File

@@ -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()