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

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

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,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}.

View File

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