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:
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 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()
|
||||
})
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
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"])
|
||||
|
||||
@@ -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
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."""
|
||||
|
||||
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}.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user