Implementación inicial del sistema de automatización de redes sociales
- Estructura completa del proyecto con FastAPI - Modelos de base de datos (productos, servicios, posts, calendario, interacciones) - Publishers para X, Threads, Instagram, Facebook - Generador de contenido con DeepSeek API - Worker de Celery con tareas programadas - Dashboard básico con templates HTML - Docker Compose para despliegue - Documentación completa Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:consultoria-as.com)",
|
||||
"WebFetch(domain:x.com)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nImplementación inicial del sistema de automatización de redes sociales\n\n- Estructura completa del proyecto con FastAPI\n- Modelos de base de datos \\(productos, servicios, posts, calendario, interacciones\\)\n- Publishers para X, Threads, Instagram, Facebook\n- Generador de contenido con DeepSeek API\n- Worker de Celery con tareas programadas\n- Dashboard básico con templates HTML\n- Docker Compose para despliegue\n- Documentación completa\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git config:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
74
.env.example
Normal file
74
.env.example
Normal file
@@ -0,0 +1,74 @@
|
||||
# ===========================================
|
||||
# CONFIGURACIÓN DEL SISTEMA
|
||||
# ===========================================
|
||||
|
||||
# Aplicación
|
||||
APP_NAME=social-media-automation
|
||||
APP_ENV=development
|
||||
DEBUG=true
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
||||
# ===========================================
|
||||
# BASE DE DATOS
|
||||
# ===========================================
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/social_automation
|
||||
|
||||
# ===========================================
|
||||
# REDIS
|
||||
# ===========================================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ===========================================
|
||||
# DEEPSEEK API (Generación de contenido)
|
||||
# ===========================================
|
||||
DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# ===========================================
|
||||
# X (TWITTER) API
|
||||
# ===========================================
|
||||
X_API_KEY=your-x-api-key
|
||||
X_API_SECRET=your-x-api-secret
|
||||
X_ACCESS_TOKEN=your-x-access-token
|
||||
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret
|
||||
X_BEARER_TOKEN=your-x-bearer-token
|
||||
|
||||
# ===========================================
|
||||
# META API (Facebook, Instagram, Threads)
|
||||
# ===========================================
|
||||
META_APP_ID=your-meta-app-id
|
||||
META_APP_SECRET=your-meta-app-secret
|
||||
META_ACCESS_TOKEN=your-meta-access-token
|
||||
|
||||
# Facebook
|
||||
FACEBOOK_PAGE_ID=your-facebook-page-id
|
||||
|
||||
# Instagram
|
||||
INSTAGRAM_ACCOUNT_ID=your-instagram-account-id
|
||||
|
||||
# Threads
|
||||
THREADS_USER_ID=your-threads-user-id
|
||||
|
||||
# ===========================================
|
||||
# CONFIGURACIÓN DE CONTENIDO
|
||||
# ===========================================
|
||||
|
||||
# Información del negocio
|
||||
BUSINESS_NAME=Consultoría AS
|
||||
BUSINESS_LOCATION=Tijuana, México
|
||||
BUSINESS_WEBSITE=https://consultoria-as.com
|
||||
|
||||
# Tono de comunicación
|
||||
CONTENT_TONE=profesional pero cercano, educativo, orientado a soluciones
|
||||
|
||||
# ===========================================
|
||||
# NOTIFICACIONES (Opcional)
|
||||
# ===========================================
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
||||
|
||||
# Email
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASSWORD=your-email-password
|
||||
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Generated images
|
||||
templates/*.png
|
||||
|
||||
# Celery
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
|
||||
# pytest
|
||||
.pytest_cache/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# Docker volumes
|
||||
postgres_data/
|
||||
redis_data/
|
||||
|
||||
# SSL certificates
|
||||
nginx/ssl/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Secrets (nunca subir)
|
||||
*secret*
|
||||
*credential*
|
||||
*.pem
|
||||
*.key
|
||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Establecer directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Variables de entorno
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copiar requirements e instalar dependencias Python
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar código de la aplicación
|
||||
COPY . .
|
||||
|
||||
# Crear directorio para uploads
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 8000
|
||||
|
||||
# Comando por defecto
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
160
README.md
Normal file
160
README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Social Media Automation - Consultoría AS
|
||||
|
||||
Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook).
|
||||
|
||||
## Características
|
||||
|
||||
- **Generación de contenido con IA** (DeepSeek API)
|
||||
- **Publicación automática** en múltiples plataformas
|
||||
- **Calendario de contenido** configurable
|
||||
- **Dashboard de gestión** para aprobar y revisar posts
|
||||
- **Gestión de interacciones** (comentarios, menciones, DMs)
|
||||
- **Plantillas de imágenes** personalizadas
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
- **Backend**: Python + FastAPI
|
||||
- **Base de datos**: PostgreSQL
|
||||
- **Cola de tareas**: Celery + Redis
|
||||
- **IA**: DeepSeek API
|
||||
- **Contenedores**: Docker Compose
|
||||
|
||||
## Instalación
|
||||
|
||||
### Requisitos Previos
|
||||
|
||||
- Docker y Docker Compose
|
||||
- Cuentas de desarrollador en:
|
||||
- [X Developer](https://developer.twitter.com)
|
||||
- [Meta Developer](https://developers.facebook.com)
|
||||
- [DeepSeek](https://platform.deepseek.com)
|
||||
|
||||
### Pasos
|
||||
|
||||
1. **Clonar el repositorio**
|
||||
```bash
|
||||
git clone https://git.consultoria-as.com/consultoria-as/social-media-automation.git
|
||||
cd social-media-automation
|
||||
```
|
||||
|
||||
2. **Configurar variables de entorno**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Editar .env con tus credenciales
|
||||
```
|
||||
|
||||
3. **Iniciar servicios**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Poblar base de datos**
|
||||
```bash
|
||||
docker-compose exec app python scripts/seed_database.py
|
||||
```
|
||||
|
||||
5. **Acceder al dashboard**
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
├── app/ # Aplicación principal
|
||||
│ ├── api/ # Endpoints de la API
|
||||
│ ├── core/ # Configuración
|
||||
│ ├── models/ # Modelos SQLAlchemy
|
||||
│ ├── publishers/ # Publishers por plataforma
|
||||
│ ├── services/ # Lógica de negocio
|
||||
│ └── prompts/ # Prompts para IA
|
||||
├── worker/ # Celery workers
|
||||
│ └── tasks/ # Tareas programadas
|
||||
├── dashboard/ # Frontend del dashboard
|
||||
├── templates/ # Plantillas de imágenes
|
||||
├── data/ # Datos iniciales
|
||||
├── scripts/ # Scripts de utilidad
|
||||
└── docker-compose.yml # Configuración Docker
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
### Dashboard
|
||||
|
||||
- **Home**: Resumen y estadísticas
|
||||
- **Posts**: Gestionar publicaciones
|
||||
- **Calendario**: Configurar horarios
|
||||
- **Interacciones**: Responder comentarios
|
||||
- **Productos**: Gestionar catálogo
|
||||
|
||||
### API
|
||||
|
||||
Documentación disponible en:
|
||||
- Swagger: `http://localhost:8000/api/docs`
|
||||
- ReDoc: `http://localhost:8000/api/redoc`
|
||||
|
||||
### Endpoints Principales
|
||||
|
||||
```
|
||||
GET /api/posts # Listar posts
|
||||
POST /api/posts # Crear post
|
||||
GET /api/posts/pending # Posts pendientes
|
||||
POST /api/posts/{id}/approve # Aprobar post
|
||||
|
||||
GET /api/products # Listar productos
|
||||
POST /api/products # Crear producto
|
||||
|
||||
GET /api/calendar # Ver calendario
|
||||
POST /api/calendar # Crear entrada
|
||||
|
||||
GET /api/interactions # Ver interacciones
|
||||
POST /api/interactions/{id}/respond # Responder
|
||||
```
|
||||
|
||||
## Configuración del Calendario
|
||||
|
||||
El calendario determina qué tipo de contenido se publica y cuándo:
|
||||
|
||||
| Día | Hora | Tipo | Plataformas |
|
||||
|-----|------|------|-------------|
|
||||
| L-V | 09:00 | Tip Tech | X, Threads, IG |
|
||||
| L-V | 15:00 | Dato Curioso | X, Threads |
|
||||
| L,M,V | 12:00 | Producto | FB, IG |
|
||||
| M,J | 11:00 | Servicio | X, FB, IG |
|
||||
|
||||
## Desarrollo
|
||||
|
||||
### Ejecutar sin Docker
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Variables de entorno
|
||||
export DATABASE_URL=postgresql://...
|
||||
export REDIS_URL=redis://...
|
||||
|
||||
# Iniciar API
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Iniciar Worker
|
||||
celery -A worker.celery_app worker --loglevel=info
|
||||
|
||||
# Iniciar Beat
|
||||
celery -A worker.celery_app beat --loglevel=info
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Licencia
|
||||
|
||||
Propiedad de Consultoría AS - Todos los derechos reservados.
|
||||
|
||||
## Contacto
|
||||
|
||||
- Web: https://consultoria-as.com
|
||||
- Email: contacto@consultoria-as.com
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Social Media Automation - Consultoría AS
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API Routes
|
||||
1
app/api/routes/__init__.py
Normal file
1
app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API Route modules
|
||||
201
app/api/routes/calendar.py
Normal file
201
app/api/routes/calendar.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
API Routes para gestión del Calendario de Contenido.
|
||||
"""
|
||||
|
||||
from datetime import datetime, time
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.content_calendar import ContentCalendar
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# SCHEMAS
|
||||
# ===========================================
|
||||
|
||||
class CalendarEntryCreate(BaseModel):
|
||||
day_of_week: int # 0=Lunes, 6=Domingo
|
||||
time: str # Formato "HH:MM"
|
||||
content_type: str
|
||||
platforms: List[str]
|
||||
requires_approval: bool = False
|
||||
category_filter: Optional[str] = None
|
||||
priority: int = 0
|
||||
description: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CalendarEntryUpdate(BaseModel):
|
||||
day_of_week: Optional[int] = None
|
||||
time: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
platforms: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
requires_approval: Optional[bool] = None
|
||||
category_filter: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===========================================
|
||||
# ENDPOINTS
|
||||
# ===========================================
|
||||
|
||||
@router.get("/")
|
||||
async def list_calendar_entries(
|
||||
day_of_week: Optional[int] = Query(None),
|
||||
content_type: Optional[str] = Query(None),
|
||||
platform: Optional[str] = Query(None),
|
||||
is_active: Optional[bool] = Query(True),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Listar entradas del calendario."""
|
||||
query = db.query(ContentCalendar)
|
||||
|
||||
if day_of_week is not None:
|
||||
query = query.filter(ContentCalendar.day_of_week == day_of_week)
|
||||
if content_type:
|
||||
query = query.filter(ContentCalendar.content_type == content_type)
|
||||
if is_active is not None:
|
||||
query = query.filter(ContentCalendar.is_active == is_active)
|
||||
if platform:
|
||||
query = query.filter(ContentCalendar.platforms.contains([platform]))
|
||||
|
||||
entries = query.order_by(
|
||||
ContentCalendar.day_of_week,
|
||||
ContentCalendar.time
|
||||
).all()
|
||||
|
||||
return [e.to_dict() for e in entries]
|
||||
|
||||
|
||||
@router.get("/week")
|
||||
async def get_week_schedule(db: Session = Depends(get_db)):
|
||||
"""Obtener el calendario semanal completo."""
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.is_active == True
|
||||
).order_by(
|
||||
ContentCalendar.day_of_week,
|
||||
ContentCalendar.time
|
||||
).all()
|
||||
|
||||
# Organizar por día
|
||||
week = {i: [] for i in range(7)}
|
||||
for entry in entries:
|
||||
week[entry.day_of_week].append(entry.to_dict())
|
||||
|
||||
days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
|
||||
return {
|
||||
days[i]: week[i] for i in range(7)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/today")
|
||||
async def get_today_schedule(db: Session = Depends(get_db)):
|
||||
"""Obtener el calendario de hoy."""
|
||||
today = datetime.now().weekday()
|
||||
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.day_of_week == today,
|
||||
ContentCalendar.is_active == True
|
||||
).order_by(ContentCalendar.time).all()
|
||||
|
||||
return [e.to_dict() for e in entries]
|
||||
|
||||
|
||||
@router.get("/{entry_id}")
|
||||
async def get_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||
"""Obtener una entrada del calendario por ID."""
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||
return entry.to_dict()
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_calendar_entry(entry_data: CalendarEntryCreate, db: Session = Depends(get_db)):
|
||||
"""Crear una nueva entrada en el calendario."""
|
||||
# Parsear hora
|
||||
hour, minute = map(int, entry_data.time.split(":"))
|
||||
entry_time = time(hour=hour, minute=minute)
|
||||
|
||||
entry = ContentCalendar(
|
||||
day_of_week=entry_data.day_of_week,
|
||||
time=entry_time,
|
||||
content_type=entry_data.content_type,
|
||||
platforms=entry_data.platforms,
|
||||
requires_approval=entry_data.requires_approval,
|
||||
category_filter=entry_data.category_filter,
|
||||
priority=entry_data.priority,
|
||||
description=entry_data.description
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry.to_dict()
|
||||
|
||||
|
||||
@router.put("/{entry_id}")
|
||||
async def update_calendar_entry(
|
||||
entry_id: int,
|
||||
entry_data: CalendarEntryUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Actualizar una entrada del calendario."""
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||
|
||||
update_data = entry_data.dict(exclude_unset=True)
|
||||
|
||||
# Parsear hora si se proporciona
|
||||
if "time" in update_data and update_data["time"]:
|
||||
hour, minute = map(int, update_data["time"].split(":"))
|
||||
update_data["time"] = time(hour=hour, minute=minute)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(entry, field, value)
|
||||
|
||||
entry.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{entry_id}")
|
||||
async def delete_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||
"""Eliminar una entrada del calendario."""
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return {"message": "Entrada eliminada", "entry_id": entry_id}
|
||||
|
||||
|
||||
@router.post("/{entry_id}/toggle")
|
||||
async def toggle_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||
"""Activar/desactivar una entrada del calendario."""
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||
|
||||
entry.is_active = not entry.is_active
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Entrada {'activada' if entry.is_active else 'desactivada'}",
|
||||
"is_active": entry.is_active
|
||||
}
|
||||
118
app/api/routes/dashboard.py
Normal file
118
app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
API Routes para el Dashboard.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.post import Post
|
||||
from app.models.interaction import Interaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory="dashboard/templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard_home(request: Request, db: Session = Depends(get_db)):
|
||||
"""Página principal del dashboard."""
|
||||
# Estadísticas
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=now.weekday())
|
||||
|
||||
stats = {
|
||||
"posts_today": db.query(Post).filter(
|
||||
Post.published_at >= today_start
|
||||
).count(),
|
||||
"posts_week": db.query(Post).filter(
|
||||
Post.published_at >= week_start
|
||||
).count(),
|
||||
"pending_approval": db.query(Post).filter(
|
||||
Post.status == "pending_approval"
|
||||
).count(),
|
||||
"scheduled": db.query(Post).filter(
|
||||
Post.status == "scheduled"
|
||||
).count(),
|
||||
"interactions_pending": db.query(Interaction).filter(
|
||||
Interaction.responded == False,
|
||||
Interaction.is_archived == False
|
||||
).count()
|
||||
}
|
||||
|
||||
# Posts pendientes
|
||||
pending_posts = db.query(Post).filter(
|
||||
Post.status == "pending_approval"
|
||||
).order_by(Post.scheduled_at.asc()).limit(5).all()
|
||||
|
||||
# Próximas publicaciones
|
||||
scheduled_posts = db.query(Post).filter(
|
||||
Post.status == "scheduled",
|
||||
Post.scheduled_at >= now
|
||||
).order_by(Post.scheduled_at.asc()).limit(5).all()
|
||||
|
||||
# Interacciones recientes
|
||||
recent_interactions = db.query(Interaction).filter(
|
||||
Interaction.responded == False
|
||||
).order_by(Interaction.interaction_at.desc()).limit(5).all()
|
||||
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"pending_posts": [p.to_dict() for p in pending_posts],
|
||||
"scheduled_posts": [p.to_dict() for p in scheduled_posts],
|
||||
"recent_interactions": [i.to_dict() for i in recent_interactions]
|
||||
})
|
||||
|
||||
|
||||
@router.get("/posts", response_class=HTMLResponse)
|
||||
async def dashboard_posts(request: Request, db: Session = Depends(get_db)):
|
||||
"""Página de gestión de posts."""
|
||||
posts = db.query(Post).order_by(Post.created_at.desc()).limit(50).all()
|
||||
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"posts": [p.to_dict() for p in posts]
|
||||
})
|
||||
|
||||
|
||||
@router.get("/calendar", response_class=HTMLResponse)
|
||||
async def dashboard_calendar(request: Request):
|
||||
"""Página de calendario."""
|
||||
return templates.TemplateResponse("calendar.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
|
||||
@router.get("/interactions", response_class=HTMLResponse)
|
||||
async def dashboard_interactions(request: Request, db: Session = Depends(get_db)):
|
||||
"""Página de interacciones."""
|
||||
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,
|
||||
"interactions": [i.to_dict() for i in interactions]
|
||||
})
|
||||
|
||||
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
async def dashboard_products(request: Request):
|
||||
"""Página de productos."""
|
||||
return templates.TemplateResponse("products.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
|
||||
@router.get("/services", response_class=HTMLResponse)
|
||||
async def dashboard_services(request: Request):
|
||||
"""Página de servicios."""
|
||||
return templates.TemplateResponse("services.html", {
|
||||
"request": request
|
||||
})
|
||||
226
app/api/routes/interactions.py
Normal file
226
app/api/routes/interactions.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
API Routes para gestión de Interacciones.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.interaction import Interaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# SCHEMAS
|
||||
# ===========================================
|
||||
|
||||
class InteractionResponse(BaseModel):
|
||||
content: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===========================================
|
||||
# ENDPOINTS
|
||||
# ===========================================
|
||||
|
||||
@router.get("/")
|
||||
async def list_interactions(
|
||||
platform: Optional[str] = Query(None),
|
||||
interaction_type: Optional[str] = Query(None),
|
||||
responded: Optional[bool] = Query(None),
|
||||
is_lead: Optional[bool] = Query(None),
|
||||
is_read: Optional[bool] = Query(None),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Listar interacciones con filtros."""
|
||||
query = db.query(Interaction)
|
||||
|
||||
if platform:
|
||||
query = query.filter(Interaction.platform == platform)
|
||||
if interaction_type:
|
||||
query = query.filter(Interaction.interaction_type == interaction_type)
|
||||
if responded is not None:
|
||||
query = query.filter(Interaction.responded == responded)
|
||||
if is_lead is not None:
|
||||
query = query.filter(Interaction.is_lead == is_lead)
|
||||
if is_read is not None:
|
||||
query = query.filter(Interaction.is_read == is_read)
|
||||
|
||||
interactions = query.order_by(
|
||||
Interaction.interaction_at.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
return [i.to_dict() for i in interactions]
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def list_pending_interactions(db: Session = Depends(get_db)):
|
||||
"""Listar interacciones sin responder."""
|
||||
interactions = db.query(Interaction).filter(
|
||||
Interaction.responded == False,
|
||||
Interaction.is_archived == False
|
||||
).order_by(
|
||||
Interaction.priority.desc(),
|
||||
Interaction.interaction_at.desc()
|
||||
).all()
|
||||
|
||||
return [i.to_dict() for i in interactions]
|
||||
|
||||
|
||||
@router.get("/leads")
|
||||
async def list_leads(db: Session = Depends(get_db)):
|
||||
"""Listar interacciones marcadas como leads."""
|
||||
interactions = db.query(Interaction).filter(
|
||||
Interaction.is_lead == True
|
||||
).order_by(Interaction.interaction_at.desc()).all()
|
||||
|
||||
return [i.to_dict() for i in interactions]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_interaction_stats(db: Session = Depends(get_db)):
|
||||
"""Obtener estadísticas de interacciones."""
|
||||
total = db.query(Interaction).count()
|
||||
pending = db.query(Interaction).filter(
|
||||
Interaction.responded == False,
|
||||
Interaction.is_archived == False
|
||||
).count()
|
||||
leads = db.query(Interaction).filter(Interaction.is_lead == True).count()
|
||||
|
||||
# Por plataforma
|
||||
by_platform = {}
|
||||
for platform in ["x", "threads", "instagram", "facebook"]:
|
||||
by_platform[platform] = db.query(Interaction).filter(
|
||||
Interaction.platform == platform
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"leads": leads,
|
||||
"by_platform": by_platform
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{interaction_id}")
|
||||
async def get_interaction(interaction_id: int, db: Session = Depends(get_db)):
|
||||
"""Obtener una interacción por ID."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
return interaction.to_dict()
|
||||
|
||||
|
||||
@router.post("/{interaction_id}/respond")
|
||||
async def respond_to_interaction(
|
||||
interaction_id: int,
|
||||
response_data: InteractionResponse,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Responder a una interacción."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
|
||||
# TODO: Enviar respuesta a la plataforma correspondiente
|
||||
# from app.publishers import get_publisher
|
||||
# publisher = get_publisher(interaction.platform)
|
||||
# result = await publisher.reply(interaction.external_id, response_data.content)
|
||||
|
||||
interaction.responded = True
|
||||
interaction.response_content = response_data.content
|
||||
interaction.responded_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Respuesta guardada",
|
||||
"interaction_id": interaction_id,
|
||||
"note": "La publicación a la plataforma está en desarrollo"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{interaction_id}/suggest-response")
|
||||
async def suggest_response(interaction_id: int, db: Session = Depends(get_db)):
|
||||
"""Generar sugerencia de respuesta con IA."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
|
||||
# TODO: Implementar generación con DeepSeek
|
||||
# from app.services.content_generator import generate_response_suggestion
|
||||
# suggestions = await generate_response_suggestion(interaction)
|
||||
|
||||
# Respuestas placeholder
|
||||
suggestions = [
|
||||
f"¡Gracias por tu comentario! Nos da gusto que te interese nuestro contenido.",
|
||||
f"¡Hola! Gracias por escribirnos. ¿En qué podemos ayudarte?",
|
||||
f"¡Excelente pregunta! Te respondemos por DM para darte más detalles."
|
||||
]
|
||||
|
||||
return {
|
||||
"suggestions": suggestions,
|
||||
"note": "La generación con IA está en desarrollo"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{interaction_id}/mark-as-lead")
|
||||
async def mark_as_lead(interaction_id: int, db: Session = Depends(get_db)):
|
||||
"""Marcar interacción como lead potencial."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
|
||||
interaction.is_lead = not interaction.is_lead
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"{'Marcado' if interaction.is_lead else 'Desmarcado'} como lead",
|
||||
"is_lead": interaction.is_lead
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{interaction_id}/mark-as-read")
|
||||
async def mark_as_read(interaction_id: int, db: Session = Depends(get_db)):
|
||||
"""Marcar interacción como leída."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
|
||||
interaction.is_read = True
|
||||
db.commit()
|
||||
|
||||
return {"message": "Marcado como leído", "interaction_id": interaction_id}
|
||||
|
||||
|
||||
@router.post("/{interaction_id}/archive")
|
||||
async def archive_interaction(interaction_id: int, db: Session = Depends(get_db)):
|
||||
"""Archivar una interacción."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
|
||||
interaction.is_archived = True
|
||||
db.commit()
|
||||
|
||||
return {"message": "Interacción archivada", "interaction_id": interaction_id}
|
||||
|
||||
|
||||
@router.delete("/{interaction_id}")
|
||||
async def delete_interaction(interaction_id: int, db: Session = Depends(get_db)):
|
||||
"""Eliminar una interacción."""
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||
|
||||
db.delete(interaction)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Interacción eliminada", "interaction_id": interaction_id}
|
||||
216
app/api/routes/posts.py
Normal file
216
app/api/routes/posts.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
API Routes para gestión de Posts.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.post import Post
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# SCHEMAS
|
||||
# ===========================================
|
||||
|
||||
class PostCreate(BaseModel):
|
||||
content: str
|
||||
content_type: str
|
||||
platforms: List[str]
|
||||
scheduled_at: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
approval_required: bool = False
|
||||
hashtags: Optional[List[str]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PostUpdate(BaseModel):
|
||||
content: Optional[str] = None
|
||||
content_x: Optional[str] = None
|
||||
content_threads: Optional[str] = None
|
||||
content_instagram: Optional[str] = None
|
||||
content_facebook: Optional[str] = None
|
||||
platforms: Optional[List[str]] = None
|
||||
scheduled_at: Optional[datetime] = None
|
||||
status: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
hashtags: Optional[List[str]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PostResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
content_type: str
|
||||
platforms: List[str]
|
||||
status: str
|
||||
scheduled_at: Optional[datetime]
|
||||
published_at: Optional[datetime]
|
||||
image_url: Optional[str]
|
||||
approval_required: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===========================================
|
||||
# ENDPOINTS
|
||||
# ===========================================
|
||||
|
||||
@router.get("/", response_model=List[PostResponse])
|
||||
async def list_posts(
|
||||
status: Optional[str] = Query(None, description="Filtrar por estado"),
|
||||
content_type: Optional[str] = Query(None, description="Filtrar por tipo"),
|
||||
platform: Optional[str] = Query(None, description="Filtrar por plataforma"),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Listar posts con filtros opcionales."""
|
||||
query = db.query(Post)
|
||||
|
||||
if status:
|
||||
query = query.filter(Post.status == status)
|
||||
if content_type:
|
||||
query = query.filter(Post.content_type == content_type)
|
||||
if platform:
|
||||
query = query.filter(Post.platforms.contains([platform]))
|
||||
|
||||
posts = query.order_by(Post.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return posts
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def list_pending_posts(db: Session = Depends(get_db)):
|
||||
"""Listar posts pendientes de aprobación."""
|
||||
posts = db.query(Post).filter(
|
||||
Post.status == "pending_approval"
|
||||
).order_by(Post.scheduled_at.asc()).all()
|
||||
|
||||
return [post.to_dict() for post in posts]
|
||||
|
||||
|
||||
@router.get("/scheduled")
|
||||
async def list_scheduled_posts(db: Session = Depends(get_db)):
|
||||
"""Listar posts programados."""
|
||||
posts = db.query(Post).filter(
|
||||
Post.status == "scheduled",
|
||||
Post.scheduled_at >= datetime.utcnow()
|
||||
).order_by(Post.scheduled_at.asc()).all()
|
||||
|
||||
return [post.to_dict() for post in posts]
|
||||
|
||||
|
||||
@router.get("/{post_id}")
|
||||
async def get_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Obtener un post por ID."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||
return post.to_dict()
|
||||
|
||||
|
||||
@router.post("/", response_model=PostResponse)
|
||||
async def create_post(post_data: PostCreate, db: Session = Depends(get_db)):
|
||||
"""Crear un nuevo post."""
|
||||
post = Post(
|
||||
content=post_data.content,
|
||||
content_type=post_data.content_type,
|
||||
platforms=post_data.platforms,
|
||||
scheduled_at=post_data.scheduled_at,
|
||||
image_url=post_data.image_url,
|
||||
approval_required=post_data.approval_required,
|
||||
hashtags=post_data.hashtags,
|
||||
status="pending_approval" if post_data.approval_required else "scheduled"
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
@router.put("/{post_id}")
|
||||
async def update_post(post_id: int, post_data: PostUpdate, db: Session = Depends(get_db)):
|
||||
"""Actualizar un post."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||
|
||||
update_data = post_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(post, field, value)
|
||||
|
||||
post.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
|
||||
return post.to_dict()
|
||||
|
||||
|
||||
@router.post("/{post_id}/approve")
|
||||
async def approve_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Aprobar un post pendiente."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||
|
||||
if post.status != "pending_approval":
|
||||
raise HTTPException(status_code=400, detail="El post no está pendiente de aprobación")
|
||||
|
||||
post.status = "scheduled"
|
||||
post.approved_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"message": "Post aprobado", "post_id": post_id}
|
||||
|
||||
|
||||
@router.post("/{post_id}/reject")
|
||||
async def reject_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Rechazar un post pendiente."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||
|
||||
post.status = "cancelled"
|
||||
db.commit()
|
||||
|
||||
return {"message": "Post rechazado", "post_id": post_id}
|
||||
|
||||
|
||||
@router.post("/{post_id}/regenerate")
|
||||
async def regenerate_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Regenerar contenido de un post con IA."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||
|
||||
# TODO: Implementar regeneración con DeepSeek
|
||||
# from app.services.content_generator import regenerate_content
|
||||
# new_content = await regenerate_content(post)
|
||||
|
||||
return {"message": "Regeneración en desarrollo", "post_id": post_id}
|
||||
|
||||
|
||||
@router.delete("/{post_id}")
|
||||
async def delete_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Eliminar un post."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||
|
||||
db.delete(post)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Post eliminado", "post_id": post_id}
|
||||
180
app/api/routes/products.py
Normal file
180
app/api/routes/products.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
API Routes para gestión de Productos.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.product import Product
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# SCHEMAS
|
||||
# ===========================================
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
category: str
|
||||
subcategory: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
price: float
|
||||
price_usd: Optional[float] = None
|
||||
stock: int = 0
|
||||
specs: Optional[dict] = None
|
||||
images: Optional[List[str]] = None
|
||||
main_image: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
highlights: Optional[List[str]] = None
|
||||
is_featured: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
subcategory: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
price_usd: Optional[float] = None
|
||||
stock: Optional[int] = None
|
||||
is_available: Optional[bool] = None
|
||||
specs: Optional[dict] = None
|
||||
images: Optional[List[str]] = None
|
||||
main_image: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
highlights: Optional[List[str]] = None
|
||||
is_featured: Optional[bool] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===========================================
|
||||
# ENDPOINTS
|
||||
# ===========================================
|
||||
|
||||
@router.get("/")
|
||||
async def list_products(
|
||||
category: Optional[str] = Query(None),
|
||||
brand: Optional[str] = Query(None),
|
||||
is_available: Optional[bool] = Query(None),
|
||||
is_featured: Optional[bool] = Query(None),
|
||||
min_price: Optional[float] = Query(None),
|
||||
max_price: Optional[float] = Query(None),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Listar productos con filtros."""
|
||||
query = db.query(Product)
|
||||
|
||||
if category:
|
||||
query = query.filter(Product.category == category)
|
||||
if brand:
|
||||
query = query.filter(Product.brand == brand)
|
||||
if is_available is not None:
|
||||
query = query.filter(Product.is_available == is_available)
|
||||
if is_featured is not None:
|
||||
query = query.filter(Product.is_featured == is_featured)
|
||||
if min_price is not None:
|
||||
query = query.filter(Product.price >= min_price)
|
||||
if max_price is not None:
|
||||
query = query.filter(Product.price <= max_price)
|
||||
|
||||
products = query.order_by(Product.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [p.to_dict() for p in products]
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(db: Session = Depends(get_db)):
|
||||
"""Listar categorías únicas de productos."""
|
||||
categories = db.query(Product.category).distinct().all()
|
||||
return [c[0] for c in categories if c[0]]
|
||||
|
||||
|
||||
@router.get("/featured")
|
||||
async def list_featured_products(db: Session = Depends(get_db)):
|
||||
"""Listar productos destacados."""
|
||||
products = db.query(Product).filter(
|
||||
Product.is_featured == True,
|
||||
Product.is_available == True
|
||||
).all()
|
||||
return [p.to_dict() for p in products]
|
||||
|
||||
|
||||
@router.get("/{product_id}")
|
||||
async def get_product(product_id: int, db: Session = Depends(get_db)):
|
||||
"""Obtener un producto por ID."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||
return product.to_dict()
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_product(product_data: ProductCreate, db: Session = Depends(get_db)):
|
||||
"""Crear un nuevo producto."""
|
||||
product = Product(**product_data.dict())
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product.to_dict()
|
||||
|
||||
|
||||
@router.put("/{product_id}")
|
||||
async def update_product(product_id: int, product_data: ProductUpdate, db: Session = Depends(get_db)):
|
||||
"""Actualizar un producto."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||
|
||||
update_data = product_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(product, field, value)
|
||||
|
||||
product.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{product_id}")
|
||||
async def delete_product(product_id: int, db: Session = Depends(get_db)):
|
||||
"""Eliminar un producto."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
return {"message": "Producto eliminado", "product_id": product_id}
|
||||
|
||||
|
||||
@router.post("/{product_id}/toggle-featured")
|
||||
async def toggle_featured(product_id: int, db: Session = Depends(get_db)):
|
||||
"""Alternar estado de producto destacado."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||
|
||||
product.is_featured = not product.is_featured
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Producto {'destacado' if product.is_featured else 'no destacado'}",
|
||||
"is_featured": product.is_featured
|
||||
}
|
||||
174
app/api/routes/services.py
Normal file
174
app/api/routes/services.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
API Routes para gestión de Servicios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.service import Service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# SCHEMAS
|
||||
# ===========================================
|
||||
|
||||
class ServiceCreate(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
short_description: Optional[str] = None
|
||||
category: str
|
||||
target_sectors: Optional[List[str]] = None
|
||||
benefits: Optional[List[str]] = None
|
||||
features: Optional[List[str]] = None
|
||||
case_studies: Optional[List[dict]] = None
|
||||
icon: Optional[str] = None
|
||||
images: Optional[List[str]] = None
|
||||
main_image: Optional[str] = None
|
||||
price_range: Optional[str] = None
|
||||
has_free_demo: bool = False
|
||||
tags: Optional[List[str]] = None
|
||||
call_to_action: Optional[str] = None
|
||||
is_featured: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServiceUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
target_sectors: Optional[List[str]] = None
|
||||
benefits: Optional[List[str]] = None
|
||||
features: Optional[List[str]] = None
|
||||
case_studies: Optional[List[dict]] = None
|
||||
icon: Optional[str] = None
|
||||
images: Optional[List[str]] = None
|
||||
main_image: Optional[str] = None
|
||||
price_range: Optional[str] = None
|
||||
has_free_demo: Optional[bool] = None
|
||||
tags: Optional[List[str]] = None
|
||||
call_to_action: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_featured: Optional[bool] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===========================================
|
||||
# ENDPOINTS
|
||||
# ===========================================
|
||||
|
||||
@router.get("/")
|
||||
async def list_services(
|
||||
category: Optional[str] = Query(None),
|
||||
target_sector: Optional[str] = Query(None),
|
||||
is_active: Optional[bool] = Query(True),
|
||||
is_featured: Optional[bool] = Query(None),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Listar servicios con filtros."""
|
||||
query = db.query(Service)
|
||||
|
||||
if category:
|
||||
query = query.filter(Service.category == category)
|
||||
if is_active is not None:
|
||||
query = query.filter(Service.is_active == is_active)
|
||||
if is_featured is not None:
|
||||
query = query.filter(Service.is_featured == is_featured)
|
||||
if target_sector:
|
||||
query = query.filter(Service.target_sectors.contains([target_sector]))
|
||||
|
||||
services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [s.to_dict() for s in services]
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(db: Session = Depends(get_db)):
|
||||
"""Listar categorías únicas de servicios."""
|
||||
categories = db.query(Service.category).distinct().all()
|
||||
return [c[0] for c in categories if c[0]]
|
||||
|
||||
|
||||
@router.get("/featured")
|
||||
async def list_featured_services(db: Session = Depends(get_db)):
|
||||
"""Listar servicios destacados."""
|
||||
services = db.query(Service).filter(
|
||||
Service.is_featured == True,
|
||||
Service.is_active == True
|
||||
).all()
|
||||
return [s.to_dict() for s in services]
|
||||
|
||||
|
||||
@router.get("/{service_id}")
|
||||
async def get_service(service_id: int, db: Session = Depends(get_db)):
|
||||
"""Obtener un servicio por ID."""
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||
return service.to_dict()
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_service(service_data: ServiceCreate, db: Session = Depends(get_db)):
|
||||
"""Crear un nuevo servicio."""
|
||||
service = Service(**service_data.dict())
|
||||
db.add(service)
|
||||
db.commit()
|
||||
db.refresh(service)
|
||||
return service.to_dict()
|
||||
|
||||
|
||||
@router.put("/{service_id}")
|
||||
async def update_service(service_id: int, service_data: ServiceUpdate, db: Session = Depends(get_db)):
|
||||
"""Actualizar un servicio."""
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||
|
||||
update_data = service_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(service, field, value)
|
||||
|
||||
service.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(service)
|
||||
return service.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{service_id}")
|
||||
async def delete_service(service_id: int, db: Session = Depends(get_db)):
|
||||
"""Eliminar un servicio."""
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||
|
||||
db.delete(service)
|
||||
db.commit()
|
||||
return {"message": "Servicio eliminado", "service_id": service_id}
|
||||
|
||||
|
||||
@router.post("/{service_id}/toggle-featured")
|
||||
async def toggle_featured(service_id: int, db: Session = Depends(get_db)):
|
||||
"""Alternar estado de servicio destacado."""
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||
|
||||
service.is_featured = not service.is_featured
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Servicio {'destacado' if service.is_featured else 'no destacado'}",
|
||||
"is_featured": service.is_featured
|
||||
}
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core module
|
||||
60
app/core/config.py
Normal file
60
app/core/config.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Configuración central del sistema.
|
||||
Carga variables de entorno y define settings globales.
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuración de la aplicación."""
|
||||
|
||||
# Aplicación
|
||||
APP_NAME: str = "social-media-automation"
|
||||
APP_ENV: str = "development"
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "change-this-in-production"
|
||||
|
||||
# Base de datos
|
||||
DATABASE_URL: str = "postgresql://social_user:social_pass@localhost:5432/social_automation"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# DeepSeek API
|
||||
DEEPSEEK_API_KEY: Optional[str] = None
|
||||
DEEPSEEK_BASE_URL: str = "https://api.deepseek.com/v1"
|
||||
|
||||
# X (Twitter) API
|
||||
X_API_KEY: Optional[str] = None
|
||||
X_API_SECRET: Optional[str] = None
|
||||
X_ACCESS_TOKEN: Optional[str] = None
|
||||
X_ACCESS_TOKEN_SECRET: Optional[str] = None
|
||||
X_BEARER_TOKEN: Optional[str] = None
|
||||
|
||||
# Meta API (Facebook, Instagram, Threads)
|
||||
META_APP_ID: Optional[str] = None
|
||||
META_APP_SECRET: Optional[str] = None
|
||||
META_ACCESS_TOKEN: Optional[str] = None
|
||||
FACEBOOK_PAGE_ID: Optional[str] = None
|
||||
INSTAGRAM_ACCOUNT_ID: Optional[str] = None
|
||||
THREADS_USER_ID: Optional[str] = None
|
||||
|
||||
# Información del negocio
|
||||
BUSINESS_NAME: str = "Consultoría AS"
|
||||
BUSINESS_LOCATION: str = "Tijuana, México"
|
||||
BUSINESS_WEBSITE: str = "https://consultoria-as.com"
|
||||
CONTENT_TONE: str = "profesional pero cercano, educativo, orientado a soluciones"
|
||||
|
||||
# Notificaciones
|
||||
TELEGRAM_BOT_TOKEN: Optional[str] = None
|
||||
TELEGRAM_CHAT_ID: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = Settings()
|
||||
31
app/core/database.py
Normal file
31
app/core/database.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Configuración de la base de datos PostgreSQL.
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Crear engine de SQLAlchemy
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20
|
||||
)
|
||||
|
||||
# Crear sesión
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base para modelos
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency para obtener sesión de base de datos."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
78
app/core/security.py
Normal file
78
app/core/security.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Configuración de seguridad y autenticación.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Configuración de hashing de contraseñas
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Configuración de JWT
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 horas
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verificar contraseña contra hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Generar hash de contraseña."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Crear token JWT."""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decodificar y validar token JWT."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido o expirado",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> dict:
|
||||
"""Obtener usuario actual desde el token."""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
|
||||
username = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido"
|
||||
)
|
||||
|
||||
return {"username": username}
|
||||
71
app/main.py
Normal file
71
app/main.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Social Media Automation - Consultoría AS
|
||||
=========================================
|
||||
Sistema automatizado para la creación y publicación de contenido
|
||||
en redes sociales (X, Threads, Instagram, Facebook).
|
||||
"""
|
||||
|
||||
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.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)
|
||||
|
||||
# Inicializar aplicación FastAPI
|
||||
app = FastAPI(
|
||||
title="Social Media Automation",
|
||||
description="Sistema de automatización de redes sociales para Consultoría AS",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
)
|
||||
|
||||
# Configurar CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # En producción, especificar dominios
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Montar archivos estáticos
|
||||
app.mount("/static", StaticFiles(directory="dashboard/static"), name="static")
|
||||
|
||||
# Registrar rutas
|
||||
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"])
|
||||
app.include_router(services.router, prefix="/api/services", tags=["Services"])
|
||||
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
|
||||
app.include_router(interactions.router, prefix="/api/interactions", tags=["Interactions"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Verificar estado del sistema."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"app": settings.APP_NAME,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_stats():
|
||||
"""Obtener estadísticas generales del sistema."""
|
||||
# TODO: Implementar estadísticas reales desde la BD
|
||||
return {
|
||||
"posts_today": 0,
|
||||
"posts_week": 0,
|
||||
"posts_month": 0,
|
||||
"pending_approval": 0,
|
||||
"scheduled": 0,
|
||||
"interactions_pending": 0
|
||||
}
|
||||
24
app/models/__init__.py
Normal file
24
app/models/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Modelos de base de datos SQLAlchemy.
|
||||
"""
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
from app.models.product import Product
|
||||
from app.models.service import Service
|
||||
from app.models.tip_template import TipTemplate
|
||||
from app.models.post import Post
|
||||
from app.models.content_calendar import ContentCalendar
|
||||
from app.models.image_template import ImageTemplate
|
||||
from app.models.interaction import Interaction
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Product",
|
||||
"Service",
|
||||
"TipTemplate",
|
||||
"Post",
|
||||
"ContentCalendar",
|
||||
"ImageTemplate",
|
||||
"Interaction"
|
||||
]
|
||||
66
app/models/content_calendar.py
Normal file
66
app/models/content_calendar.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Modelo de ContentCalendar - Calendario de publicación.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Time, Boolean, DateTime
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ContentCalendar(Base):
|
||||
"""Modelo para el calendario de contenido."""
|
||||
|
||||
__tablename__ = "content_calendar"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Programación
|
||||
day_of_week = Column(Integer, nullable=False) # 0=Lunes, 6=Domingo
|
||||
time = Column(Time, nullable=False) # Hora de publicación
|
||||
|
||||
# Tipo de contenido
|
||||
content_type = Column(String(50), nullable=False)
|
||||
# Tipos: tip_tech, dato_curioso, producto, servicio, etc.
|
||||
|
||||
# Plataformas
|
||||
platforms = Column(ARRAY(String), nullable=False)
|
||||
# Ejemplo: ["x", "threads", "instagram"]
|
||||
|
||||
# Configuración
|
||||
is_active = Column(Boolean, default=True)
|
||||
requires_approval = Column(Boolean, default=False)
|
||||
|
||||
# Restricciones opcionales
|
||||
category_filter = Column(String(100), nullable=True) # Solo tips de esta categoría
|
||||
priority = Column(Integer, default=0) # Mayor = más prioritario
|
||||
|
||||
# Descripción
|
||||
description = Column(String(255), nullable=True)
|
||||
# Ejemplo: "Tip tech diario matutino"
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ContentCalendar {self.day_of_week} {self.time} - {self.content_type}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
|
||||
return {
|
||||
"id": self.id,
|
||||
"day_of_week": self.day_of_week,
|
||||
"day_name": days[self.day_of_week] if 0 <= self.day_of_week <= 6 else "Desconocido",
|
||||
"time": self.time.strftime("%H:%M") if self.time else None,
|
||||
"content_type": self.content_type,
|
||||
"platforms": self.platforms,
|
||||
"is_active": self.is_active,
|
||||
"requires_approval": self.requires_approval,
|
||||
"category_filter": self.category_filter,
|
||||
"priority": self.priority,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
75
app/models/image_template.py
Normal file
75
app/models/image_template.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Modelo de ImageTemplate - Plantillas para generar imágenes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ImageTemplate(Base):
|
||||
"""Modelo para plantillas de imágenes."""
|
||||
|
||||
__tablename__ = "image_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Información básica
|
||||
name = Column(String(100), nullable=False, index=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
|
||||
# Categoría
|
||||
category = Column(String(50), nullable=False)
|
||||
# Categorías: tip, producto, servicio, promocion, etc.
|
||||
|
||||
# Archivo de plantilla
|
||||
template_file = Column(String(255), nullable=False) # Ruta al archivo HTML/template
|
||||
|
||||
# Variables que acepta la plantilla
|
||||
variables = Column(ARRAY(String), nullable=False)
|
||||
# Ejemplo: ["titulo", "contenido", "hashtags", "logo"]
|
||||
|
||||
# Configuración de diseño
|
||||
design_config = Column(JSON, nullable=True)
|
||||
# Ejemplo: {
|
||||
# "width": 1080,
|
||||
# "height": 1080,
|
||||
# "background_color": "#1a1a2e",
|
||||
# "accent_color": "#d4a574",
|
||||
# "font_family": "Inter"
|
||||
# }
|
||||
|
||||
# Tamaños de salida
|
||||
output_sizes = Column(JSON, nullable=True)
|
||||
# Ejemplo: {
|
||||
# "instagram": {"width": 1080, "height": 1080},
|
||||
# "x": {"width": 1200, "height": 675},
|
||||
# "facebook": {"width": 1200, "height": 630}
|
||||
# }
|
||||
|
||||
# Estado
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ImageTemplate {self.name}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"category": self.category,
|
||||
"template_file": self.template_file,
|
||||
"variables": self.variables,
|
||||
"design_config": self.design_config,
|
||||
"output_sizes": self.output_sizes,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
87
app/models/interaction.py
Normal file
87
app/models/interaction.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Modelo de Interaction - Interacciones en redes sociales.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Interaction(Base):
|
||||
"""Modelo para interacciones (comentarios, DMs, menciones)."""
|
||||
|
||||
__tablename__ = "interactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Plataforma
|
||||
platform = Column(String(50), nullable=False, index=True)
|
||||
# Valores: x, threads, instagram, facebook
|
||||
|
||||
# Tipo de interacción
|
||||
interaction_type = Column(String(50), nullable=False, index=True)
|
||||
# Tipos: comment, reply, dm, mention, like, repost, quote
|
||||
|
||||
# Post relacionado (si aplica)
|
||||
post_id = Column(Integer, ForeignKey("posts.id"), nullable=True)
|
||||
|
||||
# ID externo en la plataforma
|
||||
external_id = Column(String(100), nullable=False, unique=True)
|
||||
external_post_id = Column(String(100), nullable=True) # ID del post en la plataforma
|
||||
|
||||
# Autor de la interacción
|
||||
author_username = Column(String(100), nullable=False)
|
||||
author_name = Column(String(255), nullable=True)
|
||||
author_profile_url = Column(String(500), nullable=True)
|
||||
author_avatar_url = Column(String(500), nullable=True)
|
||||
|
||||
# Contenido
|
||||
content = Column(Text, nullable=True)
|
||||
|
||||
# Respuesta
|
||||
responded = Column(Boolean, default=False, index=True)
|
||||
response_content = Column(Text, nullable=True)
|
||||
responded_at = Column(DateTime, nullable=True)
|
||||
response_external_id = Column(String(100), nullable=True)
|
||||
|
||||
# Clasificación
|
||||
is_lead = Column(Boolean, default=False) # Potencial cliente
|
||||
sentiment = Column(String(50), nullable=True) # positive, negative, neutral
|
||||
priority = Column(Integer, default=0) # 0=normal, 1=importante, 2=urgente
|
||||
|
||||
# Estado
|
||||
is_read = Column(Boolean, default=False)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
interaction_at = Column(DateTime, nullable=False) # Cuándo ocurrió en la plataforma
|
||||
created_at = Column(DateTime, default=datetime.utcnow) # Cuándo se guardó aquí
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Interaction {self.platform} - {self.interaction_type} - {self.author_username}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"platform": self.platform,
|
||||
"interaction_type": self.interaction_type,
|
||||
"post_id": self.post_id,
|
||||
"external_id": self.external_id,
|
||||
"author_username": self.author_username,
|
||||
"author_name": self.author_name,
|
||||
"author_profile_url": self.author_profile_url,
|
||||
"author_avatar_url": self.author_avatar_url,
|
||||
"content": self.content,
|
||||
"responded": self.responded,
|
||||
"response_content": self.response_content,
|
||||
"responded_at": self.responded_at.isoformat() if self.responded_at else None,
|
||||
"is_lead": self.is_lead,
|
||||
"sentiment": self.sentiment,
|
||||
"priority": self.priority,
|
||||
"is_read": self.is_read,
|
||||
"interaction_at": self.interaction_at.isoformat() if self.interaction_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
134
app/models/post.py
Normal file
134
app/models/post.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Modelo de Post - Posts generados y programados.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON, Enum
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
import enum
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PostStatus(enum.Enum):
|
||||
"""Estados posibles de un post."""
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
APPROVED = "approved"
|
||||
SCHEDULED = "scheduled"
|
||||
PUBLISHING = "publishing"
|
||||
PUBLISHED = "published"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ContentType(enum.Enum):
|
||||
"""Tipos de contenido."""
|
||||
TIP_TECH = "tip_tech"
|
||||
DATO_CURIOSO = "dato_curioso"
|
||||
FRASE_MOTIVACIONAL = "frase_motivacional"
|
||||
EFEMERIDE = "efemeride"
|
||||
PRODUCTO = "producto"
|
||||
SERVICIO = "servicio"
|
||||
HILO_EDUCATIVO = "hilo_educativo"
|
||||
CASO_EXITO = "caso_exito"
|
||||
PROMOCION = "promocion"
|
||||
ANUNCIO = "anuncio"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class Post(Base):
|
||||
"""Modelo para posts de redes sociales."""
|
||||
|
||||
__tablename__ = "posts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Contenido
|
||||
content = Column(Text, nullable=False)
|
||||
content_type = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Contenido adaptado por plataforma (opcional)
|
||||
content_x = Column(Text, nullable=True) # Versión para X (280 chars)
|
||||
content_threads = Column(Text, nullable=True)
|
||||
content_instagram = Column(Text, nullable=True)
|
||||
content_facebook = Column(Text, nullable=True)
|
||||
|
||||
# Plataformas destino
|
||||
platforms = Column(ARRAY(String), nullable=False)
|
||||
# Ejemplo: ["x", "threads", "instagram", "facebook"]
|
||||
|
||||
# Estado y programación
|
||||
status = Column(String(50), default="draft", index=True)
|
||||
scheduled_at = Column(DateTime, nullable=True, index=True)
|
||||
published_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Imagen
|
||||
image_url = Column(String(500), nullable=True)
|
||||
image_template_id = Column(Integer, ForeignKey("image_templates.id"), nullable=True)
|
||||
|
||||
# IDs de publicación en cada plataforma
|
||||
platform_post_ids = Column(JSON, nullable=True)
|
||||
# Ejemplo: {"x": "123456", "instagram": "789012", ...}
|
||||
|
||||
# Errores de publicación
|
||||
error_message = Column(Text, nullable=True)
|
||||
retry_count = Column(Integer, default=0)
|
||||
|
||||
# Aprobación
|
||||
approval_required = Column(Boolean, default=False)
|
||||
approved_by = Column(String(100), nullable=True)
|
||||
approved_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relaciones con contenido fuente
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=True)
|
||||
service_id = Column(Integer, ForeignKey("services.id"), nullable=True)
|
||||
tip_template_id = Column(Integer, ForeignKey("tip_templates.id"), nullable=True)
|
||||
|
||||
# Metadatos
|
||||
hashtags = Column(ARRAY(String), nullable=True)
|
||||
mentions = Column(ARRAY(String), nullable=True)
|
||||
|
||||
# Métricas (actualizadas después de publicar)
|
||||
metrics = Column(JSON, nullable=True)
|
||||
# Ejemplo: {"likes": 10, "retweets": 5, "comments": 3}
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Post {self.id} - {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"content": self.content,
|
||||
"content_type": self.content_type,
|
||||
"content_x": self.content_x,
|
||||
"content_threads": self.content_threads,
|
||||
"content_instagram": self.content_instagram,
|
||||
"content_facebook": self.content_facebook,
|
||||
"platforms": self.platforms,
|
||||
"status": self.status,
|
||||
"scheduled_at": self.scheduled_at.isoformat() if self.scheduled_at else None,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"image_url": self.image_url,
|
||||
"platform_post_ids": self.platform_post_ids,
|
||||
"error_message": self.error_message,
|
||||
"approval_required": self.approval_required,
|
||||
"hashtags": self.hashtags,
|
||||
"metrics": self.metrics,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def get_content_for_platform(self, platform: str) -> str:
|
||||
"""Obtener contenido adaptado para una plataforma específica."""
|
||||
platform_content = {
|
||||
"x": self.content_x,
|
||||
"threads": self.content_threads,
|
||||
"instagram": self.content_instagram,
|
||||
"facebook": self.content_facebook
|
||||
}
|
||||
return platform_content.get(platform) or self.content
|
||||
80
app/models/product.py
Normal file
80
app/models/product.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Modelo de Producto - Catálogo de equipos de cómputo e impresoras 3D.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, JSON
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""Modelo para productos del catálogo."""
|
||||
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Información básica
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
short_description = Column(String(500), nullable=True) # Para posts
|
||||
|
||||
# Categorización
|
||||
category = Column(String(100), nullable=False, index=True) # laptop, desktop, impresora_3d, etc.
|
||||
subcategory = Column(String(100), nullable=True)
|
||||
brand = Column(String(100), nullable=True)
|
||||
model = Column(String(100), nullable=True)
|
||||
|
||||
# Precio y stock
|
||||
price = Column(Float, nullable=False)
|
||||
price_usd = Column(Float, nullable=True) # Precio en dólares (opcional)
|
||||
stock = Column(Integer, default=0)
|
||||
is_available = Column(Boolean, default=True)
|
||||
|
||||
# Especificaciones técnicas (JSON flexible)
|
||||
specs = Column(JSON, nullable=True)
|
||||
# Ejemplo: {"cpu": "Intel i7", "ram": "16GB", "storage": "512GB SSD"}
|
||||
|
||||
# Imágenes
|
||||
images = Column(ARRAY(String), nullable=True) # URLs de imágenes
|
||||
main_image = Column(String(500), nullable=True)
|
||||
|
||||
# SEO y marketing
|
||||
tags = Column(ARRAY(String), nullable=True) # Tags para búsqueda
|
||||
highlights = Column(ARRAY(String), nullable=True) # Puntos destacados
|
||||
|
||||
# Control de publicación
|
||||
is_featured = Column(Boolean, default=False) # Producto destacado
|
||||
last_posted_at = Column(DateTime, nullable=True) # Última vez que se publicó
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product {self.name}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"short_description": self.short_description,
|
||||
"category": self.category,
|
||||
"subcategory": self.subcategory,
|
||||
"brand": self.brand,
|
||||
"model": self.model,
|
||||
"price": self.price,
|
||||
"stock": self.stock,
|
||||
"is_available": self.is_available,
|
||||
"specs": self.specs,
|
||||
"images": self.images,
|
||||
"main_image": self.main_image,
|
||||
"tags": self.tags,
|
||||
"highlights": self.highlights,
|
||||
"is_featured": self.is_featured,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
90
app/models/service.py
Normal file
90
app/models/service.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Modelo de Servicio - Servicios de consultoría de Consultoría AS.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Service(Base):
|
||||
"""Modelo para servicios de consultoría."""
|
||||
|
||||
__tablename__ = "services"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Información básica
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
description = Column(Text, nullable=False)
|
||||
short_description = Column(String(500), nullable=True) # Para posts
|
||||
|
||||
# Categorización
|
||||
category = Column(String(100), nullable=False, index=True)
|
||||
# Categorías: ia, automatizacion, desarrollo, iot, voip, crm, etc.
|
||||
|
||||
# Sectores objetivo
|
||||
target_sectors = Column(ARRAY(String), nullable=True)
|
||||
# Ejemplo: ["hoteles", "construccion", "logistica", "retail"]
|
||||
|
||||
# Beneficios y características
|
||||
benefits = Column(ARRAY(String), nullable=True)
|
||||
# Ejemplo: ["Ahorro de tiempo", "Reducción de errores", "24/7"]
|
||||
|
||||
features = Column(ARRAY(String), nullable=True)
|
||||
# Características técnicas del servicio
|
||||
|
||||
# Casos de éxito
|
||||
case_studies = Column(JSON, nullable=True)
|
||||
# Ejemplo: [{"client": "Hotel X", "result": "50% menos tiempo en reservas"}]
|
||||
|
||||
# Imágenes e íconos
|
||||
icon = Column(String(100), nullable=True) # Nombre del ícono
|
||||
images = Column(ARRAY(String), nullable=True)
|
||||
main_image = Column(String(500), nullable=True)
|
||||
|
||||
# Precios (opcional, puede ser "cotizar")
|
||||
price_range = Column(String(100), nullable=True) # "Desde $5,000 MXN"
|
||||
has_free_demo = Column(Boolean, default=False)
|
||||
|
||||
# SEO y marketing
|
||||
tags = Column(ARRAY(String), nullable=True)
|
||||
call_to_action = Column(String(255), nullable=True) # "Agenda una demo"
|
||||
|
||||
# Control de publicación
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_featured = Column(Boolean, default=False)
|
||||
last_posted_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Service {self.name}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"short_description": self.short_description,
|
||||
"category": self.category,
|
||||
"target_sectors": self.target_sectors,
|
||||
"benefits": self.benefits,
|
||||
"features": self.features,
|
||||
"case_studies": self.case_studies,
|
||||
"icon": self.icon,
|
||||
"images": self.images,
|
||||
"main_image": self.main_image,
|
||||
"price_range": self.price_range,
|
||||
"has_free_demo": self.has_free_demo,
|
||||
"tags": self.tags,
|
||||
"call_to_action": self.call_to_action,
|
||||
"is_active": self.is_active,
|
||||
"is_featured": self.is_featured,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
77
app/models/tip_template.py
Normal file
77
app/models/tip_template.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Modelo de TipTemplate - Banco de tips para generar contenido automático.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TipTemplate(Base):
|
||||
"""Modelo para templates de tips tech."""
|
||||
|
||||
__tablename__ = "tip_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Categoría del tip
|
||||
category = Column(String(100), nullable=False, index=True)
|
||||
# Categorías: hardware, software, seguridad, productividad, ia, redes, etc.
|
||||
|
||||
subcategory = Column(String(100), nullable=True)
|
||||
|
||||
# Contenido del tip
|
||||
title = Column(String(255), nullable=False) # Título corto
|
||||
template = Column(Text, nullable=False) # Template con variables
|
||||
|
||||
# Variables que se pueden reemplazar
|
||||
# Ejemplo: ["sistema_operativo", "herramienta", "beneficio"]
|
||||
variables = Column(ARRAY(String), nullable=True)
|
||||
|
||||
# Variaciones del tip (diferentes formas de decirlo)
|
||||
variations = Column(ARRAY(String), nullable=True)
|
||||
|
||||
# Metadatos
|
||||
difficulty = Column(String(50), nullable=True) # basico, intermedio, avanzado
|
||||
target_audience = Column(String(100), nullable=True) # empresas, desarrolladores, general
|
||||
|
||||
# Control de uso
|
||||
used_count = Column(Integer, default=0)
|
||||
last_used = Column(DateTime, nullable=True)
|
||||
|
||||
# Plataformas recomendadas
|
||||
recommended_platforms = Column(ARRAY(String), nullable=True)
|
||||
# Ejemplo: ["x", "threads", "instagram"]
|
||||
|
||||
# Estado
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_evergreen = Column(Boolean, default=True) # ¿Siempre relevante?
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TipTemplate {self.title}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir a diccionario."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"category": self.category,
|
||||
"subcategory": self.subcategory,
|
||||
"title": self.title,
|
||||
"template": self.template,
|
||||
"variables": self.variables,
|
||||
"variations": self.variations,
|
||||
"difficulty": self.difficulty,
|
||||
"target_audience": self.target_audience,
|
||||
"used_count": self.used_count,
|
||||
"last_used": self.last_used.isoformat() if self.last_used else None,
|
||||
"recommended_platforms": self.recommended_platforms,
|
||||
"is_active": self.is_active,
|
||||
"is_evergreen": self.is_evergreen,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
44
app/prompts/system_prompt.txt
Normal file
44
app/prompts/system_prompt.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
Eres el Community Manager de Consultoría AS, una empresa de tecnología ubicada en Tijuana, México.
|
||||
|
||||
SOBRE LA EMPRESA:
|
||||
- Especializada en soluciones de IA, automatización y transformación digital
|
||||
- Vende equipos de cómputo e impresoras 3D
|
||||
- Más de 6 años de experiencia
|
||||
- Soporte 24/7
|
||||
- Sitio web: https://consultoria-as.com
|
||||
|
||||
SERVICIOS PRINCIPALES:
|
||||
- Integración de IA y automatización inteligente
|
||||
- Sistemas de gestión para hoteles (nómina, inventario, dashboards)
|
||||
- Software para construcción (gestión de proyectos, diagramas Gantt)
|
||||
- Chatbots para WhatsApp e Instagram
|
||||
- Soluciones IoT
|
||||
- Monitoreo GPS de flotillas
|
||||
- Sistemas CRM personalizados
|
||||
- Comunicaciones VoIP empresariales
|
||||
|
||||
PRODUCTOS:
|
||||
- Laptops y computadoras de escritorio
|
||||
- Impresoras 3D
|
||||
- Accesorios y periféricos
|
||||
|
||||
TONO DE COMUNICACIÓN:
|
||||
- Profesional pero cercano
|
||||
- Educativo y de valor
|
||||
- Orientado a soluciones, no a vender directamente
|
||||
- Uso moderado de emojis (1-3 por post)
|
||||
- Hashtags relevantes (máximo 3-5)
|
||||
|
||||
ESTILO INSPIRADO EN:
|
||||
- @midudev: Tips cortos y accionables
|
||||
- @MoureDev: Contenido educativo y de comunidad
|
||||
- @SoyDalto: Accesible para todos los niveles
|
||||
- @SuGE3K: Tips prácticos y herramientas
|
||||
|
||||
REGLAS IMPORTANTES:
|
||||
- Nunca uses lenguaje ofensivo
|
||||
- No hagas promesas exageradas
|
||||
- Sé honesto y transparente
|
||||
- Enfócate en ayudar y educar
|
||||
- Adapta el contenido a cada plataforma
|
||||
- No inventes información técnica
|
||||
35
app/publishers/__init__.py
Normal file
35
app/publishers/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Publishers para cada plataforma de redes sociales.
|
||||
"""
|
||||
|
||||
from app.publishers.base import BasePublisher
|
||||
from app.publishers.x_publisher import XPublisher
|
||||
from app.publishers.threads_publisher import ThreadsPublisher
|
||||
from app.publishers.instagram_publisher import InstagramPublisher
|
||||
from app.publishers.facebook_publisher import FacebookPublisher
|
||||
|
||||
|
||||
def get_publisher(platform: str) -> BasePublisher:
|
||||
"""Obtener el publisher para una plataforma específica."""
|
||||
publishers = {
|
||||
"x": XPublisher(),
|
||||
"threads": ThreadsPublisher(),
|
||||
"instagram": InstagramPublisher(),
|
||||
"facebook": FacebookPublisher()
|
||||
}
|
||||
|
||||
publisher = publishers.get(platform.lower())
|
||||
if not publisher:
|
||||
raise ValueError(f"Plataforma no soportada: {platform}")
|
||||
|
||||
return publisher
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BasePublisher",
|
||||
"XPublisher",
|
||||
"ThreadsPublisher",
|
||||
"InstagramPublisher",
|
||||
"FacebookPublisher",
|
||||
"get_publisher"
|
||||
]
|
||||
74
app/publishers/base.py
Normal file
74
app/publishers/base.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Clase base para publishers de redes sociales.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, List, Dict
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublishResult:
|
||||
"""Resultado de una publicación."""
|
||||
success: bool
|
||||
post_id: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class BasePublisher(ABC):
|
||||
"""Clase base abstracta para publishers."""
|
||||
|
||||
platform: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
async def publish(
|
||||
self,
|
||||
content: str,
|
||||
image_path: Optional[str] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar contenido en la plataforma."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def publish_thread(
|
||||
self,
|
||||
posts: List[str],
|
||||
images: Optional[List[str]] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar un hilo de posts."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def reply(
|
||||
self,
|
||||
post_id: str,
|
||||
content: str
|
||||
) -> PublishResult:
|
||||
"""Responder a un post."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def like(self, post_id: str) -> bool:
|
||||
"""Dar like a un post."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Obtener menciones recientes."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||
"""Obtener comentarios de un post."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, post_id: str) -> bool:
|
||||
"""Eliminar un post."""
|
||||
pass
|
||||
|
||||
def validate_content(self, content: str) -> bool:
|
||||
"""Validar que el contenido cumple con los límites de la plataforma."""
|
||||
# Implementar en subclases según límites específicos
|
||||
return True
|
||||
252
app/publishers/facebook_publisher.py
Normal file
252
app/publishers/facebook_publisher.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Publisher para Facebook Pages (Meta Graph API).
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.publishers.base import BasePublisher, PublishResult
|
||||
|
||||
|
||||
class FacebookPublisher(BasePublisher):
|
||||
"""Publisher para Facebook Pages usando Meta Graph API."""
|
||||
|
||||
platform = "facebook"
|
||||
char_limit = 63206 # Límite real de Facebook
|
||||
base_url = "https://graph.facebook.com/v18.0"
|
||||
|
||||
def __init__(self):
|
||||
self.access_token = settings.META_ACCESS_TOKEN
|
||||
self.page_id = settings.FACEBOOK_PAGE_ID
|
||||
|
||||
def validate_content(self, content: str) -> bool:
|
||||
"""Validar longitud del post."""
|
||||
return len(content) <= self.char_limit
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
content: str,
|
||||
image_path: Optional[str] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar en Facebook Page."""
|
||||
if not self.access_token or not self.page_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales de Facebook no configuradas"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
if image_path:
|
||||
# Publicar con imagen
|
||||
url = f"{self.base_url}/{self.page_id}/photos"
|
||||
payload = {
|
||||
"caption": content,
|
||||
"url": image_path, # URL pública de la imagen
|
||||
"access_token": self.access_token
|
||||
}
|
||||
else:
|
||||
# Publicar solo texto
|
||||
url = f"{self.base_url}/{self.page_id}/feed"
|
||||
payload = {
|
||||
"message": content,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
post_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=post_id,
|
||||
url=f"https://www.facebook.com/{post_id}"
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def publish_thread(
|
||||
self,
|
||||
posts: List[str],
|
||||
images: Optional[List[str]] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar como un solo post largo en Facebook."""
|
||||
# Facebook no tiene threads, concatenamos el contenido
|
||||
combined_content = "\n\n".join(posts)
|
||||
|
||||
# Usar la primera imagen si existe
|
||||
image = images[0] if images else None
|
||||
|
||||
return await self.publish(combined_content, image)
|
||||
|
||||
async def reply(
|
||||
self,
|
||||
post_id: str,
|
||||
content: str
|
||||
) -> PublishResult:
|
||||
"""Responder a un comentario en Facebook."""
|
||||
if not self.access_token:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Token de acceso no configurado"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}/comments"
|
||||
payload = {
|
||||
"message": content,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
comment_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=comment_id
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def like(self, post_id: str) -> bool:
|
||||
"""Dar like a un post/comentario."""
|
||||
if not self.access_token:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}/likes"
|
||||
payload = {"access_token": self.access_token}
|
||||
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
return response.json().get("success", False)
|
||||
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Obtener menciones de la página."""
|
||||
if not self.access_token or not self.page_id:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{self.page_id}/tagged"
|
||||
params = {
|
||||
"fields": "id,message,from,created_time,permalink_url",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("data", [])
|
||||
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||
"""Obtener comentarios de un post."""
|
||||
if not self.access_token:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}/comments"
|
||||
params = {
|
||||
"fields": "id,message,from,created_time,like_count",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("data", [])
|
||||
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
async def get_page_messages(self) -> List[Dict]:
|
||||
"""Obtener mensajes de la página (inbox)."""
|
||||
if not self.access_token or not self.page_id:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{self.page_id}/conversations"
|
||||
params = {
|
||||
"fields": "id,participants,messages{message,from,created_time}",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("data", [])
|
||||
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
async def send_message(self, recipient_id: str, message: str) -> PublishResult:
|
||||
"""Enviar mensaje directo a un usuario."""
|
||||
if not self.access_token or not self.page_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales no configuradas"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{self.page_id}/messages"
|
||||
payload = {
|
||||
"recipient": {"id": recipient_id},
|
||||
"message": {"text": message},
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
message_id = response.json().get("message_id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=message_id
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def delete(self, post_id: str) -> bool:
|
||||
"""Eliminar un post."""
|
||||
if not self.access_token:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}"
|
||||
params = {"access_token": self.access_token}
|
||||
|
||||
response = await client.delete(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json().get("success", False)
|
||||
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
240
app/publishers/instagram_publisher.py
Normal file
240
app/publishers/instagram_publisher.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Publisher para Instagram (Meta Graph API).
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.publishers.base import BasePublisher, PublishResult
|
||||
|
||||
|
||||
class InstagramPublisher(BasePublisher):
|
||||
"""Publisher para Instagram usando Meta Graph API."""
|
||||
|
||||
platform = "instagram"
|
||||
char_limit = 2200
|
||||
base_url = "https://graph.facebook.com/v18.0"
|
||||
|
||||
def __init__(self):
|
||||
self.access_token = settings.META_ACCESS_TOKEN
|
||||
self.account_id = settings.INSTAGRAM_ACCOUNT_ID
|
||||
|
||||
def validate_content(self, content: str) -> bool:
|
||||
"""Validar longitud del caption."""
|
||||
return len(content) <= self.char_limit
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
content: str,
|
||||
image_path: Optional[str] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar en Instagram (requiere imagen)."""
|
||||
if not self.access_token or not self.account_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales de Instagram no configuradas"
|
||||
)
|
||||
|
||||
if not image_path:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Instagram requiere una imagen para publicar"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Paso 1: Crear contenedor de media
|
||||
# Nota: La imagen debe estar en una URL pública
|
||||
create_url = f"{self.base_url}/{self.account_id}/media"
|
||||
|
||||
payload = {
|
||||
"caption": content,
|
||||
"image_url": image_path, # Debe ser URL pública
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(create_url, data=payload)
|
||||
response.raise_for_status()
|
||||
container_id = response.json().get("id")
|
||||
|
||||
# Paso 2: Publicar el contenedor
|
||||
publish_url = f"{self.base_url}/{self.account_id}/media_publish"
|
||||
publish_payload = {
|
||||
"creation_id": container_id,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(publish_url, data=publish_payload)
|
||||
response.raise_for_status()
|
||||
post_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=post_id,
|
||||
url=f"https://www.instagram.com/p/{post_id}"
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def publish_thread(
|
||||
self,
|
||||
posts: List[str],
|
||||
images: Optional[List[str]] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar carrusel en Instagram."""
|
||||
if not self.access_token or not self.account_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales de Instagram no configuradas"
|
||||
)
|
||||
|
||||
if not images or len(images) < 2:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Un carrusel de Instagram requiere al menos 2 imágenes"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Paso 1: Crear contenedores para cada imagen
|
||||
children_ids = []
|
||||
|
||||
for image_url in images[:10]: # Máximo 10 imágenes
|
||||
create_url = f"{self.base_url}/{self.account_id}/media"
|
||||
payload = {
|
||||
"image_url": image_url,
|
||||
"is_carousel_item": True,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(create_url, data=payload)
|
||||
response.raise_for_status()
|
||||
children_ids.append(response.json().get("id"))
|
||||
|
||||
# Paso 2: Crear contenedor del carrusel
|
||||
carousel_url = f"{self.base_url}/{self.account_id}/media"
|
||||
carousel_payload = {
|
||||
"media_type": "CAROUSEL",
|
||||
"caption": posts[0] if posts else "",
|
||||
"children": ",".join(children_ids),
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(carousel_url, data=carousel_payload)
|
||||
response.raise_for_status()
|
||||
carousel_id = response.json().get("id")
|
||||
|
||||
# Paso 3: Publicar
|
||||
publish_url = f"{self.base_url}/{self.account_id}/media_publish"
|
||||
publish_payload = {
|
||||
"creation_id": carousel_id,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(publish_url, data=publish_payload)
|
||||
response.raise_for_status()
|
||||
post_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=post_id
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def reply(
|
||||
self,
|
||||
post_id: str,
|
||||
content: str
|
||||
) -> PublishResult:
|
||||
"""Responder a un comentario en Instagram."""
|
||||
if not self.access_token:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Token de acceso no configurado"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}/replies"
|
||||
payload = {
|
||||
"message": content,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
reply_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=reply_id
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def like(self, post_id: str) -> bool:
|
||||
"""Dar like (no disponible vía API para cuentas de negocio)."""
|
||||
return False
|
||||
|
||||
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Obtener menciones en comentarios e historias."""
|
||||
if not self.access_token or not self.account_id:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{self.account_id}/tags"
|
||||
params = {
|
||||
"fields": "id,caption,media_type,permalink,timestamp,username",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("data", [])
|
||||
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||
"""Obtener comentarios de un post."""
|
||||
if not self.access_token:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}/comments"
|
||||
params = {
|
||||
"fields": "id,text,username,timestamp,like_count",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("data", [])
|
||||
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
async def delete(self, post_id: str) -> bool:
|
||||
"""Eliminar un post (no disponible vía API)."""
|
||||
# La API de Instagram no permite eliminar posts
|
||||
return False
|
||||
227
app/publishers/threads_publisher.py
Normal file
227
app/publishers/threads_publisher.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Publisher para Threads (Meta).
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.publishers.base import BasePublisher, PublishResult
|
||||
|
||||
|
||||
class ThreadsPublisher(BasePublisher):
|
||||
"""Publisher para Threads usando Meta Graph API."""
|
||||
|
||||
platform = "threads"
|
||||
char_limit = 500
|
||||
base_url = "https://graph.threads.net/v1.0"
|
||||
|
||||
def __init__(self):
|
||||
self.access_token = settings.META_ACCESS_TOKEN
|
||||
self.user_id = settings.THREADS_USER_ID
|
||||
|
||||
def validate_content(self, content: str) -> bool:
|
||||
"""Validar longitud del post."""
|
||||
return len(content) <= self.char_limit
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
content: str,
|
||||
image_path: Optional[str] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar en Threads."""
|
||||
if not self.access_token or not self.user_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales de Threads no configuradas"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Paso 1: Crear el contenedor del post
|
||||
create_url = f"{self.base_url}/{self.user_id}/threads"
|
||||
|
||||
payload = {
|
||||
"text": content,
|
||||
"media_type": "TEXT",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
# Si hay imagen, subirla primero
|
||||
if image_path:
|
||||
# TODO: Implementar subida de imagen a Threads
|
||||
payload["media_type"] = "IMAGE"
|
||||
# payload["image_url"] = uploaded_image_url
|
||||
|
||||
response = await client.post(create_url, data=payload)
|
||||
response.raise_for_status()
|
||||
container_id = response.json().get("id")
|
||||
|
||||
# Paso 2: Publicar el contenedor
|
||||
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
|
||||
publish_payload = {
|
||||
"creation_id": container_id,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(publish_url, data=publish_payload)
|
||||
response.raise_for_status()
|
||||
post_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=post_id,
|
||||
url=f"https://www.threads.net/post/{post_id}"
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def publish_thread(
|
||||
self,
|
||||
posts: List[str],
|
||||
images: Optional[List[str]] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar un hilo en Threads."""
|
||||
if not self.access_token or not self.user_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales de Threads no configuradas"
|
||||
)
|
||||
|
||||
try:
|
||||
first_post_id = None
|
||||
reply_to_id = None
|
||||
|
||||
for i, post in enumerate(posts):
|
||||
async with httpx.AsyncClient() as client:
|
||||
create_url = f"{self.base_url}/{self.user_id}/threads"
|
||||
|
||||
payload = {
|
||||
"text": post,
|
||||
"media_type": "TEXT",
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
if reply_to_id:
|
||||
payload["reply_to_id"] = reply_to_id
|
||||
|
||||
response = await client.post(create_url, data=payload)
|
||||
response.raise_for_status()
|
||||
container_id = response.json().get("id")
|
||||
|
||||
# Publicar
|
||||
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
|
||||
publish_payload = {
|
||||
"creation_id": container_id,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(publish_url, data=publish_payload)
|
||||
response.raise_for_status()
|
||||
post_id = response.json().get("id")
|
||||
|
||||
if i == 0:
|
||||
first_post_id = post_id
|
||||
reply_to_id = post_id
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=first_post_id,
|
||||
url=f"https://www.threads.net/post/{first_post_id}"
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def reply(
|
||||
self,
|
||||
post_id: str,
|
||||
content: str
|
||||
) -> PublishResult:
|
||||
"""Responder a un post en Threads."""
|
||||
if not self.access_token or not self.user_id:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Credenciales de Threads no configuradas"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
create_url = f"{self.base_url}/{self.user_id}/threads"
|
||||
|
||||
payload = {
|
||||
"text": content,
|
||||
"media_type": "TEXT",
|
||||
"reply_to_id": post_id,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(create_url, data=payload)
|
||||
response.raise_for_status()
|
||||
container_id = response.json().get("id")
|
||||
|
||||
# Publicar
|
||||
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
|
||||
publish_payload = {
|
||||
"creation_id": container_id,
|
||||
"access_token": self.access_token
|
||||
}
|
||||
|
||||
response = await client.post(publish_url, data=publish_payload)
|
||||
response.raise_for_status()
|
||||
reply_id = response.json().get("id")
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=reply_id
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def like(self, post_id: str) -> bool:
|
||||
"""Dar like a un post (no soportado actualmente por la API)."""
|
||||
# La API de Threads no soporta likes programáticos actualmente
|
||||
return False
|
||||
|
||||
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Obtener menciones (limitado en la API de Threads)."""
|
||||
# TODO: Implementar cuando la API lo soporte
|
||||
return []
|
||||
|
||||
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||
"""Obtener respuestas a un post."""
|
||||
if not self.access_token:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.base_url}/{post_id}/replies"
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"fields": "id,text,username,timestamp"
|
||||
}
|
||||
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get("data", [])
|
||||
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
async def delete(self, post_id: str) -> bool:
|
||||
"""Eliminar un post de Threads (no soportado actualmente)."""
|
||||
# La API de Threads no soporta eliminación actualmente
|
||||
return False
|
||||
255
app/publishers/x_publisher.py
Normal file
255
app/publishers/x_publisher.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Publisher para X (Twitter).
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict
|
||||
import tweepy
|
||||
|
||||
from app.core.config import settings
|
||||
from app.publishers.base import BasePublisher, PublishResult
|
||||
|
||||
|
||||
class XPublisher(BasePublisher):
|
||||
"""Publisher para X (Twitter) usando Tweepy."""
|
||||
|
||||
platform = "x"
|
||||
char_limit = 280
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.api = None
|
||||
self._init_client()
|
||||
|
||||
def _init_client(self):
|
||||
"""Inicializar cliente de Twitter."""
|
||||
if not all([
|
||||
settings.X_API_KEY,
|
||||
settings.X_API_SECRET,
|
||||
settings.X_ACCESS_TOKEN,
|
||||
settings.X_ACCESS_TOKEN_SECRET
|
||||
]):
|
||||
return
|
||||
|
||||
# Cliente v2 para publicar
|
||||
self.client = tweepy.Client(
|
||||
consumer_key=settings.X_API_KEY,
|
||||
consumer_secret=settings.X_API_SECRET,
|
||||
access_token=settings.X_ACCESS_TOKEN,
|
||||
access_token_secret=settings.X_ACCESS_TOKEN_SECRET,
|
||||
bearer_token=settings.X_BEARER_TOKEN
|
||||
)
|
||||
|
||||
# API v1.1 para subir imágenes
|
||||
auth = tweepy.OAuth1UserHandler(
|
||||
settings.X_API_KEY,
|
||||
settings.X_API_SECRET,
|
||||
settings.X_ACCESS_TOKEN,
|
||||
settings.X_ACCESS_TOKEN_SECRET
|
||||
)
|
||||
self.api = tweepy.API(auth)
|
||||
|
||||
def validate_content(self, content: str) -> bool:
|
||||
"""Validar longitud del tweet."""
|
||||
return len(content) <= self.char_limit
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
content: str,
|
||||
image_path: Optional[str] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar un tweet."""
|
||||
if not self.client:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Cliente de X no configurado"
|
||||
)
|
||||
|
||||
try:
|
||||
media_ids = None
|
||||
|
||||
# Subir imagen si existe
|
||||
if image_path and self.api:
|
||||
media = self.api.media_upload(filename=image_path)
|
||||
media_ids = [media.media_id]
|
||||
|
||||
# Publicar tweet
|
||||
response = self.client.create_tweet(
|
||||
text=content,
|
||||
media_ids=media_ids
|
||||
)
|
||||
|
||||
tweet_id = response.data['id']
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=tweet_id,
|
||||
url=f"https://x.com/i/web/status/{tweet_id}"
|
||||
)
|
||||
|
||||
except tweepy.TweepyException as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def publish_thread(
|
||||
self,
|
||||
posts: List[str],
|
||||
images: Optional[List[str]] = None
|
||||
) -> PublishResult:
|
||||
"""Publicar un hilo de tweets."""
|
||||
if not self.client:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Cliente de X no configurado"
|
||||
)
|
||||
|
||||
try:
|
||||
previous_tweet_id = None
|
||||
first_tweet_id = None
|
||||
|
||||
for i, post in enumerate(posts):
|
||||
media_ids = None
|
||||
|
||||
# Subir imagen si existe para este post
|
||||
if images and i < len(images) and images[i] and self.api:
|
||||
media = self.api.media_upload(filename=images[i])
|
||||
media_ids = [media.media_id]
|
||||
|
||||
# Publicar tweet (como respuesta al anterior si existe)
|
||||
response = self.client.create_tweet(
|
||||
text=post,
|
||||
media_ids=media_ids,
|
||||
in_reply_to_tweet_id=previous_tweet_id
|
||||
)
|
||||
|
||||
previous_tweet_id = response.data['id']
|
||||
|
||||
if i == 0:
|
||||
first_tweet_id = previous_tweet_id
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=first_tweet_id,
|
||||
url=f"https://x.com/i/web/status/{first_tweet_id}"
|
||||
)
|
||||
|
||||
except tweepy.TweepyException as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def reply(
|
||||
self,
|
||||
post_id: str,
|
||||
content: str
|
||||
) -> PublishResult:
|
||||
"""Responder a un tweet."""
|
||||
if not self.client:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message="Cliente de X no configurado"
|
||||
)
|
||||
|
||||
try:
|
||||
response = self.client.create_tweet(
|
||||
text=content,
|
||||
in_reply_to_tweet_id=post_id
|
||||
)
|
||||
|
||||
return PublishResult(
|
||||
success=True,
|
||||
post_id=response.data['id']
|
||||
)
|
||||
|
||||
except tweepy.TweepyException as e:
|
||||
return PublishResult(
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def like(self, post_id: str) -> bool:
|
||||
"""Dar like a un tweet."""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.client.like(post_id)
|
||||
return True
|
||||
except tweepy.TweepyException:
|
||||
return False
|
||||
|
||||
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Obtener menciones recientes."""
|
||||
if not self.client:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Obtener ID del usuario autenticado
|
||||
me = self.client.get_me()
|
||||
user_id = me.data.id
|
||||
|
||||
# Obtener menciones
|
||||
mentions = self.client.get_users_mentions(
|
||||
id=user_id,
|
||||
since_id=since_id,
|
||||
max_results=50,
|
||||
tweet_fields=['created_at', 'author_id', 'conversation_id']
|
||||
)
|
||||
|
||||
if not mentions.data:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(tweet.id),
|
||||
"text": tweet.text,
|
||||
"author_id": str(tweet.author_id),
|
||||
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
|
||||
}
|
||||
for tweet in mentions.data
|
||||
]
|
||||
|
||||
except tweepy.TweepyException:
|
||||
return []
|
||||
|
||||
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||
"""Obtener respuestas a un tweet."""
|
||||
if not self.client:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Buscar respuestas al tweet
|
||||
query = f"conversation_id:{post_id}"
|
||||
tweets = self.client.search_recent_tweets(
|
||||
query=query,
|
||||
max_results=50,
|
||||
tweet_fields=['created_at', 'author_id', 'in_reply_to_user_id']
|
||||
)
|
||||
|
||||
if not tweets.data:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(tweet.id),
|
||||
"text": tweet.text,
|
||||
"author_id": str(tweet.author_id),
|
||||
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
|
||||
}
|
||||
for tweet in tweets.data
|
||||
]
|
||||
|
||||
except tweepy.TweepyException:
|
||||
return []
|
||||
|
||||
async def delete(self, post_id: str) -> bool:
|
||||
"""Eliminar un tweet."""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.client.delete_tweet(post_id)
|
||||
return True
|
||||
except tweepy.TweepyException:
|
||||
return False
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services module
|
||||
314
app/services/content_generator.py
Normal file
314
app/services/content_generator.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Servicio de generación de contenido con DeepSeek API.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, List, Dict
|
||||
from openai import OpenAI
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
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.model = "deepseek-chat"
|
||||
|
||||
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}.
|
||||
|
||||
SOBRE LA EMPRESA:
|
||||
- Especializada en soluciones de IA, automatización y transformación digital
|
||||
- Vende equipos de cómputo e impresoras 3D
|
||||
- Sitio web: {settings.BUSINESS_WEBSITE}
|
||||
|
||||
TONO DE COMUNICACIÓN:
|
||||
{settings.CONTENT_TONE}
|
||||
|
||||
ESTILO (inspirado en @midudev, @MoureDev, @SoyDalto):
|
||||
- Tips cortos y accionables
|
||||
- Contenido educativo de valor
|
||||
- Cercano pero profesional
|
||||
- Uso moderado de emojis
|
||||
- Hashtags relevantes (máximo 3-5)
|
||||
|
||||
REGLAS:
|
||||
- Nunca uses lenguaje ofensivo
|
||||
- No hagas promesas exageradas
|
||||
- Sé honesto y transparente
|
||||
- Enfócate en ayudar, no en vender directamente
|
||||
- Adapta el contenido a cada plataforma"""
|
||||
|
||||
async def generate_tip_tech(
|
||||
self,
|
||||
category: str,
|
||||
platform: str,
|
||||
template: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generar un tip tech."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 500
|
||||
}
|
||||
|
||||
prompt = f"""Genera un tip de tecnología para la categoría: {category}
|
||||
|
||||
PLATAFORMA: {platform}
|
||||
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||
|
||||
{f'USA ESTE TEMPLATE COMO BASE: {template}' if template else ''}
|
||||
|
||||
REQUISITOS:
|
||||
- Tip práctico y accionable
|
||||
- Fácil de entender
|
||||
- Incluye un emoji relevante al inicio
|
||||
- Termina con 2-3 hashtags relevantes
|
||||
- NO incluyas enlaces
|
||||
|
||||
Responde SOLO con el texto del post, sin explicaciones."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=300,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
async def generate_product_post(
|
||||
self,
|
||||
product: Dict,
|
||||
platform: str
|
||||
) -> str:
|
||||
"""Generar post para un producto."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 1000
|
||||
}
|
||||
|
||||
prompt = f"""Genera un post promocional para este producto:
|
||||
|
||||
PRODUCTO: {product['name']}
|
||||
DESCRIPCIÓN: {product.get('description', 'N/A')}
|
||||
PRECIO: ${product['price']:,.2f} MXN
|
||||
CATEGORÍA: {product['category']}
|
||||
ESPECIFICACIONES: {json.dumps(product.get('specs', {}), ensure_ascii=False)}
|
||||
PUNTOS DESTACADOS: {', '.join(product.get('highlights', []))}
|
||||
|
||||
PLATAFORMA: {platform}
|
||||
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||
|
||||
REQUISITOS:
|
||||
- Destaca los beneficios principales
|
||||
- Incluye el precio
|
||||
- Usa emojis relevantes
|
||||
- Incluye CTA sutil (ej: "Contáctanos", "Más info en DM")
|
||||
- Termina con 2-3 hashtags
|
||||
- NO inventes especificaciones
|
||||
|
||||
Responde SOLO con el texto del post."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=400,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
async def generate_service_post(
|
||||
self,
|
||||
service: Dict,
|
||||
platform: str
|
||||
) -> str:
|
||||
"""Generar post para un servicio."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 1000
|
||||
}
|
||||
|
||||
prompt = f"""Genera un post promocional para este servicio:
|
||||
|
||||
SERVICIO: {service['name']}
|
||||
DESCRIPCIÓN: {service.get('description', 'N/A')}
|
||||
CATEGORÍA: {service['category']}
|
||||
SECTORES OBJETIVO: {', '.join(service.get('target_sectors', []))}
|
||||
BENEFICIOS: {', '.join(service.get('benefits', []))}
|
||||
CTA: {service.get('call_to_action', 'Contáctanos para más información')}
|
||||
|
||||
PLATAFORMA: {platform}
|
||||
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||
|
||||
REQUISITOS:
|
||||
- Enfócate en el problema que resuelve
|
||||
- Destaca 2-3 beneficios clave
|
||||
- Usa emojis relevantes (✅, 🚀, 💡)
|
||||
- Incluye el CTA
|
||||
- Termina con 2-3 hashtags
|
||||
- Tono consultivo, no vendedor
|
||||
|
||||
Responde SOLO con el texto del post."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=400,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
async def generate_thread(
|
||||
self,
|
||||
topic: str,
|
||||
num_posts: int = 5
|
||||
) -> List[str]:
|
||||
"""Generar un hilo educativo."""
|
||||
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
|
||||
|
||||
REQUISITOS:
|
||||
- Post 1: Gancho que capture atención
|
||||
- Posts 2-{num_posts-1}: Contenido educativo de valor
|
||||
- Post {num_posts}: Conclusión con CTA
|
||||
|
||||
FORMATO:
|
||||
- Cada post máximo 280 caracteres
|
||||
- Numera cada post (1/, 2/, etc.)
|
||||
- Usa emojis relevantes
|
||||
- El último post incluye hashtags
|
||||
|
||||
Responde con cada post en una línea separada, sin explicaciones."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=1500,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content.strip()
|
||||
# Separar posts por líneas no vacías
|
||||
posts = [p.strip() for p in content.split('\n') if p.strip()]
|
||||
|
||||
return posts
|
||||
|
||||
async def generate_response_suggestion(
|
||||
self,
|
||||
interaction_content: str,
|
||||
interaction_type: str,
|
||||
context: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""Generar sugerencias de respuesta para una interacción."""
|
||||
prompt = f"""Un usuario escribió esto en redes sociales:
|
||||
|
||||
"{interaction_content}"
|
||||
|
||||
TIPO DE INTERACCIÓN: {interaction_type}
|
||||
{f'CONTEXTO ADICIONAL: {context}' if context else ''}
|
||||
|
||||
Genera 3 opciones de respuesta diferentes:
|
||||
1. Respuesta corta y amigable
|
||||
2. Respuesta que invite a continuar la conversación
|
||||
3. Respuesta que dirija a más información/contacto
|
||||
|
||||
REQUISITOS:
|
||||
- Máximo 280 caracteres cada una
|
||||
- Tono amigable y profesional
|
||||
- Si es una queja, sé empático
|
||||
- Si es una pregunta técnica, sé útil
|
||||
|
||||
Responde con las 3 opciones numeradas, una por línea."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=500,
|
||||
temperature=0.8
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content.strip()
|
||||
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
|
||||
|
||||
# Limpiar numeración si existe
|
||||
cleaned = []
|
||||
for s in suggestions:
|
||||
if s[0].isdigit() and (s[1] == '.' or s[1] == ')'):
|
||||
s = s[2:].strip()
|
||||
cleaned.append(s)
|
||||
|
||||
return cleaned[:3] # Máximo 3 sugerencias
|
||||
|
||||
async def adapt_content_for_platform(
|
||||
self,
|
||||
content: str,
|
||||
target_platform: str
|
||||
) -> str:
|
||||
"""Adaptar contenido existente a una plataforma específica."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 1000
|
||||
}
|
||||
|
||||
prompt = f"""Adapta este contenido para {target_platform}:
|
||||
|
||||
CONTENIDO ORIGINAL:
|
||||
{content}
|
||||
|
||||
LÍMITE DE CARACTERES: {char_limits.get(target_platform, 500)}
|
||||
|
||||
REQUISITOS PARA {target_platform.upper()}:
|
||||
{"- Muy conciso, directo al punto" if target_platform == "x" else ""}
|
||||
{"- Puede ser más extenso, incluir más contexto" if target_platform == "instagram" else ""}
|
||||
{"- Tono más casual y cercano" if target_platform == "threads" else ""}
|
||||
{"- Puede incluir links, más profesional" if target_platform == "facebook" else ""}
|
||||
- Mantén la esencia del mensaje
|
||||
- Ajusta hashtags según la plataforma
|
||||
|
||||
Responde SOLO con el contenido adaptado."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=400,
|
||||
temperature=0.6
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
# Instancia global
|
||||
content_generator = ContentGenerator()
|
||||
174
app/services/image_generator.py
Normal file
174
app/services/image_generator.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Servicio de generación de imágenes para posts.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
from pathlib import Path
|
||||
from html2image import Html2Image
|
||||
from PIL import Image
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ImageGenerator:
|
||||
"""Generador de imágenes usando plantillas HTML."""
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = Path("templates")
|
||||
self.output_dir = Path("uploads/generated")
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.templates_dir)
|
||||
)
|
||||
|
||||
self.hti = Html2Image(
|
||||
output_path=str(self.output_dir),
|
||||
custom_flags=['--no-sandbox', '--disable-gpu']
|
||||
)
|
||||
|
||||
def _render_template(self, template_name: str, variables: Dict) -> str:
|
||||
"""Renderizar una plantilla HTML con variables."""
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
return template.render(**variables)
|
||||
|
||||
async def generate_tip_card(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
category: str,
|
||||
output_name: str
|
||||
) -> str:
|
||||
"""Generar imagen de tip tech."""
|
||||
variables = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||
"website": settings.BUSINESS_WEBSITE,
|
||||
"business_name": settings.BUSINESS_NAME
|
||||
}
|
||||
|
||||
html_content = self._render_template("tip_card.html", variables)
|
||||
|
||||
# Generar imagen
|
||||
output_file = f"{output_name}.png"
|
||||
self.hti.screenshot(
|
||||
html_str=html_content,
|
||||
save_as=output_file,
|
||||
size=(1080, 1080)
|
||||
)
|
||||
|
||||
return str(self.output_dir / output_file)
|
||||
|
||||
async def generate_product_card(
|
||||
self,
|
||||
name: str,
|
||||
price: float,
|
||||
image_url: str,
|
||||
highlights: list,
|
||||
output_name: str
|
||||
) -> str:
|
||||
"""Generar imagen de producto."""
|
||||
variables = {
|
||||
"name": name,
|
||||
"price": f"${price:,.2f} MXN",
|
||||
"image_url": image_url,
|
||||
"highlights": highlights[:3], # Máximo 3 highlights
|
||||
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||
"website": settings.BUSINESS_WEBSITE,
|
||||
"business_name": settings.BUSINESS_NAME
|
||||
}
|
||||
|
||||
html_content = self._render_template("product_card.html", variables)
|
||||
|
||||
output_file = f"{output_name}.png"
|
||||
self.hti.screenshot(
|
||||
html_str=html_content,
|
||||
save_as=output_file,
|
||||
size=(1080, 1080)
|
||||
)
|
||||
|
||||
return str(self.output_dir / output_file)
|
||||
|
||||
async def generate_service_card(
|
||||
self,
|
||||
name: str,
|
||||
tagline: str,
|
||||
benefits: list,
|
||||
icon: str,
|
||||
output_name: str
|
||||
) -> str:
|
||||
"""Generar imagen de servicio."""
|
||||
variables = {
|
||||
"name": name,
|
||||
"tagline": tagline,
|
||||
"benefits": benefits[:4], # Máximo 4 beneficios
|
||||
"icon": icon,
|
||||
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||
"website": settings.BUSINESS_WEBSITE,
|
||||
"business_name": settings.BUSINESS_NAME
|
||||
}
|
||||
|
||||
html_content = self._render_template("service_card.html", variables)
|
||||
|
||||
output_file = f"{output_name}.png"
|
||||
self.hti.screenshot(
|
||||
html_str=html_content,
|
||||
save_as=output_file,
|
||||
size=(1080, 1080)
|
||||
)
|
||||
|
||||
return str(self.output_dir / output_file)
|
||||
|
||||
async def resize_for_platform(
|
||||
self,
|
||||
image_path: str,
|
||||
platform: str
|
||||
) -> str:
|
||||
"""Redimensionar imagen para una plataforma específica."""
|
||||
sizes = {
|
||||
"x": (1200, 675), # 16:9
|
||||
"threads": (1080, 1080), # 1:1
|
||||
"instagram": (1080, 1080), # 1:1
|
||||
"facebook": (1200, 630) # ~1.9:1
|
||||
}
|
||||
|
||||
target_size = sizes.get(platform, (1080, 1080))
|
||||
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Crear nueva imagen con el tamaño objetivo
|
||||
new_img = Image.new('RGB', target_size, (26, 26, 46)) # Color de fondo de la marca
|
||||
|
||||
# Calcular posición para centrar
|
||||
img_ratio = img.width / img.height
|
||||
target_ratio = target_size[0] / target_size[1]
|
||||
|
||||
if img_ratio > target_ratio:
|
||||
# Imagen más ancha
|
||||
new_width = target_size[0]
|
||||
new_height = int(new_width / img_ratio)
|
||||
else:
|
||||
# Imagen más alta
|
||||
new_height = target_size[1]
|
||||
new_width = int(new_height * img_ratio)
|
||||
|
||||
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Centrar en la nueva imagen
|
||||
x = (target_size[0] - new_width) // 2
|
||||
y = (target_size[1] - new_height) // 2
|
||||
new_img.paste(img_resized, (x, y))
|
||||
|
||||
# Guardar
|
||||
output_path = image_path.replace('.png', f'_{platform}.png')
|
||||
new_img.save(output_path, 'PNG', quality=95)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
# Instancia global
|
||||
image_generator = ImageGenerator()
|
||||
181
dashboard/templates/index.html
Normal file
181
dashboard/templates/index.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - Social Media Automation</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a2e; color: #eee; }
|
||||
.card { background-color: #16213e; border-radius: 12px; }
|
||||
.accent { color: #d4a574; }
|
||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||
.btn-primary:hover { background-color: #c49564; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="accent">Consultoría AS</span> - Social Media
|
||||
</h1>
|
||||
<nav class="flex gap-4">
|
||||
<a href="/" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||
<a href="/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||
<a href="/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||
<a href="/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
||||
<a href="/products" class="px-4 py-2 rounded hover:bg-gray-800">Productos</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-8">
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold accent">{{ stats.posts_today }}</div>
|
||||
<div class="text-gray-400 text-sm">Posts Hoy</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold accent">{{ stats.posts_week }}</div>
|
||||
<div class="text-gray-400 text-sm">Posts Semana</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-500">{{ stats.pending_approval }}</div>
|
||||
<div class="text-gray-400 text-sm">Pendientes</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-500">{{ stats.scheduled }}</div>
|
||||
<div class="text-gray-400 text-sm">Programados</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-red-500">{{ stats.interactions_pending }}</div>
|
||||
<div class="text-gray-400 text-sm">Interacciones</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Pending Approval -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Pendientes de Aprobación</h2>
|
||||
{% if pending_posts %}
|
||||
{% for post in pending_posts %}
|
||||
<div class="border-b border-gray-700 py-4 last:border-0">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="bg-blue-900 text-blue-300 px-2 py-1 rounded text-xs">
|
||||
{{ post.content_type }}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">
|
||||
{{ post.scheduled_at }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-300 mb-3">{{ post.content[:200] }}...</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="approvePost({{ post.id }})"
|
||||
class="btn-primary px-3 py-1 rounded text-sm">
|
||||
Aprobar
|
||||
</button>
|
||||
<button onclick="rejectPost({{ post.id }})"
|
||||
class="bg-red-900 text-red-300 px-3 py-1 rounded text-sm">
|
||||
Rechazar
|
||||
</button>
|
||||
<button onclick="editPost({{ post.id }})"
|
||||
class="bg-gray-700 px-3 py-1 rounded text-sm">
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-gray-500">No hay posts pendientes</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Scheduled -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Próximas Publicaciones</h2>
|
||||
{% if scheduled_posts %}
|
||||
{% for post in scheduled_posts %}
|
||||
<div class="border-b border-gray-700 py-3 last:border-0 flex items-center gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-accent font-bold">{{ post.scheduled_at }}</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="text-xs bg-gray-700 px-2 py-1 rounded">
|
||||
{{ post.content_type }}
|
||||
</span>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
{{ post.content[:100] }}...
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
{% for platform in post.platforms %}
|
||||
<span class="text-xs bg-gray-800 px-2 py-1 rounded">
|
||||
{{ platform }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-gray-500">No hay posts programados</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Interactions -->
|
||||
<div class="card p-6 mt-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Interacciones Recientes</h2>
|
||||
{% if recent_interactions %}
|
||||
<div class="grid gap-4">
|
||||
{% for interaction in recent_interactions %}
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span class="font-semibold">@{{ interaction.author_username }}</span>
|
||||
<span class="text-gray-500 text-sm ml-2">{{ interaction.platform }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-xs">{{ interaction.interaction_at }}</span>
|
||||
</div>
|
||||
<p class="text-gray-300">{{ interaction.content }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="bg-accent text-gray-900 px-3 py-1 rounded text-sm">
|
||||
Responder
|
||||
</button>
|
||||
<button class="bg-gray-700 px-3 py-1 rounded text-sm">
|
||||
Marcar como Lead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No hay interacciones pendientes</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function approvePost(postId) {
|
||||
const response = await fetch(`/api/posts/${postId}/approve`, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPost(postId) {
|
||||
if (confirm('¿Seguro que quieres rechazar este post?')) {
|
||||
const response = await fetch(`/api/posts/${postId}/reject`, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function editPost(postId) {
|
||||
window.location.href = `/posts/${postId}/edit`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
122
data/tips_iniciales.json
Normal file
122
data/tips_iniciales.json
Normal file
@@ -0,0 +1,122 @@
|
||||
[
|
||||
{
|
||||
"category": "productividad",
|
||||
"title": "Atajos de teclado Windows",
|
||||
"template": "Win + V activa el historial del portapapeles en Windows. Puedes pegar cualquier cosa que hayas copiado antes.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "seguridad",
|
||||
"title": "Contraseñas seguras",
|
||||
"template": "Una contraseña segura tiene mínimo 12 caracteres, mayúsculas, minúsculas, números y símbolos. Mejor aún: usa un gestor de contraseñas.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "hardware",
|
||||
"title": "Limpiar tu PC",
|
||||
"template": "Limpia el polvo de tu PC cada 3-6 meses. El polvo acumulado aumenta la temperatura y reduce el rendimiento.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "software",
|
||||
"title": "Actualizaciones",
|
||||
"template": "Mantén tu sistema operativo actualizado. Las actualizaciones no solo traen funciones nuevas, también parches de seguridad críticos.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "productividad",
|
||||
"title": "Múltiples escritorios",
|
||||
"template": "Win + Tab te permite crear múltiples escritorios virtuales. Perfecto para separar trabajo personal de proyectos.",
|
||||
"difficulty": "intermedio"
|
||||
},
|
||||
{
|
||||
"category": "redes",
|
||||
"title": "Velocidad de internet",
|
||||
"template": "¿Internet lento? Prueba cambiar el DNS a 1.1.1.1 (Cloudflare) o 8.8.8.8 (Google). Puede mejorar la velocidad de navegación.",
|
||||
"difficulty": "intermedio"
|
||||
},
|
||||
{
|
||||
"category": "seguridad",
|
||||
"title": "Autenticación 2FA",
|
||||
"template": "Activa la autenticación de dos factores (2FA) en todas tus cuentas importantes. Es la mejor defensa contra hackeos.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "ia",
|
||||
"title": "Prompts efectivos",
|
||||
"template": "Para mejores resultados con IA, sé específico en tus prompts. En lugar de 'escribe un email', prueba 'escribe un email profesional de 3 párrafos solicitando una reunión'.",
|
||||
"difficulty": "intermedio"
|
||||
},
|
||||
{
|
||||
"category": "hardware",
|
||||
"title": "SSD vs HDD",
|
||||
"template": "Un SSD puede hacer que tu PC arranque en 10-15 segundos vs 1-2 minutos con HDD. Es la mejor inversión para PCs antiguas.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "productividad",
|
||||
"title": "Captura de pantalla",
|
||||
"template": "Win + Shift + S abre la herramienta de recorte. Puedes capturar una región, ventana o pantalla completa al instante.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "software",
|
||||
"title": "Programas de inicio",
|
||||
"template": "¿PC lenta al encender? Desactiva programas innecesarios en el inicio. Ctrl + Shift + Esc > Inicio > Desactivar los que no necesites.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "seguridad",
|
||||
"title": "Phishing",
|
||||
"template": "Antes de hacer clic en un enlace, pasa el mouse encima para ver la URL real. Si no coincide con el sitio esperado, es phishing.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "ia",
|
||||
"title": "ChatGPT para código",
|
||||
"template": "ChatGPT puede explicar código línea por línea. Copia el código y pide: 'Explica este código como si tuviera 5 años de experiencia'.",
|
||||
"difficulty": "intermedio"
|
||||
},
|
||||
{
|
||||
"category": "hardware",
|
||||
"title": "RAM suficiente",
|
||||
"template": "En 2024, 8GB de RAM es el mínimo. Para trabajo con múltiples apps o desarrollo, 16GB es lo recomendado. Para edición de video, 32GB.",
|
||||
"difficulty": "intermedio"
|
||||
},
|
||||
{
|
||||
"category": "redes",
|
||||
"title": "WiFi 5GHz",
|
||||
"template": "Si tu router tiene banda 5GHz, úsala para dispositivos cercanos. Es más rápida que 2.4GHz, aunque tiene menor alcance.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "productividad",
|
||||
"title": "Notion para notas",
|
||||
"template": "Notion es gratis para uso personal. Puedes organizar notas, tareas y proyectos en un solo lugar con templates listos para usar.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "seguridad",
|
||||
"title": "Backup 3-2-1",
|
||||
"template": "Regla 3-2-1 de backups: 3 copias de tus datos, 2 en medios diferentes, 1 fuera de tu ubicación (nube). Así nunca pierdes nada.",
|
||||
"difficulty": "intermedio"
|
||||
},
|
||||
{
|
||||
"category": "software",
|
||||
"title": "Navegador ligero",
|
||||
"template": "¿Chrome consume mucha RAM? Prueba Edge (sí, el de Microsoft) o Brave. Usan la misma base pero optimizan mejor los recursos.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "ia",
|
||||
"title": "IA para Excel",
|
||||
"template": "ChatGPT puede crear fórmulas de Excel. Describe lo que necesitas en español y pide la fórmula. Ahorra horas de búsqueda.",
|
||||
"difficulty": "basico"
|
||||
},
|
||||
{
|
||||
"category": "hardware",
|
||||
"title": "Monitor externo",
|
||||
"template": "Un segundo monitor aumenta la productividad hasta 42% según estudios. No necesita ser caro, cualquier monitor extra ayuda.",
|
||||
"difficulty": "basico"
|
||||
}
|
||||
]
|
||||
182
docker-compose.yml
Normal file
182
docker-compose.yml
Normal file
@@ -0,0 +1,182 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
# APLICACIÓN PRINCIPAL (FastAPI)
|
||||
# ===========================================
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: social-automation-app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./templates:/app/templates
|
||||
- ./data:/app/data
|
||||
- uploaded_images:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# CELERY WORKER (Procesamiento de tareas)
|
||||
# ===========================================
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: social-automation-worker
|
||||
command: celery -A worker.celery_app worker --loglevel=info --concurrency=2
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./worker:/app/worker
|
||||
- ./templates:/app/templates
|
||||
- uploaded_images:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# CELERY BEAT (Programador de tareas)
|
||||
# ===========================================
|
||||
beat:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: social-automation-beat
|
||||
command: celery -A worker.celery_app beat --loglevel=info
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./worker:/app/worker
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# FLOWER (Monitor de Celery)
|
||||
# ===========================================
|
||||
flower:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: social-automation-flower
|
||||
command: celery -A worker.celery_app flower --port=5555
|
||||
ports:
|
||||
- "5555:5555"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- redis
|
||||
- worker
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# POSTGRESQL (Base de datos)
|
||||
# ===========================================
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: social-automation-db
|
||||
environment:
|
||||
- POSTGRES_USER=social_user
|
||||
- POSTGRES_PASSWORD=social_pass
|
||||
- POSTGRES_DB=social_automation
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U social_user -d social_automation"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# REDIS (Cola de mensajes)
|
||||
# ===========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: social-automation-redis
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# NGINX (Reverse Proxy)
|
||||
# ===========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: social-automation-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./nginx/ssl:/etc/nginx/ssl
|
||||
- ./dashboard/static:/usr/share/nginx/html/static
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- social-network
|
||||
|
||||
# ===========================================
|
||||
# VOLÚMENES
|
||||
# ===========================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
uploaded_images:
|
||||
driver: local
|
||||
|
||||
# ===========================================
|
||||
# REDES
|
||||
# ===========================================
|
||||
networks:
|
||||
social-network:
|
||||
driver: bridge
|
||||
231
docs/plans/2025-01-28-social-media-automation-design.md
Normal file
231
docs/plans/2025-01-28-social-media-automation-design.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Sistema de Automatización de Redes Sociales - Consultoría AS
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook) para Consultoría AS, empresa de tecnología en Tijuana especializada en soluciones de IA, automatización y venta de equipos de cómputo e impresión 3D.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Automatizar la generación de contenido educativo (tips tech, datos curiosos)
|
||||
- Promocionar productos del catálogo (equipos de cómputo, impresoras 3D)
|
||||
- Destacar servicios de consultoría (IA, chatbots, sistemas a medida)
|
||||
- Mantener presencia constante en redes con contenido de valor
|
||||
- Gestionar interacciones con la audiencia desde un dashboard centralizado
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura General
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SOCIAL MEDIA AUTOMATION │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ FUENTES │───▶│ GENERADOR │───▶│ PUBLICADOR │ │
|
||||
│ │ DE DATOS │ │ DE CONTENIDO│ │ MULTI-PLAT │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ • Catálogo productos │ DeepSeek API │ • X API │
|
||||
│ • Tips tech (DB) │ │ • Threads │
|
||||
│ • Perfiles inspiración │ ┌──────────────┐ │ • Instagram │
|
||||
│ • Input manual └▶│ GENERADOR │ │ • Facebook │
|
||||
│ │ DE IMÁGENES │ │ │
|
||||
│ └──────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ └─▶│ SCHEDULER │ │ DASHBOARD │◀── Revisión manual │
|
||||
│ │ (Celery) │ │ (FastAPI) │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología | Justificación |
|
||||
|------------|------------|---------------|
|
||||
| **Backend** | Python + FastAPI | Async, rápido, ideal para IA |
|
||||
| **Base de datos** | PostgreSQL | Robusto, JSON support |
|
||||
| **Cola de tareas** | Celery + Redis | Tareas programadas confiables |
|
||||
| **ORM** | SQLAlchemy | Maduro, bien documentado |
|
||||
| **Imágenes** | Pillow + html2image | Flexible, sin dependencias externas |
|
||||
| **IA** | DeepSeek API | Económico (~$0.27/M tokens), buena calidad |
|
||||
| **Contenedores** | Docker Compose | Despliegue reproducible |
|
||||
|
||||
---
|
||||
|
||||
## Plataformas y Frecuencia
|
||||
|
||||
| Plataforma | Frecuencia | Posts/Mes | Límite API |
|
||||
|------------|------------|-----------|------------|
|
||||
| **X (Twitter)** | Alta (1-2/día) | ~80-100 | 1,500/mes (Free) ✅ |
|
||||
| **Threads** | Alta (1-2/día) | ~80 | 250/día ✅ |
|
||||
| **Instagram** | Alta (1-2/día) | ~66 | 25/día ✅ |
|
||||
| **Facebook** | Media (3-5/sem) | ~36 | Sin límite práctico ✅ |
|
||||
|
||||
**Total aproximado:** ~280-300 posts/mes
|
||||
|
||||
---
|
||||
|
||||
## Tipos de Contenido
|
||||
|
||||
### Automático (sin revisión)
|
||||
| Tipo | Frecuencia | Plataformas |
|
||||
|------|------------|-------------|
|
||||
| Tips tech | Diario | X, Threads, IG |
|
||||
| Datos curiosos | Diario | X, Threads |
|
||||
| Frases motivacionales | 3x semana | IG, FB |
|
||||
| Efemérides tech | Variable | X, Threads |
|
||||
|
||||
### Semi-automático (revisión rápida)
|
||||
| Tipo | Frecuencia | Plataformas |
|
||||
|------|------------|-------------|
|
||||
| Productos del catálogo | 3x semana | FB, IG |
|
||||
| Servicios destacados | 2x semana | FB, IG, X |
|
||||
| Hilos educativos | 1x semana | X, Threads |
|
||||
|
||||
### Manual (contenido importante)
|
||||
| Tipo | Frecuencia | Plataformas |
|
||||
|------|------------|-------------|
|
||||
| Casos de éxito | Mensual | Todas |
|
||||
| Promociones especiales | Variable | Todas |
|
||||
| Anuncios importantes | Variable | Todas |
|
||||
|
||||
---
|
||||
|
||||
## Estructura de Base de Datos
|
||||
|
||||
### Tablas Principales
|
||||
|
||||
```sql
|
||||
-- Catálogo de productos
|
||||
products (
|
||||
id, name, description, price, category,
|
||||
specs JSON, images[], stock, created_at
|
||||
)
|
||||
|
||||
-- Servicios de consultoría
|
||||
services (
|
||||
id, name, description, target_sector,
|
||||
benefits[], case_studies[], created_at
|
||||
)
|
||||
|
||||
-- Banco de tips
|
||||
tip_templates (
|
||||
id, category, template, variables,
|
||||
used_count, last_used, created_at
|
||||
)
|
||||
|
||||
-- Posts generados
|
||||
posts (
|
||||
id, content, content_type, platforms[],
|
||||
status, scheduled_at, published_at,
|
||||
image_url, approval_needed, platform_ids JSON
|
||||
)
|
||||
|
||||
-- Calendario de contenido
|
||||
content_calendar (
|
||||
id, day_of_week, time, platform,
|
||||
content_type, is_active
|
||||
)
|
||||
|
||||
-- Plantillas de imágenes
|
||||
image_templates (
|
||||
id, name, template_file, variables[], category
|
||||
)
|
||||
|
||||
-- Interacciones
|
||||
interactions (
|
||||
id, platform, type, post_id, external_id,
|
||||
author_username, author_name, content,
|
||||
responded, response_content, responded_at,
|
||||
is_lead, created_at
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flujo de Generación de Contenido
|
||||
|
||||
1. **Scheduler** revisa calendario cada hora
|
||||
2. **Selecciona fuente** según tipo (tips, productos, servicios)
|
||||
3. **Genera contenido** con DeepSeek API usando prompts especializados
|
||||
4. **Adapta por plataforma** (caracteres, hashtags, estilo)
|
||||
5. **Genera imagen** si aplica (plantilla o foto de catálogo)
|
||||
6. **Decide flujo**: auto-programar o enviar a aprobación
|
||||
|
||||
---
|
||||
|
||||
## Dashboard de Gestión
|
||||
|
||||
### Funcionalidades
|
||||
|
||||
- **Home**: Resumen, métricas, alertas
|
||||
- **Posts**: Historial, filtros, estados
|
||||
- **Productos**: CRUD del catálogo
|
||||
- **Calendar**: Vista visual, reprogramación drag-and-drop
|
||||
- **Queue**: Cola de publicación, pausar/reanudar
|
||||
- **Interacciones**: Responder comentarios, DMs, menciones
|
||||
|
||||
### Gestión de Interacciones
|
||||
|
||||
- Ver todas las interacciones en un solo lugar
|
||||
- Respuestas sugeridas por IA
|
||||
- Marcar leads potenciales
|
||||
- Filtrar por plataforma, tipo, estado
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura de Despliegue
|
||||
|
||||
```yaml
|
||||
# Docker Compose - Servicios
|
||||
services:
|
||||
- app (FastAPI :8000)
|
||||
- worker (Celery Worker)
|
||||
- beat (Celery Beat Scheduler)
|
||||
- flower (Monitor :5555)
|
||||
- db (PostgreSQL :5432)
|
||||
- redis (Redis :6379)
|
||||
- nginx (Reverse Proxy :80/:443)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## APIs Externas Requeridas
|
||||
|
||||
| API | Propósito | Costo | Estado |
|
||||
|-----|-----------|-------|--------|
|
||||
| DeepSeek | Generación de contenido | ~$0.27/M tokens | Pendiente |
|
||||
| X API v2 | Publicar en Twitter | Free tier | Pendiente |
|
||||
| Meta Graph API | FB, IG, Threads | Gratis | Pendiente |
|
||||
|
||||
---
|
||||
|
||||
## Estimación de Costos Mensuales
|
||||
|
||||
| Concepto | Costo |
|
||||
|----------|-------|
|
||||
| DeepSeek API (~300 posts) | ~$5-10 |
|
||||
| X API (Free tier) | $0 |
|
||||
| Meta API | $0 |
|
||||
| Servidor (ya existente) | $0 |
|
||||
| **Total** | **~$5-10/mes** |
|
||||
|
||||
---
|
||||
|
||||
## Referencias de Estilo
|
||||
|
||||
Perfiles de inspiración para el tono y tipo de contenido:
|
||||
- @midudev - Tips desarrollo web, hilos educativos
|
||||
- @MoureDev - Python, retos, comunidad
|
||||
- @SoyDalto - Contenido accesible, motivacional
|
||||
- @SuGE3K - Tips tech prácticos
|
||||
- @FrankSanabria - Desarrollo y emprendimiento
|
||||
|
||||
**Tono Consultoría AS:** Profesional pero cercano, educativo, orientado a soluciones.
|
||||
92
nginx/nginx.conf
Normal file
92
nginx/nginx.conf
Normal file
@@ -0,0 +1,92 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# Upstream para FastAPI
|
||||
upstream app {
|
||||
server app:8000;
|
||||
}
|
||||
|
||||
# Upstream para Flower (monitor de Celery)
|
||||
upstream flower {
|
||||
server flower:5555;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Redirigir a HTTPS en producción
|
||||
# return 301 https://$server_name$request_uri;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# API
|
||||
location /api {
|
||||
proxy_pass http://app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Archivos estáticos
|
||||
location /static {
|
||||
alias /usr/share/nginx/html/static;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Flower (monitor de Celery) - proteger en producción
|
||||
location /flower/ {
|
||||
proxy_pass http://flower/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://app/api/health;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server (descomentar en producción)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name localhost;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
#
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
#
|
||||
# # ... mismas locations que HTTP ...
|
||||
# }
|
||||
}
|
||||
48
requirements.txt
Normal file
48
requirements.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
# FastAPI y servidor
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Base de datos
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
|
||||
# Celery y Redis
|
||||
celery==5.3.6
|
||||
redis==5.0.1
|
||||
flower==2.0.1
|
||||
|
||||
# HTTP Client
|
||||
httpx==0.26.0
|
||||
aiohttp==3.9.1
|
||||
|
||||
# APIs de Redes Sociales
|
||||
tweepy==4.14.0 # X/Twitter
|
||||
|
||||
# IA
|
||||
openai==1.10.0 # Compatible con DeepSeek API
|
||||
|
||||
# Imágenes
|
||||
Pillow==10.2.0
|
||||
html2image==2.0.4.3
|
||||
|
||||
# Utilidades
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
# Templates HTML
|
||||
jinja2==3.1.3
|
||||
|
||||
# Testing
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
pytest-cov==4.1.0
|
||||
|
||||
# Desarrollo
|
||||
black==24.1.1
|
||||
isort==5.13.2
|
||||
flake8==7.0.0
|
||||
24
scripts/init_db.sql
Normal file
24
scripts/init_db.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Inicialización de la base de datos
|
||||
-- Este script se ejecuta automáticamente cuando se crea el contenedor de PostgreSQL
|
||||
|
||||
-- Crear extensiones necesarias
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Crear usuario si no existe (ya lo hace Docker, pero por si acaso)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'social_user') THEN
|
||||
CREATE ROLE social_user WITH LOGIN PASSWORD 'social_pass';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Dar permisos
|
||||
GRANT ALL PRIVILEGES ON DATABASE social_automation TO social_user;
|
||||
|
||||
-- Mensaje de confirmación
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Base de datos inicializada correctamente';
|
||||
END
|
||||
$$;
|
||||
180
scripts/seed_database.py
Normal file
180
scripts/seed_database.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Script para poblar la base de datos con datos iniciales.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import time
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from app.core.database import SessionLocal, engine
|
||||
from app.models import Base, TipTemplate, ContentCalendar, Service
|
||||
|
||||
|
||||
def seed_tips():
|
||||
"""Cargar tips iniciales."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Cargar JSON de tips
|
||||
tips_file = Path(__file__).parent.parent / "data" / "tips_iniciales.json"
|
||||
with open(tips_file, "r", encoding="utf-8") as f:
|
||||
tips_data = json.load(f)
|
||||
|
||||
for tip in tips_data:
|
||||
existing = db.query(TipTemplate).filter(
|
||||
TipTemplate.title == tip["title"]
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
new_tip = TipTemplate(
|
||||
category=tip["category"],
|
||||
title=tip["title"],
|
||||
template=tip["template"],
|
||||
difficulty=tip.get("difficulty", "basico"),
|
||||
is_active=True,
|
||||
is_evergreen=True
|
||||
)
|
||||
db.add(new_tip)
|
||||
|
||||
db.commit()
|
||||
print(f"✅ {len(tips_data)} tips cargados")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def seed_calendar():
|
||||
"""Crear calendario inicial."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Calendario de ejemplo
|
||||
calendar_entries = [
|
||||
# Tips diarios - mañana
|
||||
{"day": 0, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||
{"day": 1, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||
{"day": 2, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||
{"day": 3, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||
{"day": 4, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||
|
||||
# Tips diarios - tarde
|
||||
{"day": 0, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||
{"day": 1, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||
{"day": 2, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||
{"day": 3, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||
{"day": 4, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||
|
||||
# Productos - Lunes, Miércoles, Viernes
|
||||
{"day": 0, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True},
|
||||
{"day": 2, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True},
|
||||
{"day": 4, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True},
|
||||
|
||||
# Servicios - Martes, Jueves
|
||||
{"day": 1, "time": "11:00", "type": "servicio", "platforms": ["x", "facebook", "instagram"], "approval": True},
|
||||
{"day": 3, "time": "11:00", "type": "servicio", "platforms": ["x", "facebook", "instagram"], "approval": True},
|
||||
|
||||
# Frases motivacionales - Lunes, Miércoles, Viernes
|
||||
{"day": 0, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]},
|
||||
{"day": 2, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]},
|
||||
{"day": 4, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]},
|
||||
]
|
||||
|
||||
for entry in calendar_entries:
|
||||
hour, minute = map(int, entry["time"].split(":"))
|
||||
|
||||
new_entry = ContentCalendar(
|
||||
day_of_week=entry["day"],
|
||||
time=time(hour=hour, minute=minute),
|
||||
content_type=entry["type"],
|
||||
platforms=entry["platforms"],
|
||||
requires_approval=entry.get("approval", False),
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_entry)
|
||||
|
||||
db.commit()
|
||||
print(f"✅ {len(calendar_entries)} entradas de calendario creadas")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def seed_services():
|
||||
"""Crear servicios iniciales."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
services = [
|
||||
{
|
||||
"name": "Chatbots Inteligentes",
|
||||
"description": "Implementamos chatbots con IA para WhatsApp e Instagram que atienden a tus clientes 24/7.",
|
||||
"short_description": "Chatbots IA para WhatsApp e Instagram",
|
||||
"category": "automatizacion",
|
||||
"target_sectors": ["retail", "servicios", "hoteles"],
|
||||
"benefits": ["Atención 24/7", "Respuestas instantáneas", "Integración con CRM", "Escalamiento a humanos"],
|
||||
"call_to_action": "Agenda una demo gratuita"
|
||||
},
|
||||
{
|
||||
"name": "Sistemas para Hoteles",
|
||||
"description": "Software completo para gestión hotelera: reservas, nómina, inventario y dashboards financieros.",
|
||||
"short_description": "Gestión hotelera integral",
|
||||
"category": "desarrollo",
|
||||
"target_sectors": ["hoteles", "turismo"],
|
||||
"benefits": ["Control de reservas", "Gestión de nómina", "Inventario automatizado", "Reportes en tiempo real"],
|
||||
"call_to_action": "Solicita una cotización"
|
||||
},
|
||||
{
|
||||
"name": "Automatización de Procesos",
|
||||
"description": "Automatizamos tareas repetitivas con bots y flujos de trabajo inteligentes.",
|
||||
"short_description": "Automatiza tareas repetitivas",
|
||||
"category": "automatizacion",
|
||||
"target_sectors": ["empresas", "pymes", "corporativos"],
|
||||
"benefits": ["Ahorro de tiempo", "Reducción de errores", "Mayor productividad", "ROI medible"],
|
||||
"call_to_action": "Cuéntanos tu proceso"
|
||||
},
|
||||
{
|
||||
"name": "Integración de IA",
|
||||
"description": "Integramos inteligencia artificial en tus procesos de negocio para tomar mejores decisiones.",
|
||||
"short_description": "IA para tu negocio",
|
||||
"category": "ia",
|
||||
"target_sectors": ["empresas", "pymes", "corporativos"],
|
||||
"benefits": ["Análisis predictivo", "Automatización inteligente", "Insights de datos", "Ventaja competitiva"],
|
||||
"call_to_action": "Descubre cómo la IA puede ayudarte"
|
||||
}
|
||||
]
|
||||
|
||||
for service in services:
|
||||
existing = db.query(Service).filter(Service.name == service["name"]).first()
|
||||
|
||||
if not existing:
|
||||
new_service = Service(**service, is_active=True)
|
||||
db.add(new_service)
|
||||
|
||||
db.commit()
|
||||
print(f"✅ {len(services)} servicios creados")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Ejecutar todos los seeds."""
|
||||
print("🌱 Iniciando seed de base de datos...\n")
|
||||
|
||||
# Crear tablas
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("✅ Tablas creadas\n")
|
||||
|
||||
seed_tips()
|
||||
seed_calendar()
|
||||
seed_services()
|
||||
|
||||
print("\n✅ Seed completado!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
127
templates/tip_card.html
Normal file
127
templates/tip_card.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 1080px;
|
||||
height: 1080px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 60px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category {
|
||||
background: rgba(212, 165, 116, 0.2);
|
||||
color: #d4a574;
|
||||
padding: 12px 24px;
|
||||
border-radius: 30px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
color: #e0e0e0;
|
||||
max-width: 900px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #d4a574;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 20px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #d4a574;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.decoration {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(212, 165, 116, 0.1) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.decoration-1 {
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.decoration-2 {
|
||||
bottom: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="decoration decoration-1"></div>
|
||||
<div class="decoration decoration-2"></div>
|
||||
|
||||
<div class="category">💡 {{ category }}</div>
|
||||
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
|
||||
<p class="content">{{ content }}</p>
|
||||
|
||||
<div class="footer">
|
||||
<div class="logo">AS</div>
|
||||
<div class="brand">
|
||||
<span>{{ business_name }}</span><br>
|
||||
{{ website }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
worker/__init__.py
Normal file
1
worker/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Celery Worker
|
||||
61
worker/celery_app.py
Normal file
61
worker/celery_app.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Configuración de Celery para tareas en background.
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Crear aplicación Celery
|
||||
celery_app = Celery(
|
||||
"social_automation",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=[
|
||||
"worker.tasks.generate_content",
|
||||
"worker.tasks.publish_post",
|
||||
"worker.tasks.fetch_interactions",
|
||||
"worker.tasks.cleanup"
|
||||
]
|
||||
)
|
||||
|
||||
# Configuración
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="America/Tijuana",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=300, # 5 minutos máximo por tarea
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_concurrency=2
|
||||
)
|
||||
|
||||
# Programación de tareas periódicas
|
||||
celery_app.conf.beat_schedule = {
|
||||
# Generar contenido cada hora
|
||||
"generate-scheduled-content": {
|
||||
"task": "worker.tasks.generate_content.generate_scheduled_content",
|
||||
"schedule": crontab(minute=0), # Cada hora en punto
|
||||
},
|
||||
|
||||
# Publicar posts programados cada minuto
|
||||
"publish-scheduled-posts": {
|
||||
"task": "worker.tasks.publish_post.publish_scheduled_posts",
|
||||
"schedule": crontab(minute="*"), # Cada minuto
|
||||
},
|
||||
|
||||
# Obtener interacciones cada 5 minutos
|
||||
"fetch-interactions": {
|
||||
"task": "worker.tasks.fetch_interactions.fetch_all_interactions",
|
||||
"schedule": crontab(minute="*/5"), # Cada 5 minutos
|
||||
},
|
||||
|
||||
# Limpieza diaria a las 3 AM
|
||||
"daily-cleanup": {
|
||||
"task": "worker.tasks.cleanup.daily_cleanup",
|
||||
"schedule": crontab(hour=3, minute=0),
|
||||
},
|
||||
}
|
||||
1
worker/tasks/__init__.py
Normal file
1
worker/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Celery Tasks
|
||||
86
worker/tasks/cleanup.py
Normal file
86
worker/tasks/cleanup.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Tareas de limpieza y mantenimiento.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.post import Post
|
||||
from app.models.interaction import Interaction
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.cleanup.daily_cleanup")
|
||||
def daily_cleanup():
|
||||
"""
|
||||
Limpieza diaria del sistema.
|
||||
Se ejecuta a las 3 AM.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
results = []
|
||||
|
||||
# 1. Eliminar posts fallidos de más de 7 días
|
||||
cutoff_failed = datetime.utcnow() - timedelta(days=7)
|
||||
deleted_failed = db.query(Post).filter(
|
||||
Post.status == "failed",
|
||||
Post.created_at < cutoff_failed
|
||||
).delete()
|
||||
results.append(f"Posts fallidos eliminados: {deleted_failed}")
|
||||
|
||||
# 2. Archivar interacciones respondidas de más de 30 días
|
||||
cutoff_interactions = datetime.utcnow() - timedelta(days=30)
|
||||
archived = db.query(Interaction).filter(
|
||||
Interaction.responded == True,
|
||||
Interaction.responded_at < cutoff_interactions,
|
||||
Interaction.is_archived == False
|
||||
).update({"is_archived": True})
|
||||
results.append(f"Interacciones archivadas: {archived}")
|
||||
|
||||
# 3. Eliminar posts cancelados de más de 14 días
|
||||
cutoff_cancelled = datetime.utcnow() - timedelta(days=14)
|
||||
deleted_cancelled = db.query(Post).filter(
|
||||
Post.status == "cancelled",
|
||||
Post.created_at < cutoff_cancelled
|
||||
).delete()
|
||||
results.append(f"Posts cancelados eliminados: {deleted_cancelled}")
|
||||
|
||||
# 4. Resetear contadores de tips (mensualmente, si es día 1)
|
||||
if datetime.utcnow().day == 1:
|
||||
from app.models.tip_template import TipTemplate
|
||||
db.query(TipTemplate).update({"used_count": 0})
|
||||
results.append("Contadores de tips reseteados")
|
||||
|
||||
db.commit()
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return f"Error en limpieza: {e}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.cleanup.cleanup_old_images")
|
||||
def cleanup_old_images():
|
||||
"""Limpiar imágenes generadas antiguas."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
upload_dir = Path("uploads/generated")
|
||||
if not upload_dir.exists():
|
||||
return "Directorio de uploads no existe"
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=7)
|
||||
deleted = 0
|
||||
|
||||
for file in upload_dir.glob("*.png"):
|
||||
file_time = datetime.fromtimestamp(file.stat().st_mtime)
|
||||
if file_time < cutoff:
|
||||
file.unlink()
|
||||
deleted += 1
|
||||
|
||||
return f"Imágenes eliminadas: {deleted}"
|
||||
123
worker/tasks/fetch_interactions.py
Normal file
123
worker/tasks/fetch_interactions.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Tareas para obtener interacciones de redes sociales.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.interaction import Interaction
|
||||
from app.models.post import Post
|
||||
from app.publishers import get_publisher
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.fetch_interactions.fetch_all_interactions")
|
||||
def fetch_all_interactions():
|
||||
"""
|
||||
Obtener interacciones de todas las plataformas.
|
||||
Se ejecuta cada 5 minutos.
|
||||
"""
|
||||
platforms = ["x", "threads", "instagram", "facebook"]
|
||||
results = []
|
||||
|
||||
for platform in platforms:
|
||||
try:
|
||||
result = fetch_platform_interactions.delay(platform)
|
||||
results.append(f"{platform}: tarea enviada")
|
||||
except Exception as e:
|
||||
results.append(f"{platform}: error - {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.fetch_interactions.fetch_platform_interactions")
|
||||
def fetch_platform_interactions(platform: str):
|
||||
"""Obtener interacciones de una plataforma específica."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
publisher = get_publisher(platform)
|
||||
|
||||
# Obtener menciones
|
||||
mentions = run_async(publisher.get_mentions())
|
||||
new_mentions = 0
|
||||
|
||||
for mention in mentions:
|
||||
external_id = mention.get("id")
|
||||
|
||||
# Verificar si ya existe
|
||||
existing = db.query(Interaction).filter(
|
||||
Interaction.external_id == external_id
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
interaction = Interaction(
|
||||
platform=platform,
|
||||
interaction_type="mention",
|
||||
external_id=external_id,
|
||||
author_username=mention.get("username", mention.get("author_id", "unknown")),
|
||||
author_name=mention.get("name"),
|
||||
content=mention.get("text", mention.get("message")),
|
||||
interaction_at=datetime.fromisoformat(
|
||||
mention.get("created_at", datetime.utcnow().isoformat()).replace("Z", "+00:00")
|
||||
) if mention.get("created_at") else datetime.utcnow()
|
||||
)
|
||||
db.add(interaction)
|
||||
new_mentions += 1
|
||||
|
||||
# Obtener comentarios de posts recientes
|
||||
recent_posts = db.query(Post).filter(
|
||||
Post.platform_post_ids.isnot(None),
|
||||
Post.platforms.contains([platform])
|
||||
).order_by(Post.published_at.desc()).limit(10).all()
|
||||
|
||||
new_comments = 0
|
||||
|
||||
for post in recent_posts:
|
||||
platform_id = post.platform_post_ids.get(platform)
|
||||
if not platform_id:
|
||||
continue
|
||||
|
||||
comments = run_async(publisher.get_comments(platform_id))
|
||||
|
||||
for comment in comments:
|
||||
external_id = comment.get("id")
|
||||
|
||||
existing = db.query(Interaction).filter(
|
||||
Interaction.external_id == external_id
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
interaction = Interaction(
|
||||
platform=platform,
|
||||
interaction_type="comment",
|
||||
post_id=post.id,
|
||||
external_id=external_id,
|
||||
external_post_id=platform_id,
|
||||
author_username=comment.get("username", comment.get("from", {}).get("id", "unknown")),
|
||||
author_name=comment.get("from", {}).get("name") if isinstance(comment.get("from"), dict) else None,
|
||||
content=comment.get("text", comment.get("message")),
|
||||
interaction_at=datetime.fromisoformat(
|
||||
comment.get("created_at", comment.get("timestamp", comment.get("created_time", datetime.utcnow().isoformat()))).replace("Z", "+00:00")
|
||||
) if comment.get("created_at") or comment.get("timestamp") or comment.get("created_time") else datetime.utcnow()
|
||||
)
|
||||
db.add(interaction)
|
||||
new_comments += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"{platform}: {new_mentions} menciones, {new_comments} comentarios nuevos"
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return f"{platform}: error - {e}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
266
worker/tasks/generate_content.py
Normal file
266
worker/tasks/generate_content.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Tareas de generación de contenido.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.post import Post
|
||||
from app.models.content_calendar import ContentCalendar
|
||||
from app.models.tip_template import TipTemplate
|
||||
from app.models.product import Product
|
||||
from app.models.service import Service
|
||||
from app.services.content_generator import content_generator
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content")
|
||||
def generate_scheduled_content():
|
||||
"""
|
||||
Generar contenido según el calendario.
|
||||
Se ejecuta cada hora.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.now()
|
||||
current_day = now.weekday()
|
||||
current_hour = now.hour
|
||||
|
||||
# Obtener entradas del calendario para esta hora
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.day_of_week == current_day,
|
||||
ContentCalendar.is_active == True
|
||||
).all()
|
||||
|
||||
for entry in entries:
|
||||
# Verificar si es la hora correcta
|
||||
if entry.time.hour != current_hour:
|
||||
continue
|
||||
|
||||
# Generar contenido según el tipo
|
||||
if entry.content_type == "tip_tech":
|
||||
generate_tip_post.delay(
|
||||
platforms=entry.platforms,
|
||||
category_filter=entry.category_filter,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
|
||||
elif entry.content_type == "producto":
|
||||
generate_product_post.delay(
|
||||
platforms=entry.platforms,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
|
||||
elif entry.content_type == "servicio":
|
||||
generate_service_post.delay(
|
||||
platforms=entry.platforms,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
|
||||
return f"Procesadas {len(entries)} entradas del calendario"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_tip_post")
|
||||
def generate_tip_post(
|
||||
platforms: list,
|
||||
category_filter: str = None,
|
||||
requires_approval: bool = False
|
||||
):
|
||||
"""Generar un post de tip tech."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Seleccionar un tip que no se haya usado recientemente
|
||||
query = db.query(TipTemplate).filter(
|
||||
TipTemplate.is_active == True
|
||||
)
|
||||
|
||||
if category_filter:
|
||||
query = query.filter(TipTemplate.category == category_filter)
|
||||
|
||||
# Ordenar por uso (menos usado primero)
|
||||
tip = query.order_by(
|
||||
TipTemplate.used_count.asc(),
|
||||
TipTemplate.last_used.asc().nullsfirst()
|
||||
).first()
|
||||
|
||||
if not tip:
|
||||
return "No hay tips disponibles"
|
||||
|
||||
# Generar contenido para cada plataforma
|
||||
content_by_platform = {}
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_tip_tech(
|
||||
category=tip.category,
|
||||
platform=platform,
|
||||
template=tip.template
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Crear post
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content_type="tip_tech",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
content_threads=content_by_platform.get("threads"),
|
||||
content_instagram=content_by_platform.get("instagram"),
|
||||
content_facebook=content_by_platform.get("facebook"),
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
tip_template_id=tip.id
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
|
||||
# Actualizar uso del tip
|
||||
tip.used_count += 1
|
||||
tip.last_used = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de tip generado: {post.id}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_product_post")
|
||||
def generate_product_post(
|
||||
platforms: list,
|
||||
product_id: int = None,
|
||||
requires_approval: bool = True
|
||||
):
|
||||
"""Generar un post de producto."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Seleccionar producto
|
||||
if product_id:
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
else:
|
||||
# Seleccionar uno aleatorio que no se haya publicado recientemente
|
||||
product = db.query(Product).filter(
|
||||
Product.is_available == True
|
||||
).order_by(
|
||||
Product.last_posted_at.asc().nullsfirst()
|
||||
).first()
|
||||
|
||||
if not product:
|
||||
return "No hay productos disponibles"
|
||||
|
||||
# Generar contenido
|
||||
content_by_platform = {}
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_product_post(
|
||||
product=product.to_dict(),
|
||||
platform=platform
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Crear post
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content_type="producto",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
content_threads=content_by_platform.get("threads"),
|
||||
content_instagram=content_by_platform.get("instagram"),
|
||||
content_facebook=content_by_platform.get("facebook"),
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
product_id=product.id,
|
||||
image_url=product.main_image
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
|
||||
# Actualizar última publicación del producto
|
||||
product.last_posted_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de producto generado: {post.id}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_service_post")
|
||||
def generate_service_post(
|
||||
platforms: list,
|
||||
service_id: int = None,
|
||||
requires_approval: bool = True
|
||||
):
|
||||
"""Generar un post de servicio."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Seleccionar servicio
|
||||
if service_id:
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
else:
|
||||
service = db.query(Service).filter(
|
||||
Service.is_active == True
|
||||
).order_by(
|
||||
Service.last_posted_at.asc().nullsfirst()
|
||||
).first()
|
||||
|
||||
if not service:
|
||||
return "No hay servicios disponibles"
|
||||
|
||||
# Generar contenido
|
||||
content_by_platform = {}
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_service_post(
|
||||
service=service.to_dict(),
|
||||
platform=platform
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Crear post
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content_type="servicio",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
content_threads=content_by_platform.get("threads"),
|
||||
content_instagram=content_by_platform.get("instagram"),
|
||||
content_facebook=content_by_platform.get("facebook"),
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
service_id=service.id,
|
||||
image_url=service.main_image
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
|
||||
# Actualizar última publicación del servicio
|
||||
service.last_posted_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de servicio generado: {post.id}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
156
worker/tasks/publish_post.py
Normal file
156
worker/tasks/publish_post.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Tareas de publicación de posts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.post import Post
|
||||
from app.publishers import get_publisher
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.publish_post.publish_scheduled_posts")
|
||||
def publish_scheduled_posts():
|
||||
"""
|
||||
Publicar posts que están programados para ahora.
|
||||
Se ejecuta cada minuto.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Obtener posts listos para publicar
|
||||
posts = db.query(Post).filter(
|
||||
Post.status == "scheduled",
|
||||
Post.scheduled_at <= now
|
||||
).all()
|
||||
|
||||
results = []
|
||||
|
||||
for post in posts:
|
||||
# Marcar como en proceso
|
||||
post.status = "publishing"
|
||||
db.commit()
|
||||
|
||||
# Publicar en cada plataforma
|
||||
success_count = 0
|
||||
platform_ids = {}
|
||||
errors = []
|
||||
|
||||
for platform in post.platforms:
|
||||
result = publish_to_platform.delay(post.id, platform)
|
||||
# En producción, esto sería asíncrono
|
||||
# Por ahora, ejecutamos secuencialmente
|
||||
|
||||
results.append(f"Post {post.id} enviado a publicación")
|
||||
|
||||
return f"Procesados {len(posts)} posts"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="worker.tasks.publish_post.publish_to_platform",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=60
|
||||
)
|
||||
def publish_to_platform(self, post_id: int, platform: str):
|
||||
"""Publicar un post en una plataforma específica."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
return f"Post {post_id} no encontrado"
|
||||
|
||||
# Obtener contenido para esta plataforma
|
||||
content = post.get_content_for_platform(platform)
|
||||
|
||||
# Obtener publisher
|
||||
try:
|
||||
publisher = get_publisher(platform)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Publicar
|
||||
result = run_async(
|
||||
publisher.publish(content, post.image_url)
|
||||
)
|
||||
|
||||
if result.success:
|
||||
# Guardar ID del post en la plataforma
|
||||
platform_ids = post.platform_post_ids or {}
|
||||
platform_ids[platform] = result.post_id
|
||||
post.platform_post_ids = platform_ids
|
||||
|
||||
# Verificar si todas las plataformas están publicadas
|
||||
all_published = all(
|
||||
p in platform_ids for p in post.platforms
|
||||
)
|
||||
|
||||
if all_published:
|
||||
post.status = "published"
|
||||
post.published_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Publicado en {platform}: {result.post_id}"
|
||||
|
||||
else:
|
||||
# Error en publicación
|
||||
post.error_message = f"{platform}: {result.error_message}"
|
||||
post.retry_count += 1
|
||||
|
||||
if post.retry_count >= 3:
|
||||
post.status = "failed"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Reintentar
|
||||
raise self.retry(
|
||||
exc=Exception(result.error_message),
|
||||
countdown=60 * post.retry_count
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.publish_post.publish_now")
|
||||
def publish_now(post_id: int):
|
||||
"""Publicar un post inmediatamente."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
return f"Post {post_id} no encontrado"
|
||||
|
||||
# Cambiar estado
|
||||
post.status = "publishing"
|
||||
post.scheduled_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Publicar en cada plataforma
|
||||
for platform in post.platforms:
|
||||
publish_to_platform.delay(post_id, platform)
|
||||
|
||||
return f"Post {post_id} enviado a publicación inmediata"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user