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:
93
alembic.ini
Normal file
93
alembic.ini
Normal 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
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration with async-ready approach.
|
||||||
86
alembic/env.py
Normal file
86
alembic/env.py
Normal 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
26
alembic/script.py.mako
Normal 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
236
app/api/routes/auth.py
Normal 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()
|
||||||
@@ -3,8 +3,9 @@ API Routes para el Dashboard.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -12,15 +13,26 @@ from sqlalchemy import func
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.post import Post
|
from app.models.post import Post
|
||||||
from app.models.interaction import Interaction
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="dashboard/templates")
|
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)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard_home(request: Request, db: Session = Depends(get_db)):
|
async def dashboard_home(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Página principal del dashboard."""
|
"""Página principal del dashboard."""
|
||||||
|
user = require_auth(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
# Estadísticas
|
# Estadísticas
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
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", {
|
return templates.TemplateResponse("index.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
"user": user.to_dict(),
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"pending_posts": [p.to_dict() for p in pending_posts],
|
"pending_posts": [p.to_dict() for p in pending_posts],
|
||||||
"scheduled_posts": [p.to_dict() for p in scheduled_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)
|
@router.get("/posts", response_class=HTMLResponse)
|
||||||
async def dashboard_posts(request: Request, db: Session = Depends(get_db)):
|
async def dashboard_posts(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Página de gestión de posts."""
|
"""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()
|
posts = db.query(Post).order_by(Post.created_at.desc()).limit(50).all()
|
||||||
|
|
||||||
return templates.TemplateResponse("posts.html", {
|
return templates.TemplateResponse("posts.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
"user": user.to_dict(),
|
||||||
"posts": [p.to_dict() for p in posts]
|
"posts": [p.to_dict() for p in posts]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendar", response_class=HTMLResponse)
|
@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."""
|
"""Página de calendario."""
|
||||||
|
user = require_auth(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
return templates.TemplateResponse("calendar.html", {
|
return templates.TemplateResponse("calendar.html", {
|
||||||
"request": request
|
"request": request,
|
||||||
|
"user": user.to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/interactions", response_class=HTMLResponse)
|
@router.get("/interactions", response_class=HTMLResponse)
|
||||||
async def dashboard_interactions(request: Request, db: Session = Depends(get_db)):
|
async def dashboard_interactions(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Página de interacciones."""
|
"""Página de interacciones."""
|
||||||
|
user = require_auth(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
interactions = db.query(Interaction).filter(
|
interactions = db.query(Interaction).filter(
|
||||||
Interaction.is_archived == False
|
Interaction.is_archived == False
|
||||||
).order_by(Interaction.interaction_at.desc()).limit(50).all()
|
).order_by(Interaction.interaction_at.desc()).limit(50).all()
|
||||||
|
|
||||||
return templates.TemplateResponse("interactions.html", {
|
return templates.TemplateResponse("interactions.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
"user": user.to_dict(),
|
||||||
"interactions": [i.to_dict() for i in interactions]
|
"interactions": [i.to_dict() for i in interactions]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products", response_class=HTMLResponse)
|
@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."""
|
"""Página de productos."""
|
||||||
|
user = require_auth(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
return templates.TemplateResponse("products.html", {
|
return templates.TemplateResponse("products.html", {
|
||||||
"request": request
|
"request": request,
|
||||||
|
"user": user.to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services", response_class=HTMLResponse)
|
@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."""
|
"""Página de servicios."""
|
||||||
|
user = require_auth(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
return templates.TemplateResponse("services.html", {
|
return templates.TemplateResponse("services.html", {
|
||||||
"request": request
|
"request": request,
|
||||||
|
"user": user.to_dict()
|
||||||
})
|
})
|
||||||
|
|||||||
18
app/main.py
18
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).
|
en redes sociales (X, Threads, Instagram, Facebook).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.config import settings
|
||||||
from app.core.database import engine
|
from app.core.database import engine
|
||||||
from app.models import Base
|
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
|
# Inicializar aplicación FastAPI
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -24,6 +34,7 @@ app = FastAPI(
|
|||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configurar CORS
|
# Configurar CORS
|
||||||
@@ -39,6 +50,7 @@ app.add_middleware(
|
|||||||
app.mount("/static", StaticFiles(directory="dashboard/static"), name="static")
|
app.mount("/static", StaticFiles(directory="dashboard/static"), name="static")
|
||||||
|
|
||||||
# Registrar rutas
|
# Registrar rutas
|
||||||
|
app.include_router(auth.router, prefix="", tags=["Auth"])
|
||||||
app.include_router(dashboard.router, prefix="", tags=["Dashboard"])
|
app.include_router(dashboard.router, prefix="", tags=["Dashboard"])
|
||||||
app.include_router(posts.router, prefix="/api/posts", tags=["Posts"])
|
app.include_router(posts.router, prefix="/api/posts", tags=["Posts"])
|
||||||
app.include_router(products.router, prefix="/api/products", tags=["Products"])
|
app.include_router(products.router, prefix="/api/products", tags=["Products"])
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Modelos de base de datos SQLAlchemy.
|
|||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
from app.models.service import Service
|
from app.models.service import Service
|
||||||
from app.models.tip_template import TipTemplate
|
from app.models.tip_template import TipTemplate
|
||||||
@@ -14,6 +15,7 @@ from app.models.interaction import Interaction
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
"User",
|
||||||
"Product",
|
"Product",
|
||||||
"Service",
|
"Service",
|
||||||
"TipTemplate",
|
"TipTemplate",
|
||||||
|
|||||||
49
app/models/user.py
Normal file
49
app/models/user.py
Normal 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
|
||||||
|
}
|
||||||
@@ -13,12 +13,21 @@ class ContentGenerator:
|
|||||||
"""Generador de contenido usando DeepSeek API."""
|
"""Generador de contenido usando DeepSeek API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client = OpenAI(
|
self._client = None
|
||||||
api_key=settings.DEEPSEEK_API_KEY,
|
|
||||||
base_url=settings.DEEPSEEK_BASE_URL
|
|
||||||
)
|
|
||||||
self.model = "deepseek-chat"
|
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:
|
def _get_system_prompt(self) -> str:
|
||||||
"""Obtener el prompt del sistema con la personalidad de la marca."""
|
"""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}.
|
return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}.
|
||||||
|
|||||||
@@ -24,10 +24,22 @@ class ImageGenerator:
|
|||||||
loader=FileSystemLoader(self.templates_dir)
|
loader=FileSystemLoader(self.templates_dir)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.hti = Html2Image(
|
self._hti = None
|
||||||
output_path=str(self.output_dir),
|
|
||||||
custom_flags=['--no-sandbox', '--disable-gpu']
|
@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:
|
def _render_template(self, template_name: str, variables: Dict) -> str:
|
||||||
"""Renderizar una plantilla HTML con variables."""
|
"""Renderizar una plantilla HTML con variables."""
|
||||||
|
|||||||
111
dashboard/templates/login.html
Normal file
111
dashboard/templates/login.html
Normal 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
309
docs/API_KEYS_SETUP.md
Normal 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
90
scripts/create_admin.py
Normal 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()
|
||||||
Reference in New Issue
Block a user