First commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
app/telegram_sessions/
|
||||
backup_service/backups
|
||||
.env.docker
|
||||
docker-compose.yml
|
||||
nginx/ssl
|
||||
nginx.conf
|
||||
tip_backup_*
|
||||
backups
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copiar requirements e instalar dependencias de Python
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar el código de la aplicación
|
||||
COPY . .
|
||||
|
||||
# añadir script de arranque que correrá migraciones
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Exponer el puerto
|
||||
EXPOSE 8000
|
||||
|
||||
# Comando para ejecutar la aplicación
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Dockerfile.dev
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Instalar dependencias del sistema para desarrollo
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
vim \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copiar requirements
|
||||
COPY requirements_frontend.txt ./requirements.txt
|
||||
|
||||
# Instalar dependencias de desarrollo también
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir black pylint pytest
|
||||
|
||||
# Usuario no-root
|
||||
RUN useradd -m -u 1000 devuser && \
|
||||
chown -R devuser:devuser /frontend
|
||||
USER devuser
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
# Streamlit se ejecutará con hot-reload automático
|
||||
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /usuarios
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copiar requirements e instalar dependencias de Python
|
||||
COPY requirements_users.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar el código de la aplicación
|
||||
COPY . .
|
||||
|
||||
# Exponer el puerto
|
||||
EXPOSE 8080
|
||||
|
||||
# Comando para ejecutar la aplicación
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
@@ -0,0 +1,766 @@
|
||||
# TIP — Threat Intelligence Platform
|
||||
|
||||
> Plataforma centralizada de monitoreo e inteligencia de amenazas sobre Telegram.
|
||||
|
||||
---
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
1. [Descripción General](#descripción-general)
|
||||
2. [Arquitectura del Sistema](#arquitectura-del-sistema)
|
||||
3. [Microservicios](#microservicios)
|
||||
4. [Flujo del Alimentador](#flujo-del-alimentador)
|
||||
5. [Requisitos Previos](#requisitos-previos)
|
||||
6. [Instalación y Configuración](#instalación-y-configuración)
|
||||
7. [Gestión de Backups](#gestión-de-backups)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Descripción General
|
||||
|
||||
**TIP (Threat Intelligence Platform)** es una plataforma de monitoreo en tiempo real diseñada para capturar, analizar y gestionar mensajes de grupos y canales de Telegram. Mediante reglas configurables basadas en expresiones regulares, el sistema detecta patrones sospechosos, genera alertas automáticas y mantiene un registro auditable de todas las operaciones.
|
||||
|
||||
### Características Principales
|
||||
|
||||
- **Monitoreo en tiempo real**: Captura automática de mensajes desde grupos y canales de Telegram
|
||||
- **Detección basada en reglas**: Expresiones regulares configurables para identificar patrones de interés
|
||||
- **Gestión de alertas**: Ciclo de vida completo — Pendiente → En Curso → Resuelta — con historial de notas
|
||||
- **Auditoría integral**: Registro de IP, usuario y timestamp en cada operación del sistema
|
||||
- **Exportación de reportes**: Generación de PDFs con trazabilidad completa al cerrar una alerta
|
||||
- **Estadísticas en tiempo real**: Dashboards con métricas, heatmaps y análisis de actividad
|
||||
- **Control de acceso por roles**: Administradores y Operadores con permisos diferenciados
|
||||
- **Sistema de backups**: Exportación e importación de bases de datos, configuración y sesiones de Telegram
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura del Sistema
|
||||
|
||||
TIP está compuesto por **4 microservicios** y **2 bases de datos** independientes, orquestados con Docker Compose y expuestos a través de un proxy NGINX con SSL.
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════════════════════════════╗
|
||||
║ INTERNET ║
|
||||
╚══════════════════════════════════════════════╦═══════════════════════════════════╝
|
||||
║ HTTPS :443 / HTTP :80
|
||||
╔════════════════╩════════════════╗
|
||||
║ NGINX Proxy ║
|
||||
║ Terminación SSL · Routing ║
|
||||
╚══════╦══════════╦═══════╦═══════╝
|
||||
║ ║ ║
|
||||
╔══════════════╝ ╔═════╝ ╔══=╩═════════════╗
|
||||
▼ ▼ ▼ ▼
|
||||
╔════════════════╗ ╔══════════════╗ ╔══════════════╗ ╔═══════════════╗
|
||||
║ Frontend ║ ║ API Feeder ║ ║ API Users ║ ║Backup Service ║
|
||||
║ Streamlit ║ ║ FastAPI ║ ║ FastAPI ║ ║ FastAPI ║
|
||||
║ Python 3.11 ║ ║ Python 3.11 ║ ║ Python 3.11 ║ ║ Python 3.11 ║
|
||||
║ :8501 ║ ║ :8000 ║ ║ :8090 ║ ║ :8099 ║
|
||||
╚═══════╦════════╝ ╚══════╦═══════╝ ╚══════╦═══════╝ ╚═══════╦═══════╝
|
||||
║ ║ ║ ║
|
||||
HTTP ║ ╔════════╩══════╗ ║ ║
|
||||
REST ╠ ║ Telegram ║ ║ ║
|
||||
║ ║ API (MTProto)║ ║ ║
|
||||
║ ╚═══════════════╝ ║ ║
|
||||
║ ║ ║ ╔═══════╩══════╗
|
||||
║ ╔════════╩═══════╗ ╔═══════╩══════╗ ║ /config/... ║
|
||||
╚═════════║ MariaDB :3306 ║ ║MariaDB :3307 ║ ║ (volumes) ║
|
||||
║ DB: feeder ║ ║ DB: users ║ ╚══════════════╝
|
||||
╚════════════════╝ ╚══════════════╝
|
||||
```
|
||||
|
||||
### Comunicación entre Servicios
|
||||
|
||||
| Origen | Destino | Protocolo | Propósito |
|
||||
|--------|---------|-----------|-----------|
|
||||
| Frontend | API Feeder | HTTP/REST | Alertas, mensajes, reglas, grupos, estadísticas |
|
||||
| Frontend | API Users | HTTP/REST | Login, gestión de usuarios |
|
||||
| Frontend | Backup Service | HTTP/REST | Estado y descarga de backups |
|
||||
| API Feeder | Telegram | MTProto (Telethon) | Captura de mensajes y adjuntos |
|
||||
| API Feeder | API Feeder (interno) | HTTP/REST | Persistencia de mensajes vía `api_implementations.py` |
|
||||
| Backup Service | MariaDB Feeder | TCP/MySQL | mysqldump para backup |
|
||||
| Backup Service | MariaDB Users | TCP/MySQL | mysqldump para backup |
|
||||
| NGINX | Todos los servicios | HTTP (reverse proxy) | Enrutamiento y terminación SSL |
|
||||
|
||||
### Seguridad
|
||||
|
||||
- **JWT compartido**: Todos los microservicios validan tokens con la misma `SECRET_KEY`
|
||||
- **Roles**: `admin` y `operator` con controles en cada endpoint
|
||||
- **Rate limiting**: Registro público limitado a 5 peticiones/minuto (SlowAPI)
|
||||
- **HTTPS**: Terminación SSL en NGINX con soporte TLSv1.2 y TLSv1.3
|
||||
|
||||
---
|
||||
|
||||
## Microservicios
|
||||
|
||||
### 1. Frontend — Streamlit
|
||||
|
||||
**Propósito**: Interfaz de usuario para el monitoreo y gestión de la plataforma.
|
||||
|
||||
**Lenguaje**: Python **3.11**
|
||||
|
||||
**Ubicación**: `/frontend`
|
||||
|
||||
| Librería | Versión | Propósito |
|
||||
|----------|---------|-----------|
|
||||
| Streamlit | ≥ 1.28.0 | Framework web reactivo |
|
||||
| Requests | ≥ 2.31.0 | Cliente HTTP hacia las APIs |
|
||||
| Pandas | ≥ 2.0.0 | Manipulación de datos en dashboards |
|
||||
| ReportLab | ≥ 3.6.0 | Generación de reportes PDF |
|
||||
| Matplotlib | ≥ 3.7.0 | Gráficos estáticos auxiliares |
|
||||
|
||||
**Funcionalidades**:
|
||||
- Panel de inicio con métricas y estado del alimentador
|
||||
- Gestión de alertas con notas, cambio de estado y exportación PDF
|
||||
- Búsqueda avanzada de mensajes con filtros por texto, grupo, remitente y fecha
|
||||
- Descarga de adjuntos directamente desde Telegram
|
||||
- Exploración de remitentes con historial de mensajes
|
||||
- Estadísticas con gráficos de línea, barras, heatmaps y ranking de senders
|
||||
- Panel de administración para gestión de usuarios, auditoría y backups
|
||||
|
||||
---
|
||||
|
||||
### 2. API Feeder — FastAPI
|
||||
|
||||
**Propósito**: Backend principal. Captura mensajes de Telegram, evalúa reglas y sirve los datos al Frontend.
|
||||
|
||||
**Lenguaje**: Python **3.11**
|
||||
|
||||
**Ubicación**: `/app`
|
||||
|
||||
| Librería | Versión | Propósito |
|
||||
|----------|---------|-----------|
|
||||
| FastAPI | 0.104.1 | Framework REST asíncrono |
|
||||
| Uvicorn | 0.24.0 | Servidor ASGI |
|
||||
| Telethon | 1.28.5 | Cliente MTProto para Telegram |
|
||||
| SQLAlchemy | 2.0.23 | ORM |
|
||||
| PyMySQL | 1.1.0 | Driver MySQL/MariaDB |
|
||||
| Pydantic | 2.11.9 | Validación y serialización |
|
||||
| Alembic | 1.11.1 | Migraciones de base de datos |
|
||||
| python-jose | 3.3.0 | Generación y validación de JWT |
|
||||
| python-dotenv | 1.0.0 | Gestión de variables de entorno |
|
||||
| httpx | latest | Cliente HTTP asíncrono |
|
||||
| aiofiles | 23.2.1 | I/O asíncrono de archivos |
|
||||
| Requests | 2.32.5 | Cliente HTTP síncrono (integraciones internas) |
|
||||
|
||||
**Endpoints principales**:
|
||||
|
||||
| Grupo | Método | Ruta | Descripción |
|
||||
|-------|--------|------|-------------|
|
||||
| Messages | POST | `/messages/` | Crear mensaje + evaluar reglas |
|
||||
| Messages | GET | `/messages/search/` | Búsqueda con filtros |
|
||||
| Alerts | GET | `/alerts/` | Listar con filtros |
|
||||
| Alerts | POST | `/alerts/{id}/resolve` | Cerrar alerta |
|
||||
| Alerts | POST | `/alerts/{id}/in-progress` | Marcar en curso |
|
||||
| Rules | POST | `/rules/` | Crear regla (con apply_to_history) |
|
||||
| Groups | PATCH | `/groups/{id}/update-position` | Actualizar cursor de mensajes |
|
||||
| Attachments | GET | `/attachments/{id}/download` | Descargar adjunto desde Telegram |
|
||||
| Audit | GET | `/audit/` | Consultar logs de auditoría |
|
||||
| Stats | GET | `/stats/` | Métricas y estadísticas agregadas |
|
||||
| Manage | POST | `/manage/` | Agregar grupo/canal de Telegram |
|
||||
| Manage | GET | `/manage/init-session` | Iniciar autenticación Telegram |
|
||||
| Manage | POST | `/manage/verify-code` | Verificar código de autenticación |
|
||||
|
||||
---
|
||||
|
||||
### 3. API Users — FastAPI
|
||||
|
||||
**Propósito**: Gestión de usuarios, autenticación y auditoría de cambios de permisos.
|
||||
|
||||
**Lenguaje**: Python **3.11**
|
||||
|
||||
**Ubicación**: `/usuarios`
|
||||
|
||||
| Librería | Versión | Propósito |
|
||||
|----------|---------|-----------|
|
||||
| FastAPI | 0.104.1 | Framework REST |
|
||||
| Uvicorn | 0.24.0 | Servidor ASGI |
|
||||
| SQLAlchemy | 2.0.23 | ORM |
|
||||
| PyMySQL | 1.1.2 | Driver MySQL/MariaDB |
|
||||
| Pydantic | 2.5.0 | Validación de datos |
|
||||
| passlib[bcrypt] | 1.7.4 | Hash seguro de contraseñas |
|
||||
| bcrypt | 4.0.1 | Backend de hashing |
|
||||
| python-jose | 3.3.0 | JWT |
|
||||
| SlowAPI | latest | Rate limiting en endpoints públicos |
|
||||
| python-multipart | 0.0.6 | Formularios multipart |
|
||||
|
||||
**Endpoints principales**:
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/login` | Autenticación — devuelve JWT |
|
||||
| GET | `/api/v1/users/` | Listar usuarios (admin) |
|
||||
| GET | `/api/v1/users/pending` | Usuarios pendientes de aprobación |
|
||||
| POST | `/api/v1/users/common` | Registro público (rate-limited) |
|
||||
| PUT | `/api/v1/users/{id}` | Actualizar datos |
|
||||
| PATCH | `/api/v1/users/{id}/role` | Cambiar rol |
|
||||
| POST | `/api/v1/users/{id}/activate` | Activar usuario |
|
||||
| GET | `/api/v1/logs/users` | Historial de modificaciones |
|
||||
|
||||
---
|
||||
|
||||
### 4. Backup Service — FastAPI
|
||||
|
||||
**Propósito**: Centraliza la generación y verificación de backups del sistema completo.
|
||||
|
||||
**Lenguaje**: Python **3.11**
|
||||
|
||||
**Ubicación**: `/backup_service`
|
||||
|
||||
| Librería | Versión | Propósito |
|
||||
|----------|---------|-----------|
|
||||
| FastAPI | 0.104.1 | Framework REST |
|
||||
| Uvicorn | 0.24.0 | Servidor ASGI |
|
||||
| python-jose | 3.3.0 | Validación JWT (mismo SECRET_KEY) |
|
||||
| mysqldump / mysqladmin | sistema | Dump de bases de datos (instalados en Dockerfile) |
|
||||
|
||||
**Endpoints**:
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/backup` | Genera y descarga ZIP con los componentes seleccionados |
|
||||
| GET | `/backup/status` | Estado de cada componente sin generar backup |
|
||||
| GET | `/health` | Verificación de salud del servicio |
|
||||
|
||||
**Componentes exportables**:
|
||||
- `databases/` — mysqldump de las bases de datos Feeder y Users
|
||||
- `config/` — nginx.conf y docker-compose.yml
|
||||
- `ssl/` — Certificados SSL
|
||||
- `telegram_sessions/` — Archivos `.session` de autenticación de Telegram
|
||||
|
||||
---
|
||||
|
||||
## Flujo del Alimentador
|
||||
|
||||
El **Feeder** (alimentador) es el núcleo de captura del sistema. Se ejecuta en un hilo de fondo independiente al inicio de la API y opera en ciclos continuos.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ INICIO DE LA API (startup_event) │
|
||||
│ └─► Lanza run_sync_scraper() en thread separado via asyncio.to_thread │
|
||||
└──────────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ CICLO PRINCIPAL (run_sync_scraper) │
|
||||
│ │
|
||||
│ ┌─ INICIO DEL CICLO ──────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 1. TelegramChatSingleton.get_scraper() │ │
|
||||
│ │ └─► ¿Existe instancia singleton? │ │
|
||||
│ │ ├─ NO → TelegramClientManager.connect() │ │
|
||||
│ │ │ └─► Busca session_*.session en /telegram_sessions│ │
|
||||
│ │ │ Autentica con Telethon (MTProto) │ │
|
||||
│ │ └─ SÍ → Reutiliza cliente existente │ │
|
||||
│ │ │ │
|
||||
│ │ 2. scraper.add_chats() │ │
|
||||
│ │ └─► GET /groups/ → obtiene todos los grupos activos │ │
|
||||
│ │ Para cada grupo: │ │
|
||||
│ │ └─► Consulta Telegram con get_entity(id) │ │
|
||||
│ │ Actualiza nombre, tipo y descripción en la DB │ │
|
||||
│ │ │ │
|
||||
│ │ 3. scraper.feeder_loop() │ │
|
||||
│ │ └─► Para cada grupo en la DB: │ │
|
||||
│ │ │ │
|
||||
│ │ a) set_chat_id(group_id) │ │
|
||||
│ │ └─► Configura el canal objetivo en Telethon │ │
|
||||
│ │ │ │
|
||||
│ │ b) self.first_id = group.message_position │ │
|
||||
│ │ └─► Lee el cursor del último mensaje procesado │ │
|
||||
│ │ │ │
|
||||
│ │ c) refresh_chat() │ │
|
||||
│ │ └─► GetHistoryRequest(min_id=first_id) │ │
|
||||
│ │ Descarga en lotes de 100 solo mensajes nuevos │ │
|
||||
│ │ │ │
|
||||
│ │ d) get_and_post_message_info() │ │
|
||||
│ │ │ │ │
|
||||
│ │ ├─► _preload_senders() │ │
|
||||
│ │ │ └─► Para cada sender_id único: │ │
|
||||
│ │ │ GET /senders/{id} → ¿existe? │ │
|
||||
│ │ │ NO → get_entity(id) en Telegram │ │
|
||||
│ │ │ POST /senders/ → crea el remitente │ │
|
||||
│ │ │ │ │
|
||||
│ │ └─► Para cada mensaje descargado: │ │
|
||||
│ │ │ │ │
|
||||
│ │ ├─► Extrae: id, content, date, sender_id, │ │
|
||||
│ │ │ group_id, media (adjunto si existe) │ │
|
||||
│ │ │ │ │
|
||||
│ │ ├─► POST /messages/ (API interna) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ └─► Endpoint crea el mensaje y: │ │
|
||||
│ │ │ Para cada regla activa: │ │
|
||||
│ │ │ └─► re.search(regex, content) │ │
|
||||
│ │ │ ¿Coincide? → crea Alert(status=open)│ │
|
||||
│ │ │ │ │
|
||||
│ │ └─► Si tiene adjunto → POST /attachments/ │ │
|
||||
│ │ │ │
|
||||
│ │ e) act_message_position(group_id, last_message_id) │ │
|
||||
│ │ └─► PATCH /groups/{id}/update-position │ │
|
||||
│ │ Actualiza el cursor para el próximo ciclo │ │
|
||||
│ │ │ │
|
||||
│ │ 4. Fin del ciclo → sleep(200 segundos) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚠️ Si ocurre una excepción en cualquier punto: │
|
||||
│ └─► scraper_status["last_error"] = str(e) │
|
||||
│ sleep(60 segundos) │
|
||||
│ El singleton mantiene el cliente → intento de reconexión │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Descarga de Adjuntos (bajo demanda)
|
||||
|
||||
Cuando un usuario descarga un adjunto desde la UI, se sigue un flujo independiente para no interferir con el ciclo del Feeder:
|
||||
|
||||
```
|
||||
Frontend
|
||||
└─► GET /attachments/{id}/download
|
||||
│
|
||||
├─► Obtiene metadata del adjunto (group_id, message_id)
|
||||
│
|
||||
├─► TelegramChatSingleton.get_tmp_scraper()
|
||||
│ └─► Crea un TelegramClientManager temporal
|
||||
│ Copia el .session file a un archivo temporal
|
||||
│ Lanza un thread dedicado con su propio event loop
|
||||
│
|
||||
└─► download_attachment_to_buffer(group_id, message_id)
|
||||
└─► client.get_messages(group_id, ids=message_id)
|
||||
client.download_media(message, file=BytesIO())
|
||||
Devuelve los bytes → StreamingResponse al Frontend
|
||||
Elimina la sesión temporal al finalizar
|
||||
```
|
||||
|
||||
### Configuración del Ciclo
|
||||
|
||||
Los parámetros del ciclo se pueden ajustar directamente en `app/main.py`:
|
||||
|
||||
```python
|
||||
scraper_config = {
|
||||
"cicle_time": 200, # segundos entre ciclos (recomendado ≥ 200 para evitar flood)
|
||||
"error_delay": 60, # segundos de espera tras un error antes de reintentar
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requisitos Previos
|
||||
|
||||
### Hardware Mínimo
|
||||
|
||||
| Recurso | Mínimo | Recomendado |
|
||||
|---------|--------|-------------|
|
||||
| CPU | 2 núcleos | 4 núcleos |
|
||||
| RAM | 4 GB | 8 GB |
|
||||
| Almacenamiento | 20 GB | 50 GB+ |
|
||||
|
||||
### Software Requerido
|
||||
|
||||
| Componente | Versión Mínima | Instalación |
|
||||
|-----------|----------------|-------------|
|
||||
| Docker | 20.10+ | https://docs.docker.com/engine/install/ |
|
||||
| Docker Compose | 1.29+ | Incluido en Docker Desktop |
|
||||
| Git | 2.20+ | https://git-scm.com/downloads |
|
||||
| jq (solo Linux/macOS) | 1.6+ | `sudo apt install jq` o `brew install jq` |
|
||||
|
||||
> **Nota sobre Python**: El proyecto utiliza **Python 3.11** en todos los microservicios. La versión está declarada en cada `Dockerfile` (`FROM python:3.11-slim`). No es necesario tener Python instalado localmente; Docker lo gestiona de forma aislada.
|
||||
|
||||
### Credenciales Necesarias
|
||||
|
||||
- **Telegram API ID y API Hash**: Obtener en https://my.telegram.org/apps con la cuenta que se usará para el monitoreo
|
||||
- **Número de teléfono** de esa cuenta (formato internacional, ej: `+5491112345678`)
|
||||
- Acceso a los grupos/canales de Telegram que se desean monitorear
|
||||
|
||||
---
|
||||
|
||||
## Instalación y Configuración
|
||||
|
||||
### Paso 1 — Clonar el Repositorio
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tu-usuario/tip.git
|
||||
cd tip
|
||||
```
|
||||
|
||||
### Paso 2 — Crear el Archivo `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edita `.env` con tus valores reales:
|
||||
|
||||
```env
|
||||
# ── TELEGRAM ────────────────────────────────────────────────────────────
|
||||
# Obtener en https://my.telegram.org/apps
|
||||
TELEGRAM_API_ID=123456789
|
||||
TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789
|
||||
TELEGRAM_TELEPHONE=+5491112345678
|
||||
|
||||
# ── SEGURIDAD ────────────────────────────────────────────────────────────
|
||||
# Clave compartida entre todos los microservicios (mínimo 20 caracteres)
|
||||
# Generar con: openssl rand -hex 32
|
||||
SECRET_KEY=reemplazar_con_clave_secreta_minimo_32_chars
|
||||
|
||||
# ── BASES DE DATOS ───────────────────────────────────────────────────────
|
||||
DB_ROOT_PASS_FEEDER=password_feeder
|
||||
DB_ROOT_PASS_USERS=password_users
|
||||
|
||||
# ── ADMIN (creado automáticamente en el primer inicio) ──────────────────
|
||||
ADMIN_EMAIL=admin@tudominio.com
|
||||
ADMIN_PASSWORD=contraseña_admin_segura
|
||||
```
|
||||
|
||||
### Paso 3 — Configurar `docker-compose.yml`
|
||||
|
||||
```bash
|
||||
cp docker-compose_example.yml docker-compose.yml
|
||||
```
|
||||
|
||||
Reemplaza los valores marcados con `YOUR-*`:
|
||||
|
||||
```yaml
|
||||
# Contraseñas de bases de datos
|
||||
database:
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password_feeder # ← cambiar
|
||||
|
||||
database_users:
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password_users # ← cambiar
|
||||
|
||||
# URLs de conexión a las bases de datos
|
||||
api:
|
||||
environment:
|
||||
DATABASE_URL: mariadb+pymysql://root:password_feeder@database:3306/feeder
|
||||
TELEGRAM_API_ID: TU_API_ID
|
||||
TELEGRAM_API_HASH: TU_API_HASH
|
||||
TELEGRAM_TELEPHONE: TU_TELEFONO
|
||||
SECRET_KEY: TU_SECRET_KEY
|
||||
|
||||
api_users:
|
||||
environment:
|
||||
DATABASE_URL: mariadb+pymysql://root:password_users@database_users:3307/users
|
||||
SECRET_KEY: TU_SECRET_KEY # ← debe ser la misma que api
|
||||
ADMIN_EMAIL: admin@tudominio.com
|
||||
ADMIN_PASSWORD: contraseña_admin
|
||||
|
||||
# URLs para el backup service
|
||||
backup:
|
||||
environment:
|
||||
FEEDER_DATABASE_URL: mariadb+pymysql://root:password_feeder@database:3306/feeder
|
||||
USERS_DATABASE_URL: mariadb+pymysql://root:password_users@database_users:3307/users
|
||||
SECRET_KEY: TU_SECRET_KEY
|
||||
```
|
||||
|
||||
### Paso 4 — Configurar NGINX
|
||||
|
||||
```bash
|
||||
mkdir -p nginx/ssl
|
||||
cp nginx/nginx.conf_example nginx/nginx.conf
|
||||
```
|
||||
|
||||
Edita `nginx/nginx.conf` y reemplaza `localhost` con tu dominio:
|
||||
|
||||
```nginx
|
||||
server_name tu_dominio.com;
|
||||
```
|
||||
|
||||
Para **desarrollo local sin SSL**, comenta el bloque HTTPS y usa solo el bloque HTTP.
|
||||
|
||||
**Para producción con Let's Encrypt**:
|
||||
|
||||
```bash
|
||||
# Instalar certbot (fuera de Docker)
|
||||
sudo apt install certbot
|
||||
|
||||
# Generar certificados
|
||||
sudo certbot certonly --standalone -d tu_dominio.com
|
||||
|
||||
# Copiar al directorio del proyecto
|
||||
sudo cp /etc/letsencrypt/live/tu_dominio.com/fullchain.pem ./nginx/ssl/
|
||||
sudo cp /etc/letsencrypt/live/tu_dominio.com/privkey.pem ./nginx/ssl/
|
||||
sudo chown $(whoami):$(whoami) ./nginx/ssl/*
|
||||
```
|
||||
|
||||
### Paso 5 — Iniciar los Contenedores
|
||||
|
||||
```bash
|
||||
# Construir imágenes e iniciar todos los servicios
|
||||
docker compose up -d --build
|
||||
|
||||
# Verificar que todos estén corriendo
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Resultado esperado:
|
||||
|
||||
```
|
||||
NAME SERVICE STATUS
|
||||
feeder_api api running
|
||||
users_api api_users running
|
||||
frontend frontend running
|
||||
backup_service backup running
|
||||
feeder_db database running (healthy)
|
||||
users_db database_users running (healthy)
|
||||
nginx_proxy nginx running
|
||||
```
|
||||
|
||||
```bash
|
||||
# Ver logs en tiempo real
|
||||
docker compose logs -f api api_users
|
||||
```
|
||||
|
||||
### Paso 6 — Autenticar la Sesión de Telegram
|
||||
|
||||
La primera vez (o tras reinstalación) es necesario autenticar el cliente de Telegram:
|
||||
|
||||
1. Accede a la UI como **admin**: https://tu_dominio.com
|
||||
2. Ve a **Panel de Administración** → **Sistema** → **Alimentador**
|
||||
3. Haz clic en **Iniciar Nueva Sesión**
|
||||
4. Revisa el código de verificación que llegó a tu cuenta de Telegram
|
||||
5. Ingrésalo en el campo correspondiente (más la contraseña 2FA si aplica)
|
||||
6. El alimentador se activará automáticamente
|
||||
|
||||
### Paso 7 — Agregar Grupos a Monitorear
|
||||
|
||||
1. Ve a **Grupos y Canales** → **Agregar grupo**
|
||||
2. Abre el grupo en Telegram Web (`web.telegram.org`)
|
||||
3. Copia la URL completa (contiene el ID: `.../#-100XXXXXXXXX`)
|
||||
4. Pégala en el campo y haz clic en **Agregar Grupo**
|
||||
5. El sistema valida el acceso contra Telegram y lo registra
|
||||
|
||||
---
|
||||
|
||||
## Gestión de Backups
|
||||
|
||||
### Generar un Backup
|
||||
|
||||
#### Desde la UI
|
||||
|
||||
1. Accede como **admin**
|
||||
2. Ve a **Panel de Administración** → **Sistema** → **Backup del Sistema**
|
||||
3. Selecciona los componentes a incluir
|
||||
4. Haz clic en **⬇️ Generar y Descargar Backup**
|
||||
5. Guarda el archivo `tip_backup_YYYYMMDD_HHMMSS.zip` en lugar seguro
|
||||
|
||||
#### Desde la línea de comandos
|
||||
|
||||
```bash
|
||||
# Primero, obtener un token JWT (reemplazar por tus credenciales)
|
||||
TOKEN=$(curl -s -X POST http://localhost:8090/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@tudominio.com","password":"tu_password"}' \
|
||||
| jq -r '.access_token')
|
||||
|
||||
# Solicitar el backup
|
||||
curl -X GET \
|
||||
"http://localhost:8099/backup?include_db=true&include_config=true&include_ssl=true&include_sessions=true" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-o "tip_backup_$(date +%Y%m%d_%H%M%S).zip"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restaurar un Backup
|
||||
|
||||
> ⚠️ **ADVERTENCIA**: La restauración sobrescribe datos existentes. Asegúrate de que es lo que necesitas antes de continuar.
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 1. Coloca el archivo tip_backup_*.zip en la raíz del proyecto
|
||||
# 2. Abre PowerShell como Administrador y navega al directorio
|
||||
cd C:\ruta\al\proyecto
|
||||
|
||||
# 3. Habilitar ejecución de scripts (si no está habilitado)
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
# 4. Ejecutar el script
|
||||
.\restore_backup.ps1
|
||||
```
|
||||
|
||||
#### Linux / macOS (Bash)
|
||||
|
||||
```bash
|
||||
# 1. Coloca el archivo tip_backup_*.zip en la raíz del proyecto
|
||||
# 2. Dar permisos de ejecución al script
|
||||
chmod +x restore_backup.sh
|
||||
|
||||
# 3. Ejecutar el script
|
||||
./restore_backup.sh
|
||||
```
|
||||
|
||||
#### ¿Qué hace el script de restauración?
|
||||
|
||||
```
|
||||
INICIO
|
||||
│
|
||||
├─ 1. Solicita confirmación al usuario
|
||||
│
|
||||
├─ 2. Localiza el tip_backup_*.zip más reciente en el directorio actual
|
||||
│
|
||||
├─ 3. Extrae el contenido en ./temp_restore/
|
||||
│
|
||||
├─ 4. Lee manifest.json → identifica componentes incluidos y advertencias
|
||||
│
|
||||
├─ 5. Obtiene credenciales de las bases de datos desde docker-compose.yml
|
||||
│
|
||||
├─ 6. Por cada componente en el manifiesto:
|
||||
│ ├─ database:feeder → levanta feeder_db y restaura el .sql
|
||||
│ ├─ database:users → levanta users_db y restaura el .sql
|
||||
│ ├─ config:* → copia nginx.conf y/o docker-compose.yml
|
||||
│ ├─ ssl → copia certificados a ./nginx/ssl/
|
||||
│ └─ telegram_sessions → copia .session a ./app/telegram_sessions/
|
||||
│
|
||||
├─ 7. Muestra las advertencias registradas en el manifiesto
|
||||
│
|
||||
├─ 8. Elimina ./temp_restore/
|
||||
│
|
||||
└─ 9. Pregunta si desea reiniciar los contenedores (docker compose down && up)
|
||||
```
|
||||
|
||||
#### Dependencias del Script
|
||||
|
||||
| Dependencia | Windows | Linux/macOS |
|
||||
|-------------|---------|-------------|
|
||||
| Docker + Docker Compose | ✅ Requerido | ✅ Requerido |
|
||||
| jq | No requerido (usa PowerShell nativo) | ✅ `sudo apt install jq` |
|
||||
|
||||
---
|
||||
|
||||
### Buenas Prácticas de Backup
|
||||
|
||||
- **Frecuencia**: Realiza backups diarios en producción, especialmente antes de actualizaciones
|
||||
- **Validación**: Prueba la restauración en un entorno de prueba periódicamente
|
||||
- **Almacenamiento externo**: Guarda copias en un servidor remoto o servicio de almacenamiento (S3, OneDrive, etc.)
|
||||
- **Encriptación**: Si el backup contiene sesiones o datos sensibles, encríptalo antes de transferirlo:
|
||||
```bash
|
||||
# Linux/macOS
|
||||
openssl enc -aes-256-cbc -salt -in tip_backup_*.zip -out tip_backup_encrypted.zip
|
||||
|
||||
# Descifrar
|
||||
openssl enc -d -aes-256-cbc -in tip_backup_encrypted.zip -out tip_backup_restored.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Los contenedores no inician
|
||||
|
||||
```bash
|
||||
# Ver logs completos de todos los servicios
|
||||
docker compose logs
|
||||
|
||||
# Ver logs de un servicio en particular
|
||||
docker compose logs api
|
||||
docker compose logs database
|
||||
|
||||
# Reconstruir imágenes sin caché
|
||||
docker compose down
|
||||
docker compose up -d --build --force-recreate
|
||||
```
|
||||
|
||||
### Error de conexión a la base de datos
|
||||
|
||||
```bash
|
||||
# Verificar que MariaDB está aceptando conexiones
|
||||
docker compose exec database mysql -u root -p -e "SELECT 1;"
|
||||
|
||||
# Verificar conectividad entre contenedores
|
||||
docker compose exec api ping database
|
||||
```
|
||||
|
||||
### El alimentador aparece como inactivo
|
||||
|
||||
```bash
|
||||
# Verificar si hay archivos de sesión de Telegram
|
||||
docker compose exec api ls -la /app/telegram_sessions/
|
||||
|
||||
# Si no hay sesión o está corrupta, eliminarla y autenticar de nuevo desde la UI
|
||||
docker compose exec api rm -f /app/telegram_sessions/session_*
|
||||
# Luego: Panel de Administración → Sistema → Iniciar Nueva Sesión
|
||||
```
|
||||
|
||||
### NGINX devuelve 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# Verificar que los servicios upstream estén corriendo
|
||||
docker compose ps
|
||||
|
||||
# Validar la configuración de NGINX
|
||||
docker compose exec nginx nginx -t
|
||||
|
||||
# Ver logs de NGINX
|
||||
docker compose logs nginx
|
||||
```
|
||||
|
||||
### Las alertas no se generan automáticamente
|
||||
|
||||
```bash
|
||||
# Verificar que existen reglas activas
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/rules/
|
||||
|
||||
# Verificar que el feeder está capturando mensajes
|
||||
docker compose logs api | grep -E "Feeding|Cycle|feeder_loop"
|
||||
|
||||
# Verificar que no hay errores de regex en las reglas
|
||||
docker compose logs api | grep -i "regex\|WARN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
tip/
|
||||
├── app/ # API Feeder
|
||||
│ ├── alembic/ # Migraciones de base de datos
|
||||
│ ├── integrations/ # Cliente Telegram (chats.py, api_implementations.py)
|
||||
│ ├── routers/ # Endpoints REST
|
||||
│ ├── main.py # Entry point + scraper loop
|
||||
│ ├── models.py # Modelos SQLAlchemy
|
||||
│ ├── schemas.py # Schemas Pydantic
|
||||
│ ├── auth.py # JWT helpers
|
||||
│ └── audit.py # Logger de auditoría
|
||||
├── usuarios/ # API Users
|
||||
│ ├── main.py
|
||||
│ ├── models.py
|
||||
│ ├── schemas.py
|
||||
│ ├── crud.py
|
||||
│ ├── services.py
|
||||
│ └── dependencies.py
|
||||
├── frontend/ # UI Streamlit
|
||||
│ ├── app.py
|
||||
│ ├── src/
|
||||
│ │ ├── api.py # Cliente HTTP hacia las APIs
|
||||
│ │ ├── auth.py # Login / sesión
|
||||
│ │ └── pages/
|
||||
│ │ ├── dashboard.py
|
||||
│ │ └── views/ # alerts, messages, rules, groups, stats, ...
|
||||
│ └── .streamlit/
|
||||
│ ├── config.toml # Tema visual
|
||||
│ └── secrets.toml
|
||||
├── backup_service/ # Backup Service
|
||||
│ ├── main.py
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── nginx/
|
||||
│ ├── nginx.conf # Configuración del proxy (generada desde el ejemplo)
|
||||
│ └── ssl/ # Certificados SSL (excluidos del repo)
|
||||
├── Dockerfile # Imagen API Feeder
|
||||
├── Dockerfile_users # Imagen API Users
|
||||
├── Dockerfile_frontend # Imagen Frontend
|
||||
├── docker-compose.yml # Orquestación (generado desde el ejemplo)
|
||||
├── docker-compose_example.yml # Plantilla de configuración
|
||||
├── entrypoint.sh # Ejecuta migraciones Alembic antes de iniciar la API
|
||||
├── restore_backup.sh # Script de restauración (Linux/macOS)
|
||||
├── restore_backup.ps1 # Script de restauración (Windows)
|
||||
├── requirements.txt # Dependencias API Feeder
|
||||
├── requirements_users.txt # Dependencias API Users
|
||||
├── requirements_frontend.txt # Dependencias Frontend
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Plataforma desarrollada para uso interno — Municipio de Paraná.*
|
||||
@@ -0,0 +1,8 @@
|
||||
# Exportar los módulos principales
|
||||
from .database import Base, get_db
|
||||
from . import models
|
||||
from . import schemas
|
||||
#from . import crud
|
||||
|
||||
#__all__ = ["Base", "get_db", "models", "schemas", "crud"]
|
||||
__all__ = ["Base", "get_db", "models", "schemas"]
|
||||
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# sqlalchemy url; will be overridden by env var if present
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,63 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
config = context.config
|
||||
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
from models import Base
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
def _get_url():
|
||||
# first try environment variable, then fall back to config file
|
||||
return os.getenv("DATABASE_URL") or config.get_main_option("sqlalchemy.url")
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in "offline" mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = _get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in "online" mode."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
url=_get_url(),
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
app/audit.py
|
||||
Helper centralizado para registrar eventos de auditoría.
|
||||
Se llama desde los routers después de cada operación exitosa.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
|
||||
|
||||
def _serialize(obj: Any) -> Optional[str]:
|
||||
"""Serializa un objeto a JSON string. Devuelve None si obj es None."""
|
||||
if obj is None:
|
||||
return None
|
||||
if hasattr(obj, '__dict__'):
|
||||
# SQLAlchemy model — serializar atributos no privados
|
||||
data = {
|
||||
k: v for k, v in obj.__dict__.items()
|
||||
if not k.startswith('_')
|
||||
}
|
||||
# Convertir datetime a string para que sea serializable
|
||||
for key, value in data.items():
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return json.dumps(data, default=str)
|
||||
if isinstance(obj, dict):
|
||||
return json.dumps(obj, default=str)
|
||||
return str(obj)
|
||||
|
||||
|
||||
def log_action(
|
||||
db: Session,
|
||||
entity_type: str,
|
||||
entity_id: Any,
|
||||
action: str,
|
||||
user_id: Optional[Any] = None,
|
||||
before: Any = None,
|
||||
after: Any = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> None:
|
||||
# Convertir "system" (feeder) a -1, y cualquier valor no numérico también
|
||||
if user_id == "system" or user_id is None:
|
||||
resolved_user_id = -1
|
||||
else:
|
||||
try:
|
||||
resolved_user_id = int(user_id)
|
||||
except (ValueError, TypeError):
|
||||
resolved_user_id = -1
|
||||
|
||||
entry = models.AuditLog(
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id),
|
||||
action=action,
|
||||
user_id=resolved_user_id,
|
||||
before_value=_serialize(before),
|
||||
after_value=_serialize(after),
|
||||
timestamp=datetime.utcnow(),
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(entry)
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
auth.py:
|
||||
Métodos y variables globales necesarias para la autenticación y auditoria.
|
||||
Notas:
|
||||
- El microservicio requiere que la aplicación que lo utilice comparta su SECRET_KEY
|
||||
- El microservicio requiere que la aplicación que lo utilice implemente un campo "user_id" en su SECRET_KEY y una expiración: exp
|
||||
* No se recomienda implementar dos sistemas que manipulen este token en simultaneo, puede provocar incongruencias en el módulo de auditoria. *
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY no configurada. Abortando inicio.")
|
||||
if len(SECRET_KEY) < 20:
|
||||
raise RuntimeError("SECRET_KEY demasiado corta")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
"""
|
||||
Obtiene un ID de usuario del JWT para auditoria.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Token inválido")
|
||||
return user_id # simplemente el id
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Token inválido")
|
||||
|
||||
def get_system_token() -> str:
|
||||
"""Genera o renueva el token de sistema para uso interno."""
|
||||
payload = {
|
||||
"user_id": "system",
|
||||
"exp": datetime.utcnow() + timedelta(hours=24)
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
# Token global para reutilizar
|
||||
_system_token: str = None
|
||||
|
||||
def get_internal_headers() -> dict:
|
||||
"""Headers con token de sistema para llamadas internas."""
|
||||
global _system_token
|
||||
|
||||
# Renovar si no existe o está por vencer
|
||||
if _system_token is None or _token_expiring_soon(_system_token):
|
||||
_system_token = get_system_token()
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {_system_token}"
|
||||
}
|
||||
|
||||
def _token_expiring_soon(token: str) -> bool:
|
||||
"""
|
||||
Verifica la expiración del token.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
exp = datetime.fromtimestamp(payload["exp"])
|
||||
return exp - datetime.utcnow() < timedelta(hours=1)
|
||||
except Exception:
|
||||
return True # si falla, renovar
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
database.py
|
||||
|
||||
Contiene configuración y dependencias para manipular la base de datos.
|
||||
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=10, # Aumenta el tamaño del pool
|
||||
max_overflow=20, # Permite conexiones adicionales
|
||||
pool_timeout=30 # Tiempo de espera para obtener conexión
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency para inyectar la sesión de BD
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
api_implementations.py
|
||||
Contiene métodos utilizados por el alimentador a lo largo de "chats.py" para manipular los endpoints del sistema.
|
||||
"""
|
||||
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from typing import Dict, Optional
|
||||
from auth import get_internal_headers
|
||||
|
||||
##TODO EL QUILOMBO NUEVO##
|
||||
API_BASE_URL = os.getenv('API_URL')
|
||||
TIMEOUT = 5.0
|
||||
|
||||
router = APIRouter()
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def patch_api_sync(validated_data: Dict, endpoint: str) -> Optional[Dict]:
|
||||
"""Función síncrona para hacer PUT a la API"""
|
||||
try:
|
||||
response = requests.patch(
|
||||
f"{API_BASE_URL}/{endpoint}/",
|
||||
json=validated_data,
|
||||
headers=get_internal_headers(),
|
||||
timeout=TIMEOUT
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_400_BAD_REQUEST:
|
||||
error_detail = response.json().get("detail", "")
|
||||
return error_detail
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Connection failed: {str(e)}"
|
||||
)
|
||||
|
||||
def post_api_sync(validated_data: Dict, endpoint: str) -> Optional[Dict]:
|
||||
"""Función síncrona para hacer POST a la API"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_BASE_URL}/{endpoint}/",
|
||||
json=validated_data,
|
||||
headers=get_internal_headers(),
|
||||
timeout=TIMEOUT
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_400_BAD_REQUEST:
|
||||
error_detail = response.json().get("detail", "")
|
||||
if "already exists" in error_detail:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Connection failed: {str(e)}"
|
||||
)
|
||||
|
||||
def get_api_sync(endpoint: str):
|
||||
"""Función síncrona para hacer GET a la API"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{API_BASE_URL}/{endpoint}/",
|
||||
headers=get_internal_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Connection failed: {str(e)}"
|
||||
)
|
||||
|
||||
def create_telegram_group(group_data: Dict) -> Optional[Dict]:
|
||||
"""Crea un grupo en la API remota (versión síncrona)"""
|
||||
try:
|
||||
validated_data = {
|
||||
"id_telegram": group_data["id_telegram"],
|
||||
"name": group_data["name"],
|
||||
"description": group_data["description"],
|
||||
"type": group_data["type"],
|
||||
"message_position": 0
|
||||
}
|
||||
return post_api_sync(validated_data, "groups")
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid group data: {str(e)}"
|
||||
)
|
||||
|
||||
def refresh_telegram_group(channel: int, group_data: Dict) -> Optional[Dict]:
|
||||
"""Crea un grupo en la API remota (versión síncrona)"""
|
||||
try:
|
||||
validated_data = {
|
||||
"name": str(group_data["name"]),
|
||||
"description": str(group_data["description"]),
|
||||
"type": str(group_data["type"])
|
||||
}
|
||||
print(f"La data que vamos intentar cargar es {validated_data}")
|
||||
return patch_api_sync(validated_data, f"groups/{channel}")
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid group data: {str(e)}"
|
||||
)
|
||||
|
||||
def act_message_posittion(group_id: int, message_id: int):
|
||||
try:
|
||||
validated_data = {
|
||||
"message_position": message_id
|
||||
}
|
||||
return patch_api_sync(validated_data, f"groups/{group_id}/update-position")
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid group data: {str(e)}"
|
||||
)
|
||||
|
||||
def create_sender(sender: Dict) -> Optional[Dict]:
|
||||
"""Crea un mensaje en la API remota (versión síncrona)"""
|
||||
try:
|
||||
sender_data = {
|
||||
"id_telegram": sender["id_telegram"],
|
||||
"type": sender["type"],
|
||||
"username": sender["username"],
|
||||
"first_name": str(sender["first_name"]),
|
||||
"last_name": str(sender["last_name"]),
|
||||
"phone": str(sender["phone"])
|
||||
}
|
||||
print(sender_data)
|
||||
|
||||
return post_api_sync(sender_data, "senders")
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid group data: {str(e)}"
|
||||
)
|
||||
|
||||
def create_message(message: Dict) -> Optional[Dict]:
|
||||
"""Crea un mensaje en la API remota (versión síncrona)"""
|
||||
try:
|
||||
message_data = {
|
||||
"id_mess_g": message["id_mess_g"],
|
||||
"content": str(message["content"]),
|
||||
"date": message["date"],
|
||||
"sender_id": message["sender_id"],
|
||||
"group_id": message["group_id"]
|
||||
}
|
||||
response = post_api_sync(message_data, "messages")
|
||||
|
||||
if response is None:
|
||||
return None
|
||||
|
||||
if message['attachments'] is not None:
|
||||
try:
|
||||
create_attachment(message['attachments'])
|
||||
except Exception as e:
|
||||
print(f"Failed to create attachment for message {response['id_mess_g']}: {str(e)}")
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid group data: {str(e)}"
|
||||
)
|
||||
|
||||
def create_attachment(attachment: Dict) -> Optional[Dict]:
|
||||
"""Crea un mensaje en la API remota (versión síncrona)"""
|
||||
try:
|
||||
attachment_data = {
|
||||
"message_id": attachment["message_id"],
|
||||
"type": attachment["type"],
|
||||
"description": str(attachment["description"]),
|
||||
"isDownloaded": "false",
|
||||
"group_id": attachment["group_id"]
|
||||
}
|
||||
print(f"Trying to load attachment data: {attachment_data}")
|
||||
return post_api_sync(attachment_data, "attachments")
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid group data: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,833 @@
|
||||
"""
|
||||
Chats.py
|
||||
Contiene la lógica del alimentador.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Dict
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import result
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from telethon import TelegramClient, functions
|
||||
from telethon.errors import MediaEmptyError
|
||||
from telethon.tl.types import (
|
||||
Document, MessageMediaPhoto, MessageMediaDocument, MessageMediaWebPage,
|
||||
MessageMediaContact, MessageMediaGeo, MessageMediaVenue, MessageMediaGame,
|
||||
MessageMediaInvoice, DocumentAttributeAudio, DocumentAttributeVideo,
|
||||
DocumentAttributeSticker, DocumentAttributeAnimated, DocumentAttributeFilename,
|
||||
Channel, Chat
|
||||
)
|
||||
|
||||
from integrations.api_implementations import (
|
||||
get_api_sync, post_api_sync, create_sender, create_message,
|
||||
act_message_posittion, refresh_telegram_group
|
||||
)
|
||||
from io import BytesIO
|
||||
|
||||
class TelegramClientManager:
|
||||
"""Responsable únicamente de la conexión, autenticación y ciclo de vida del cliente."""
|
||||
|
||||
def __init__(self):
|
||||
load_dotenv()
|
||||
self.api_id = os.getenv("TELEGRAM_API_ID")
|
||||
self.api_hash = os.getenv("TELEGRAM_API_HASH")
|
||||
self.download_path = os.getenv("DOWNLOAD_PATH", "downloads")
|
||||
self.client: Optional[TelegramClient] = None
|
||||
self.is_authorized = False
|
||||
self.session_path = None
|
||||
|
||||
def get_sessions_file(self):
|
||||
"""Busca el archivo de sesión más reciente en telegram_sessions/."""
|
||||
session_dir = os.path.join(os.getcwd(), "telegram_sessions")
|
||||
session_files = glob.glob(os.path.join(session_dir, "session_*.session"))
|
||||
if not session_files:
|
||||
raise RuntimeError("No se encontró ningún archivo de sesión en telegram_sessions/")
|
||||
return max(session_files, key=os.path.getmtime)
|
||||
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
Utiliza un archivo de sesión existente para autenticar una sesión de Telegram mediante un cliente de Telethon.
|
||||
"""
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
session_path = self.get_sessions_file().replace(".session", "")
|
||||
print(f"[INFO] Usando sesión: {session_path}")
|
||||
|
||||
self.client = TelegramClient(
|
||||
session_path,
|
||||
self.api_id,
|
||||
self.api_hash,
|
||||
connection_retries=5,
|
||||
retry_delay=5,
|
||||
auto_reconnect=True
|
||||
)
|
||||
# start() en vez de connect() — inicializa el loop interno correctamente
|
||||
self.client.start()
|
||||
self.is_authorized = self.client.is_user_authorized()
|
||||
|
||||
if self.is_authorized:
|
||||
print("[INFO] Cliente Telegram conectado y autorizado")
|
||||
else:
|
||||
print("[WARN] Sesión no autorizada")
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Verifica si la sesión es válida"""
|
||||
session_filename = self.get_sessions_file()
|
||||
session_src = f"{session_filename}"
|
||||
|
||||
if not os.path.exists(session_src):
|
||||
raise RuntimeError(f"Session file no encontrado: {session_src}")
|
||||
|
||||
session_dir = os.path.dirname(session_filename)
|
||||
tmp_session = tempfile.mktemp(prefix="val_session_", dir=session_dir)
|
||||
shutil.copy2(session_src, f"{tmp_session}.session")
|
||||
|
||||
result = {"is_active": False, "error": None}
|
||||
|
||||
def _validate_in_thread():
|
||||
tmp_client = None
|
||||
tmp_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(tmp_loop)
|
||||
try:
|
||||
tmp_client = TelegramClient(
|
||||
tmp_session,
|
||||
self.api_id,
|
||||
self.api_hash,
|
||||
loop=tmp_loop,
|
||||
)
|
||||
tmp_loop.run_until_complete(tmp_client.connect())
|
||||
|
||||
if not tmp_loop.run_until_complete(tmp_client.is_user_authorized()):
|
||||
result["error"] = "Sesión no autorizada"
|
||||
result["is_active"] = False
|
||||
return
|
||||
|
||||
result["is_active"] = True
|
||||
print("[is_active] OK — Sesión válida")
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
result["is_active"] = False
|
||||
print(f"[is_active] ERROR: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
if tmp_client:
|
||||
try:
|
||||
tmp_loop.run_until_complete(tmp_client.disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.set_event_loop(None)
|
||||
try:
|
||||
tmp_loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
tmp_file = f"{tmp_session}.session"
|
||||
if os.path.exists(tmp_file):
|
||||
try:
|
||||
os.remove(tmp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
thread = threading.Thread(target=_validate_in_thread, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=30)
|
||||
|
||||
if thread.is_alive():
|
||||
result["error"] = "Timeout al validar la sesión en Telegram"
|
||||
result["is_active"] = False
|
||||
|
||||
print(result)
|
||||
return result["is_active"]
|
||||
|
||||
def get_client(self) -> Optional[TelegramClient]:
|
||||
"""Devuelve el cliente, reconectando si es necesario."""
|
||||
if self.client is None:
|
||||
self.connect()
|
||||
return self.client
|
||||
|
||||
if not self.client.is_connected():
|
||||
print("[INFO] Cliente desconectado, reconectando...")
|
||||
self.client.connect()
|
||||
time.sleep(1)
|
||||
self.is_authorized = self.client.is_user_authorized()
|
||||
|
||||
return self.client
|
||||
|
||||
def disconnect(self):
|
||||
"""Desconecta el cliente de forma segura."""
|
||||
if self.client and self.client.is_connected():
|
||||
self.client.disconnect()
|
||||
print("[INFO] Cliente Telegram desconectado")
|
||||
|
||||
def __del__(self):
|
||||
self.disconnect()
|
||||
|
||||
class DataTransformer:
|
||||
"""Métodos estáticos para transformar datos de Telethon a dicts."""
|
||||
|
||||
@staticmethod
|
||||
def get_message_attachment_info(message, message_id: int, group_id: int) -> Optional[Dict]:
|
||||
"""Extrae y estructura la información del adjunto de un mensaje."""
|
||||
if not hasattr(message, 'media') or not message.media:
|
||||
return None
|
||||
|
||||
media = message.media
|
||||
info = {
|
||||
'message_id': message_id,
|
||||
'group_id': group_id,
|
||||
'type': None,
|
||||
'description': None,
|
||||
'isDownloaded': False
|
||||
}
|
||||
|
||||
if isinstance(media, MessageMediaPhoto):
|
||||
info.update({
|
||||
'type': 'photo',
|
||||
'description': f'Photo (ID: {media.photo.id})'
|
||||
})
|
||||
|
||||
elif isinstance(media, MessageMediaDocument):
|
||||
doc = media.document
|
||||
if isinstance(doc, Document):
|
||||
attr_map = {
|
||||
DocumentAttributeAudio: lambda a: ('voice', 'Voice message') if getattr(a, 'voice', False) else ('audio', 'Audio'),
|
||||
DocumentAttributeVideo: lambda a: ('video', 'Video'),
|
||||
DocumentAttributeSticker: lambda a: ('sticker', 'Sticker'),
|
||||
DocumentAttributeAnimated: lambda a: ('gif', 'GIF'),
|
||||
}
|
||||
|
||||
tipo = None
|
||||
descripcion = None
|
||||
for attr in doc.attributes:
|
||||
for attr_class, resolver in attr_map.items():
|
||||
if isinstance(attr, attr_class):
|
||||
tipo, descripcion = resolver(attr)
|
||||
break
|
||||
if tipo:
|
||||
break
|
||||
|
||||
if not tipo:
|
||||
filename = next(
|
||||
(a.file_name for a in doc.attributes if isinstance(a, DocumentAttributeFilename)),
|
||||
doc.mime_type
|
||||
)
|
||||
tipo, descripcion = 'document', f'Document ({filename})'
|
||||
|
||||
info.update({'type': tipo, 'description': descripcion})
|
||||
|
||||
elif isinstance(media, MessageMediaWebPage):
|
||||
url = getattr(media.webpage, 'url', str(media.webpage))
|
||||
info.update({'type': 'webpage', 'description': f'URL: {url}'})
|
||||
|
||||
elif isinstance(media, MessageMediaContact):
|
||||
info.update({
|
||||
'type': 'contact',
|
||||
'description': f'Contact: {media.first_name} {media.last_name}'
|
||||
})
|
||||
|
||||
elif isinstance(media, MessageMediaGeo):
|
||||
info.update({
|
||||
'type': 'location',
|
||||
'description': f'Location (lat, long): {media.geo.lat}, {media.geo.long}'
|
||||
})
|
||||
|
||||
elif isinstance(media, MessageMediaVenue):
|
||||
info.update({
|
||||
'type': 'venue',
|
||||
'description': f'Place: {media.title} ({media.address})'
|
||||
})
|
||||
|
||||
elif isinstance(media, MessageMediaGame):
|
||||
info.update({'type': 'game', 'description': f'Game: {media.game.title}'})
|
||||
|
||||
elif isinstance(media, MessageMediaInvoice):
|
||||
info.update({'type': 'invoice', 'description': f'Invoice: {media.title}'})
|
||||
|
||||
else:
|
||||
info.update({
|
||||
'type': 'unknown',
|
||||
'description': f'Unknown media type: {type(media)}'
|
||||
})
|
||||
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
def get_user_info(client: TelegramClient, user_id: int) -> Optional[Dict]:
|
||||
"""Obtiene información de un usuario por ID."""
|
||||
try:
|
||||
# Sin "with client" para no desconectar el cliente compartido
|
||||
user = client.loop.run_until_complete(client.get_entity(user_id))
|
||||
return {
|
||||
'id_telegram': user_id,
|
||||
'type': 'bot' if getattr(user, 'bot', False) else 'user',
|
||||
'username': getattr(user, 'username', None),
|
||||
'first_name': getattr(user, 'first_name', None),
|
||||
'last_name': getattr(user, 'last_name', None),
|
||||
'phone': getattr(user, 'phone', None)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting user info for {user_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
class TelegramScraper:
|
||||
"""Lógica de scraping de canales. No maneja conexión directamente."""
|
||||
|
||||
def __init__(self, client_manager: TelegramClientManager):
|
||||
self.manager = client_manager
|
||||
self.channel_identifier = None
|
||||
self.first_id = 0
|
||||
self.ChannelMessages = []
|
||||
|
||||
@property
|
||||
def client(self) -> TelegramClient:
|
||||
"""Siempre devuelve un cliente conectado."""
|
||||
return self.manager.get_client()
|
||||
|
||||
# --- Helpers de paginación ---
|
||||
|
||||
def _last_message_id(self, channel_messages) -> int:
|
||||
try:
|
||||
return channel_messages.messages[0].id
|
||||
except IndexError:
|
||||
return 0
|
||||
|
||||
def _first_message_id(self, channel_messages) -> int:
|
||||
try:
|
||||
return channel_messages.messages[-1].id
|
||||
except IndexError:
|
||||
return 0
|
||||
|
||||
# --- Configuración de canal ---
|
||||
|
||||
def set_chat_id(self, channel: str):
|
||||
try:
|
||||
if '-' in channel:
|
||||
self.channel_identifier = int(channel)
|
||||
else:
|
||||
self.channel_identifier = channel
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error resolving channel: {e}")
|
||||
|
||||
def get_channel_info(self) -> Optional[Dict]:
|
||||
"""
|
||||
Obtiene información de la entidad y determina su tipo correctamente.
|
||||
|
||||
Tipos de entidad en Telethon:
|
||||
- Channel, megagroup=False → canal (público o privado)
|
||||
- Channel, megagroup=True → supergrupo (usa prefijo -100)
|
||||
- Chat → grupo normal (NO usa prefijo -100)
|
||||
"""
|
||||
try:
|
||||
|
||||
entity = self.client.loop.run_until_complete(
|
||||
self.client.get_entity(self.channel_identifier)
|
||||
)
|
||||
|
||||
username = getattr(entity, 'username', None)
|
||||
is_public = bool(username)
|
||||
entity_id = getattr(entity, 'id', None)
|
||||
title = getattr(entity, 'title', '')
|
||||
description = getattr(entity, 'about', '')
|
||||
|
||||
if isinstance(entity, Channel):
|
||||
if entity.megagroup:
|
||||
# Supergrupo — usa prefijo -100
|
||||
chat_type = 'public_supergroup' if is_public else 'private_supergroup'
|
||||
else:
|
||||
# Canal puro
|
||||
chat_type = 'public_channel' if is_public else 'private_channel'
|
||||
elif isinstance(entity, Chat):
|
||||
# Grupo normal — NO usa prefijo -100
|
||||
chat_type = 'group'
|
||||
else:
|
||||
# Usuario u otro tipo — tratarlo como privado
|
||||
chat_type = 'private_channel'
|
||||
|
||||
return {
|
||||
'type': chat_type,
|
||||
'is_group': isinstance(entity, Chat),
|
||||
'is_megagroup': isinstance(entity, Channel) and entity.megagroup,
|
||||
'username': username or 'private',
|
||||
'id': entity_id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting channel info: {e}")
|
||||
if "authorization" in str(e).lower():
|
||||
self.manager.is_authorized = False
|
||||
return None
|
||||
# --- Obtención de mensajes ---
|
||||
|
||||
def _get_channel_messages(self, limit: int, id_init: int, min_id: int, max_id: int):
|
||||
"""
|
||||
Obtiene un lote de mensajes de un canal.
|
||||
"""
|
||||
try:
|
||||
return self.client.loop.run_until_complete(
|
||||
self.client(functions.messages.GetHistoryRequest(
|
||||
peer=self.channel_identifier,
|
||||
limit=limit,
|
||||
offset_date=0,
|
||||
add_offset=0,
|
||||
offset_id=id_init,
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
hash=0
|
||||
))
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error fetching messages: {e}")
|
||||
if "authorization" in str(e).lower():
|
||||
self.manager.is_authorized = False
|
||||
return None
|
||||
|
||||
def set_channel_messages(self):
|
||||
"""Carga todos los mensajes del canal en self.ChannelMessages."""
|
||||
channel_messages_array = []
|
||||
first_messages = self._get_channel_messages(1, 0, 0, 0)
|
||||
self.first_id = self._first_message_id(first_messages)
|
||||
last_id = self._last_message_id(first_messages)
|
||||
|
||||
while last_id != 0:
|
||||
messages = self._get_channel_messages(100, last_id, 0, 0)
|
||||
channel_messages_array.append(messages)
|
||||
last_id = self._first_message_id(messages)
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"Finished, we made {len(channel_messages_array)} cycles")
|
||||
self.ChannelMessages = channel_messages_array
|
||||
|
||||
def refresh_chat(self):
|
||||
"""Carga solo los mensajes nuevos desde first_id."""
|
||||
channel_messages_array = []
|
||||
first_messages = self._get_channel_messages(1, 0, 0, 0)
|
||||
last_id = self._last_message_id(first_messages)
|
||||
|
||||
while last_id > self.first_id:
|
||||
messages = self._get_channel_messages(100, last_id, self.first_id, 0)
|
||||
channel_messages_array.append(messages)
|
||||
print(f"last id: {last_id}, new last_id: {self._first_message_id(messages)}")
|
||||
last_id = self._first_message_id(messages)
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"Finished, we made {len(channel_messages_array)} cycles")
|
||||
self.ChannelMessages = channel_messages_array
|
||||
|
||||
# --- Procesamiento y publicación ---
|
||||
|
||||
def _preload_senders(self) -> Dict:
|
||||
"""Precarga y publica los senders únicos encontrados en los mensajes."""
|
||||
unique_sender_ids = {
|
||||
msg.sender_id
|
||||
for channel_message in self.ChannelMessages
|
||||
for msg in channel_message.messages
|
||||
if hasattr(msg, 'sender_id') and msg.sender_id
|
||||
}
|
||||
print(f"🔍 {len(unique_sender_ids)} usuarios únicos encontrados")
|
||||
|
||||
users_cache = {}
|
||||
for sender_id in unique_sender_ids:
|
||||
user_info = DataTransformer.get_user_info(self.client, sender_id)
|
||||
if user_info:
|
||||
users_cache[sender_id] = user_info
|
||||
try:
|
||||
exists = get_api_sync(f"senders/{sender_id}")
|
||||
except Exception:
|
||||
exists = None
|
||||
if exists is None:
|
||||
create_sender(user_info)
|
||||
|
||||
return users_cache
|
||||
|
||||
def get_and_post_message_info(self) -> List[Dict]:
|
||||
"""Procesa y publica todos los mensajes cargados en self.ChannelMessages."""
|
||||
users_cache = self._preload_senders()
|
||||
msgs = []
|
||||
|
||||
for channel_message in self.ChannelMessages:
|
||||
for msg in channel_message.messages:
|
||||
sender_id = getattr(msg, 'sender_id', None)
|
||||
message = {
|
||||
'id_mess_g': msg.id,
|
||||
'content': getattr(msg, 'message', ''),
|
||||
'date': msg.date.isoformat(),
|
||||
'attachments': DataTransformer.get_message_attachment_info(
|
||||
msg, msg.id, self.channel_identifier
|
||||
),
|
||||
'sender_id': sender_id,
|
||||
'group_id': self.channel_identifier
|
||||
}
|
||||
try:
|
||||
create_message(message)
|
||||
msgs.append(message)
|
||||
except Exception as e:
|
||||
print(f"Error creating message: {str(e)}")
|
||||
|
||||
if msgs:
|
||||
act_message_posittion(self.channel_identifier, msgs[-1]["id_mess_g"])
|
||||
else:
|
||||
print("The channel has not pending messages")
|
||||
|
||||
return msgs
|
||||
|
||||
def get_message_info(self) -> List[Dict]:
|
||||
"""Devuelve los mensajes estructurados sin publicarlos en la API para debug."""
|
||||
users_cache = self._preload_senders()
|
||||
msgs = []
|
||||
|
||||
for channel_message in self.ChannelMessages:
|
||||
for msg in channel_message.messages:
|
||||
sender_id = getattr(msg, 'sender_id', None)
|
||||
msgs.append({
|
||||
'id_mess_g': msg.id,
|
||||
'content': getattr(msg, 'message', ''),
|
||||
'date': msg.date.isoformat(),
|
||||
'attachments': DataTransformer.get_message_attachment_info(
|
||||
msg, msg.id, self.channel_identifier
|
||||
),
|
||||
'sender': users_cache.get(sender_id, {})
|
||||
})
|
||||
|
||||
return msgs
|
||||
|
||||
# --- Gestión de canales ---
|
||||
|
||||
def add_chat(self, channel: str) -> Dict:
|
||||
"""
|
||||
Agrega o actualiza un canal/grupo en la API.
|
||||
Construye el ID negativo correcto según el tipo:
|
||||
- Canal y Supergrupo → -100{id}
|
||||
- Grupo normal → -{id} (sin el 100)
|
||||
"""
|
||||
self.set_chat_id(channel)
|
||||
info = self.get_channel_info()
|
||||
|
||||
if not info:
|
||||
raise ValueError(f"Channel/group {channel} not found or inaccessible")
|
||||
|
||||
entity_id = info['id']
|
||||
|
||||
# Construir el ID negativo correcto según el tipo
|
||||
if info.get('is_group'):
|
||||
# Grupo normal: ID negativo simple
|
||||
if not str(entity_id).startswith('-'):
|
||||
entity_id = f"-{entity_id}"
|
||||
else:
|
||||
# Canal o supergrupo: prefijo -100
|
||||
if not str(entity_id).startswith('-100'):
|
||||
entity_id = f"-100{entity_id}"
|
||||
|
||||
info['id'] = str(entity_id)
|
||||
|
||||
new_group = {
|
||||
"name": info.get('username') if info.get('username') != 'private'
|
||||
else info.get('title', f"group_{entity_id}"),
|
||||
"description": info.get('description') or info.get('title', ''),
|
||||
"type": info['type']
|
||||
}
|
||||
print(f"[add_chat] id={entity_id}, type={info['type']}, group_data={new_group}")
|
||||
|
||||
created_group = refresh_telegram_group(entity_id, new_group)
|
||||
if created_group:
|
||||
return {
|
||||
"status": "success",
|
||||
"group": created_group,
|
||||
"message": "Group updated successfully"
|
||||
}
|
||||
return {"status": "error", "message": f"Error updating {channel}"}
|
||||
|
||||
def add_chats(self):
|
||||
"""Actualiza la información de todos los grupos almacenados."""
|
||||
chats = get_api_sync("groups")
|
||||
print(chats)
|
||||
for chat in chats:
|
||||
self.add_chat(str(chat['id_telegram']))
|
||||
|
||||
def feeder_loop(self):
|
||||
"""Ciclo principal de alimentación de datos."""
|
||||
print("Init feeder loop")
|
||||
inicio = time.time()
|
||||
groups = get_api_sync("groups")
|
||||
|
||||
for group in groups:
|
||||
channel_id = group["id_telegram"]
|
||||
self.set_chat_id(str(channel_id))
|
||||
info = self.get_channel_info()
|
||||
print(f"Channel info: {info}")
|
||||
self.first_id = group["message_position"]
|
||||
self.refresh_chat()
|
||||
self.get_and_post_message_info()
|
||||
|
||||
fin = time.time()
|
||||
print(f"Tiempo empleado: {fin - inicio:.2f} segundos")
|
||||
|
||||
# --- Descarga de adjuntos ---
|
||||
|
||||
def download_attachment_to_buffer(self, group_id: int, message_id: int) -> BytesIO:
|
||||
"""
|
||||
Descarga el media de un mensaje de Telegram a un BytesIO.
|
||||
Crea una sesión Telethon temporal sobre una copia del session file,
|
||||
en un thread dedicado para no interferir con el loop del scraper.
|
||||
"""
|
||||
session_filename = self.manager.get_sessions_file()
|
||||
session_src = f"{session_filename}"
|
||||
|
||||
if not os.path.exists(session_src):
|
||||
raise RuntimeError(f"Session file no encontrado: {session_src}")
|
||||
|
||||
session_dir = os.path.dirname(session_filename)
|
||||
tmp_session = tempfile.mktemp(prefix="dl_session_", dir=session_dir)
|
||||
shutil.copy2(session_src, f"{tmp_session}.session")
|
||||
print(f"[DL] Session copiada -> {tmp_session}.session")
|
||||
|
||||
result = {"buffer": None, "error": None}
|
||||
|
||||
def _download_in_thread():
|
||||
tmp_client = None
|
||||
tmp_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(tmp_loop)
|
||||
|
||||
try:
|
||||
tmp_client = TelegramClient(
|
||||
tmp_session,
|
||||
self.manager.api_id,
|
||||
self.manager.api_hash,
|
||||
loop=tmp_loop,
|
||||
)
|
||||
|
||||
# connect() en lugar de start() — no dispara prompts interactivos
|
||||
tmp_loop.run_until_complete(tmp_client.connect())
|
||||
|
||||
if not tmp_loop.run_until_complete(tmp_client.is_user_authorized()):
|
||||
raise RuntimeError("La sesión copiada no está autorizada")
|
||||
|
||||
print(f"[DL] Cliente temporal conectado y autorizado")
|
||||
|
||||
message = tmp_loop.run_until_complete(
|
||||
tmp_client.get_messages(group_id, ids=message_id)
|
||||
)
|
||||
|
||||
if message is None:
|
||||
raise ValueError(f"Mensaje {message_id} no encontrado en grupo {group_id}")
|
||||
if not message.media:
|
||||
raise ValueError(f"El mensaje {message_id} no tiene media adjunta")
|
||||
|
||||
print(f"[DL] Mensaje encontrado, media={type(message.media).__name__}")
|
||||
|
||||
buffer = BytesIO()
|
||||
tmp_loop.run_until_complete(
|
||||
tmp_client.download_media(message, file=buffer)
|
||||
)
|
||||
buffer.seek(0)
|
||||
print(f"[DL] Descarga OK, tamaño={buffer.getbuffer().nbytes} bytes")
|
||||
result["buffer"] = buffer
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DL] ERROR en thread: {type(e).__name__}: {e}")
|
||||
result["error"] = e
|
||||
|
||||
finally:
|
||||
if tmp_client is not None:
|
||||
try:
|
||||
if not tmp_loop.is_closed():
|
||||
tmp_loop.run_until_complete(tmp_client.disconnect())
|
||||
except Exception as e:
|
||||
print(f"[DL] ERROR desconectando cliente temporal: {type(e).__name__}: {e}")
|
||||
try:
|
||||
asyncio.set_event_loop(None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
tmp_loop.close()
|
||||
except Exception as e:
|
||||
print(f"[DL] ERROR cerrando loop temporal: {type(e).__name__}: {e}")
|
||||
tmp_session_file = f"{tmp_session}.session"
|
||||
if os.path.exists(tmp_session_file):
|
||||
try:
|
||||
os.remove(tmp_session_file)
|
||||
print(f"[DL] Session temporal eliminada: {tmp_session_file}")
|
||||
except Exception as e:
|
||||
print(f"[DL] No se pudo eliminar session temporal: {e}")
|
||||
|
||||
thread = threading.Thread(target=_download_in_thread, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=600)
|
||||
|
||||
if thread.is_alive():
|
||||
raise RuntimeError("Timeout: la descarga tardó más de 10 minutos")
|
||||
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
|
||||
if result["buffer"] is None:
|
||||
raise RuntimeError("La descarga no produjo contenido")
|
||||
|
||||
return result["buffer"]
|
||||
|
||||
|
||||
def validate_chat(self, channel_id: int) -> Dict:
|
||||
"""
|
||||
Valida que un canal/grupo exista y sea accesible en Telegram,
|
||||
y retorna su información sin guardarlo en la DB.
|
||||
|
||||
Usa sesión temporal (mismo patrón que download_attachment_to_buffer)
|
||||
para no interferir con el loop del scraper.
|
||||
|
||||
Returns:
|
||||
dict con 'valid': True/False y, si válido, los campos de info.
|
||||
Raises:
|
||||
RuntimeError si no se puede obtener el session file.
|
||||
"""
|
||||
|
||||
session_filename = self.session_path
|
||||
session_src = f"{session_filename}"
|
||||
|
||||
if not os.path.exists(session_src):
|
||||
raise RuntimeError(f"Session file no encontrado: {session_src}")
|
||||
|
||||
session_dir = os.path.dirname(session_filename)
|
||||
tmp_session = tempfile.mktemp(prefix="val_session_", dir=session_dir)
|
||||
shutil.copy2(session_src, f"{tmp_session}.session")
|
||||
|
||||
result = {"valid": False, "error": None, "info": None}
|
||||
|
||||
def _validate_in_thread():
|
||||
tmp_client = None
|
||||
tmp_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(tmp_loop)
|
||||
try:
|
||||
tmp_client = TelegramClient(
|
||||
tmp_session,
|
||||
self.manager.api_id,
|
||||
self.manager.api_hash,
|
||||
loop=tmp_loop,
|
||||
)
|
||||
tmp_loop.run_until_complete(tmp_client.connect())
|
||||
|
||||
if not tmp_loop.run_until_complete(tmp_client.is_user_authorized()):
|
||||
result["error"] = "Sesión no autorizada"
|
||||
return
|
||||
|
||||
entity = tmp_loop.run_until_complete(
|
||||
tmp_client.get_entity(channel_id)
|
||||
)
|
||||
|
||||
username = getattr(entity, 'username', None)
|
||||
is_public = bool(username)
|
||||
entity_id = getattr(entity, 'id', None)
|
||||
title = getattr(entity, 'title', '')
|
||||
description = getattr(entity, 'about', '')
|
||||
|
||||
if isinstance(entity, Channel):
|
||||
if entity.megagroup:
|
||||
chat_type = 'public_supergroup' if is_public else 'private_supergroup'
|
||||
else:
|
||||
chat_type = 'public_channel' if is_public else 'private_channel'
|
||||
# Construir ID con prefijo -100
|
||||
full_id = int(f"-100{entity_id}") if entity_id else channel_id
|
||||
elif isinstance(entity, Chat):
|
||||
chat_type = 'group'
|
||||
full_id = -abs(entity_id) if entity_id else channel_id
|
||||
else:
|
||||
result["error"] = f"Tipo de entidad no soportado: {type(entity).__name__}"
|
||||
return
|
||||
|
||||
result["valid"] = True
|
||||
result["info"] = {
|
||||
"id_telegram": full_id,
|
||||
"type": chat_type,
|
||||
"username": username or title or str(full_id),
|
||||
"title": title,
|
||||
"description": description,
|
||||
}
|
||||
print(f"[validate_chat] OK — id={full_id}, type={chat_type}, title={title}")
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
print(f"[validate_chat] ERROR: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
if tmp_client:
|
||||
try:
|
||||
tmp_loop.run_until_complete(tmp_client.disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.set_event_loop(None)
|
||||
try:
|
||||
tmp_loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
tmp_file = f"{tmp_session}.session"
|
||||
if os.path.exists(tmp_file):
|
||||
try:
|
||||
os.remove(tmp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
thread = threading.Thread(target=_validate_in_thread, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=30)
|
||||
|
||||
if thread.is_alive():
|
||||
result["error"] = "Timeout al validar el canal en Telegram"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TelegramChatSingleton:
|
||||
"""Singleton thread-safe. Expone el scraper y el cliente."""
|
||||
|
||||
_manager: Optional[TelegramClientManager] = None
|
||||
_scraper: Optional[TelegramScraper] = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls):
|
||||
raise RuntimeError("Use get_scraper() or get_client() classmethods")
|
||||
|
||||
@classmethod
|
||||
def get_tmp_scraper(cls) -> TelegramScraper:
|
||||
"""Crea y devuelve una instancia temporal de TelegramScraper con su propio manager."""
|
||||
tmp_manager = TelegramClientManager()
|
||||
if tmp_manager.is_active():
|
||||
return TelegramScraper(tmp_manager)
|
||||
else:
|
||||
raise RuntimeError("No se pudo crear el scraper temporal: sesión no válida")
|
||||
|
||||
@classmethod
|
||||
def get_scraper(cls) -> TelegramScraper:
|
||||
"""Devuelve la instancia única del scraper, creándola si no existe."""
|
||||
if cls._scraper is None:
|
||||
with cls._lock:
|
||||
if cls._scraper is None:
|
||||
cls._manager = TelegramClientManager()
|
||||
cls._manager.connect()
|
||||
cls._scraper = TelegramScraper(cls._manager)
|
||||
return cls._scraper
|
||||
|
||||
@classmethod
|
||||
def get_client(cls) -> Optional[TelegramClient]:
|
||||
"""Devuelve el cliente activo si existe. NO intenta conectar."""
|
||||
if cls._manager is None:
|
||||
return None
|
||||
return cls._manager.client # acceso directo, sin llamar get_client() del manager
|
||||
|
||||
@classmethod
|
||||
def cleanup(cls):
|
||||
"""Limpia la instancia (útil para tests o reinicio manual)."""
|
||||
with cls._lock:
|
||||
if cls._manager:
|
||||
cls._manager.disconnect()
|
||||
cls._manager = None
|
||||
cls._scraper = None
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Main.py
|
||||
|
||||
Contiene métodos principales del sistema.
|
||||
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from xmlrpc import client
|
||||
from fastapi import FastAPI
|
||||
import models
|
||||
from database import engine
|
||||
from routers import groups, messages, senders, attachments, rules, alerts, mannage, notes, audit, stats
|
||||
from integrations.chats import TelegramChatSingleton
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Telegram Monitor API",
|
||||
description="API para monitoreo de mensajes de Telegram",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
app.include_router(groups.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(senders.router)
|
||||
app.include_router(attachments.router)
|
||||
app.include_router(rules.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(notes.router)
|
||||
app.include_router(mannage.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(stats.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Bienvenido a la API de Monitor de Telegram"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
""""
|
||||
Diccionario para almacenar las variables de estado.
|
||||
"""
|
||||
|
||||
scraper_status = {
|
||||
"is_active": False,
|
||||
"last_cycle": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
"""
|
||||
Configuración del alimentador:
|
||||
* Se recomienda no tener un cicle_time acotado para evitar bloqueos por "Flood" a la API de Telegram.
|
||||
* Error_delay representa el tiempo a esperar hasta que se reinicie el alimentador tras un error en el ciclo.
|
||||
"""
|
||||
scraper_config = {
|
||||
"cicle_time": 200,
|
||||
"error_delay": 60,
|
||||
}
|
||||
|
||||
@app.get("/telegram-status")
|
||||
def telegram_status():
|
||||
"""
|
||||
Verificar estado del cliente del bucle principal
|
||||
Reintenta la conexión desde un hilo nuevo para verificar si el archivo de sesión aún es válido
|
||||
Modifica "is_active" y "last_error" dependiendo del estado de la sesión
|
||||
"""
|
||||
manager = TelegramChatSingleton._manager
|
||||
|
||||
# Verificar si el manager y cliente existen
|
||||
if manager is None:
|
||||
status = {
|
||||
"is_active": False,
|
||||
"last_cycle": scraper_status.get("last_cycle"),
|
||||
"last_error": "Cliente de Telegram no inicializado",
|
||||
}
|
||||
print(status)
|
||||
return status
|
||||
|
||||
# Verificar estado de la sesión con timeout
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(manager.is_active)
|
||||
try:
|
||||
is_active = future.result(timeout=5)
|
||||
scraper_status["is_active"] = is_active
|
||||
if is_active:
|
||||
scraper_status["last_error"] = None
|
||||
else:
|
||||
scraper_status["last_error"] = "Sesión no autorizada"
|
||||
except concurrent.futures.TimeoutError:
|
||||
scraper_status["is_active"] = False
|
||||
scraper_status["last_error"] = "Timeout verificando estado de sesión"
|
||||
except Exception as e:
|
||||
scraper_status["is_active"] = False
|
||||
scraper_status["last_error"] = f"Error verificando estado: {str(e)}"
|
||||
|
||||
except Exception as e:
|
||||
scraper_status["is_active"] = False
|
||||
scraper_status["last_error"] = f"Error inesperado: {str(e)}"
|
||||
|
||||
status = {
|
||||
"is_active": scraper_status["is_active"],
|
||||
"last_cycle": scraper_status.get("last_cycle"),
|
||||
"last_error": scraper_status.get("last_error"),
|
||||
}
|
||||
print(status)
|
||||
return status
|
||||
|
||||
def run_sync_scraper():
|
||||
"""
|
||||
Inicializa el alimentador,
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
scraper = TelegramChatSingleton.get_scraper()
|
||||
|
||||
scraper_status["is_active"] = (
|
||||
scraper.client is not None and scraper.client.is_connected()
|
||||
)
|
||||
scraper_status["last_error"] = None
|
||||
|
||||
print("Updatring Telegram Groups")
|
||||
scraper.add_chats()
|
||||
|
||||
print("Feeding Groups")
|
||||
scraper.feeder_loop()
|
||||
|
||||
scraper_status["last_cycle"] = datetime.datetime.utcnow().isoformat()
|
||||
print("Cycle completed, waiting 5 minutes")
|
||||
time.sleep(scraper_config["cicle_time"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in scraper: {e}")
|
||||
scraper_status["is_active"] = False
|
||||
scraper_status["last_error"] = str(e)
|
||||
# Ya no reseteamos scraper=None — el Singleton y el manager
|
||||
# manejan la reconexión internamente en get_client()
|
||||
time.sleep(scraper_config["error_delay"])
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(asyncio.to_thread(run_sync_scraper))
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Models.py
|
||||
Contiene los modelos de sqlalchemy para representar las entidades, campos y relaciones en la base de datos.
|
||||
|
||||
"""
|
||||
|
||||
from sqlalchemy import Integer, String, DateTime, ForeignKey, Boolean, BIGINT, ForeignKeyConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from database import Base
|
||||
|
||||
class Group(Base):
|
||||
"""
|
||||
Entidad Grupos:
|
||||
* Se identifica por un ID de Telegram
|
||||
* Contiene de uno a muchos mensajes.
|
||||
"""
|
||||
__tablename__ = 'groups'
|
||||
|
||||
#id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
id_telegram: Mapped[int] = mapped_column(BIGINT, primary_key=True, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
type: Mapped[str] = mapped_column(String(30))
|
||||
message_position: Mapped[int] = mapped_column(BIGINT)
|
||||
messages: Mapped[List["Message"]] = relationship(back_populates="group")
|
||||
|
||||
class Message(Base):
|
||||
"""
|
||||
Entidad Mensajes:
|
||||
* Se identifica por ID de Mensaje en un ID de grupo específico.
|
||||
* Esta relacionada con un Remitente
|
||||
* Puede estar relacionado con un adjunto
|
||||
* Puede estar relacionado con una o muchas alertas.
|
||||
"""
|
||||
__tablename__ = 'messages'
|
||||
|
||||
#id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
id_mess_g: Mapped[int] = mapped_column(BIGINT, primary_key=True) # ID en el grupo
|
||||
group_id: Mapped[int] = mapped_column(BIGINT, ForeignKey('groups.id_telegram'), primary_key=True)
|
||||
|
||||
content: Mapped[str] = mapped_column(String(4096))
|
||||
date: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
# Foreign keys
|
||||
sender_id: Mapped[int] = mapped_column(ForeignKey('senders.id_telegram'))
|
||||
|
||||
|
||||
# Relationships
|
||||
sender: Mapped["Sender"] = relationship(back_populates="messages")
|
||||
group: Mapped["Group"] = relationship(back_populates="messages")
|
||||
attachments: Mapped[List["Attachment"]] = relationship(back_populates="message")
|
||||
alerts: Mapped[List["Alert"]] = relationship(back_populates="message")
|
||||
|
||||
class Attachment(Base):
|
||||
"""
|
||||
Clase Adjuntos:
|
||||
* Se identifica según su ID.
|
||||
* Esta relacionado a un mensaje en un grupo específico.
|
||||
"""
|
||||
__tablename__ = 'attachments'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
type: Mapped[str] = mapped_column(String(50))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
isDownloaded: Mapped[bool] = mapped_column(Boolean)
|
||||
message_id: Mapped[int] = mapped_column(BIGINT)
|
||||
group_id: Mapped[int] = mapped_column(BIGINT)
|
||||
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
['message_id', 'group_id'],
|
||||
['messages.id_mess_g', 'messages.group_id']
|
||||
),
|
||||
)
|
||||
|
||||
message: Mapped["Message"] = relationship(
|
||||
back_populates="attachments",
|
||||
foreign_keys="[Attachment.message_id, Attachment.group_id]"
|
||||
)
|
||||
|
||||
class Sender(Base):
|
||||
"""
|
||||
Entidad "Sender" o Remitente:
|
||||
* Se identifica según el ID proporcionado por Telegram.
|
||||
* Esta relacionado con un o muchos mensajes.
|
||||
"""
|
||||
__tablename__ = 'senders'
|
||||
|
||||
#id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
id_telegram: Mapped[int] = mapped_column(BIGINT, primary_key=True, unique=True)
|
||||
type: Mapped[str] = mapped_column(String(10)) # user/channel/chat
|
||||
username: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
first_name: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
last_name: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
messages: Mapped[List["Message"]] = relationship(back_populates="sender")
|
||||
|
||||
# Entidad Reglas:
|
||||
#
|
||||
class Rule(Base):
|
||||
"""
|
||||
Entidad Regla:
|
||||
* Se identifica según su ID.
|
||||
* Una regla puede relacionarse con ninguna, una o múltiples alertas.
|
||||
"""
|
||||
__tablename__ = 'rules'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
description: Mapped[str] = mapped_column(String(255))
|
||||
regex: Mapped[str] = mapped_column(String(1024))
|
||||
severity: Mapped[str] = mapped_column(String(20)) # baja/media/alta
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
alerts: Mapped[List["Alert"]] = relationship(back_populates="rule")
|
||||
|
||||
|
||||
class Alert(Base):
|
||||
"""Entidad Alertas:+
|
||||
* Una alerta se identifica mediante un mensaje en un grupo en específico
|
||||
* Una o muchas Alertas se relacionan con una regla.
|
||||
* Una alerta esta relacionada con ninguna, una o múltiples notas.
|
||||
"""
|
||||
__tablename__ = 'alerts'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
message_id: Mapped[int] = mapped_column(BIGINT)
|
||||
group_id: Mapped[int] = mapped_column(BIGINT)
|
||||
|
||||
rule_id: Mapped[int] = mapped_column(ForeignKey('rules.id'))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
status: Mapped[str] = mapped_column(String(20), default='open')
|
||||
notes: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
['message_id', 'group_id'],
|
||||
['messages.id_mess_g', 'messages.group_id']
|
||||
),
|
||||
)
|
||||
|
||||
message: Mapped["Message"] = relationship(back_populates="alerts")
|
||||
rule: Mapped["Rule"] = relationship(back_populates="alerts")
|
||||
notes_list: Mapped[List["Note"]] = relationship(back_populates="alert")
|
||||
|
||||
|
||||
class Note(Base):
|
||||
"""
|
||||
Entidad Nota:
|
||||
* Se identifica según ID de mensaje y ID de grupo específico.
|
||||
* Una o muchas notas estan relaciónada a una Alerta.
|
||||
"""
|
||||
__tablename__ = 'notes'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
alert_id: Mapped[int] = mapped_column(ForeignKey('alerts.id'), index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer)
|
||||
content: Mapped[str] = mapped_column(String(1000))
|
||||
creation_date: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
alert: Mapped["Alert"] = relationship(back_populates="notes_list")
|
||||
|
||||
|
||||
# Entidad para almacenar logs de auditoría
|
||||
class AuditLog(Base):
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(50)) # rule, alert, group, message, sender
|
||||
entity_id: Mapped[str] = mapped_column(String(100)) # string para cubrir PKs compuestas
|
||||
action: Mapped[str] = mapped_column(String(20)) # create, update, delete, status_change
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
before_value: Mapped[Optional[str]] = mapped_column(String(4096), nullable=True) # JSON serializado
|
||||
after_value: Mapped[Optional[str]] = mapped_column(String(4096), nullable=True) # JSON serializado
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
alerts.py
|
||||
Contiene endpoint para administrar el CRUD y la lógica de las alertas.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from auth import get_current_user
|
||||
from audit import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/alerts/", response_model=schemas.AlertResponse, tags=['Alerts'])
|
||||
def create_alert(
|
||||
alert: schemas.AlertCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_message = db.query(models.Message).filter(
|
||||
models.Message.id_mess_g == alert.message_id,
|
||||
models.Message.group_id == alert.group_id
|
||||
).first()
|
||||
if not db_message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
db_rule = db.query(models.Rule).filter(models.Rule.id == alert.rule_id).first()
|
||||
if not db_rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
db_alert = models.Alert(
|
||||
message_id=alert.message_id,
|
||||
group_id=alert.group_id,
|
||||
rule_id=alert.rule_id,
|
||||
status=alert.status,
|
||||
notes=alert.notes,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(db_alert)
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='alert',
|
||||
entity_id=db_alert.id,
|
||||
action='create', user_id=current_user,
|
||||
after=db_alert, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_alert)
|
||||
return db_alert
|
||||
|
||||
@router.get("/alerts/", response_model=List[schemas.AlertResponse], tags=['Alerts'])
|
||||
def read_alerts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
query = db.query(models.Alert)
|
||||
if status:
|
||||
query = query.filter(models.Alert.status == status)
|
||||
if severity:
|
||||
query = query.filter(models.Alert.rule.has(models.Rule.severity == severity))
|
||||
if date_from:
|
||||
query = query.filter(models.Alert.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(models.Alert.created_at <= date_to)
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/alerts/{alert_id}", response_model=schemas.AlertResponse, tags=['Alerts'])
|
||||
def read_alert(
|
||||
alert_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
return db_alert
|
||||
|
||||
@router.put("/alerts/{alert_id}", response_model=schemas.AlertResponse, tags=['Alerts'])
|
||||
def update_alert(
|
||||
alert_id: int,
|
||||
alert: schemas.AlertCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
before_snapshot = {
|
||||
'id': db_alert.id,
|
||||
'message_id': db_alert.message_id,
|
||||
'group_id': db_alert.group_id,
|
||||
'rule_id': db_alert.rule_id,
|
||||
'status': db_alert.status,
|
||||
'notes': db_alert.notes,
|
||||
}
|
||||
|
||||
for field, value in alert.model_dump().items():
|
||||
setattr(db_alert, field, value)
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='alert', entity_id=alert_id,
|
||||
action='update', user_id=current_user,
|
||||
before=before_snapshot, after=db_alert,
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_alert)
|
||||
return db_alert
|
||||
|
||||
@router.delete("/alerts/{alert_id}", tags=['Alerts'])
|
||||
def delete_alert(
|
||||
alert_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='alert', entity_id=alert_id,
|
||||
action='delete', user_id=current_user,
|
||||
before=db_alert, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.delete(db_alert)
|
||||
db.commit()
|
||||
return {"message": "Alert deleted successfully"}
|
||||
|
||||
@router.post("/alerts/{alert_id}/resolve", tags=['Alerts'])
|
||||
def resolve_alert(
|
||||
alert_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='alert', entity_id=alert_id,
|
||||
action='status_change', user_id=current_user,
|
||||
before={'status': db_alert.status},
|
||||
after={'status': 'close'},
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db_alert.status = "close"
|
||||
db.commit()
|
||||
db.refresh(db_alert)
|
||||
return db_alert
|
||||
|
||||
@router.post("/alerts/{alert_id}/reopen", tags=['Alerts'])
|
||||
def open_alert(
|
||||
alert_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='alert', entity_id=alert_id,
|
||||
action='status_change', user_id=current_user,
|
||||
before={'status': db_alert.status},
|
||||
after={'status': 'open'},
|
||||
ip_address=request.client.host
|
||||
)
|
||||
db_alert.status = "open"
|
||||
db.commit()
|
||||
db.refresh(db_alert)
|
||||
return db_alert
|
||||
|
||||
@router.post("/alerts/{alert_id}/in-progress", tags=['Alerts'])
|
||||
def set_alert_in_progress(
|
||||
alert_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
# Solo cambiar si está abierta, no sobreescribir estados más avanzados
|
||||
if db_alert.status != "open":
|
||||
return db_alert
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='alert', entity_id=alert_id,
|
||||
action='status_change', user_id=current_user,
|
||||
before={'status': db_alert.status},
|
||||
after={'status': 'in_progress'},
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db_alert.status = "in_progress"
|
||||
db.commit()
|
||||
db.refresh(db_alert)
|
||||
return db_alert
|
||||
@@ -0,0 +1,157 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from auth import get_current_user
|
||||
from fastapi.responses import StreamingResponse
|
||||
from io import BytesIO
|
||||
from integrations.chats import TelegramChatSingleton
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ATTACHMENT CRUD
|
||||
|
||||
@router.post("/attachments/", response_model=schemas.AttachmentResponse, tags=['Attachments'])
|
||||
def create_attachment(attachment: schemas.AttachmentCreate, db: Session = Depends(get_db)):
|
||||
# Verificar que el mensaje existe (FK compuesta)
|
||||
db_message = db.query(models.Message).filter(
|
||||
models.Message.id_mess_g == attachment.message_id,
|
||||
models.Message.group_id == attachment.group_id
|
||||
).first()
|
||||
if not db_message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
db_attachment = models.Attachment(**attachment.model_dump())
|
||||
db.add(db_attachment)
|
||||
db.commit()
|
||||
db.refresh(db_attachment)
|
||||
return db_attachment
|
||||
|
||||
@router.get("/attachments/", response_model=List[schemas.AttachmentResponse], tags=['Attachments'])
|
||||
def read_attachments(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
|
||||
attachments = db.query(models.Attachment).offset(skip).limit(limit).all()
|
||||
return attachments
|
||||
|
||||
@router.get("/attachments/{attachment_id}", response_model=schemas.AttachmentResponse, tags=['Attachments'])
|
||||
def read_attachment(attachment_id: int, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
|
||||
db_attachment = db.query(models.Attachment).filter(models.Attachment.id == attachment_id).first()
|
||||
if not db_attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
return db_attachment
|
||||
|
||||
@router.put("/attachments/{attachment_id}", response_model=schemas.AttachmentResponse, tags=['Attachments'])
|
||||
def update_attachment(attachment_id: int, attachment: schemas.AttachmentCreate, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
|
||||
db_attachment = db.query(models.Attachment).filter(models.Attachment.id == attachment_id).first()
|
||||
if not db_attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
for field, value in attachment.model_dump().items():
|
||||
setattr(db_attachment, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_attachment)
|
||||
return db_attachment
|
||||
|
||||
@router.delete("/attachments/{attachment_id}", tags=['Attachments'])
|
||||
def delete_attachment(attachment_id: int, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
|
||||
db_attachment = db.query(models.Attachment).filter(models.Attachment.id == attachment_id).first()
|
||||
if not db_attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
db.delete(db_attachment)
|
||||
db.commit()
|
||||
return {"message": "Attachment deleted successfully"}
|
||||
|
||||
def _download_sync(group_id: int, message_id: int) -> bytes:
|
||||
"""
|
||||
Descarga el media de un mensaje de Telegram de forma síncrona.
|
||||
Pensado para correrse dentro de asyncio.to_thread().
|
||||
|
||||
Usa el TelegramChatSingleton existente para no abrir una segunda sesión.
|
||||
"""
|
||||
scraper = TelegramChatSingleton.get_scraper()
|
||||
client = scraper.client # TelegramClient ya conectado y autorizado
|
||||
|
||||
# get_messages es una corrutina de Telethon — la corremos en el event loop
|
||||
# del cliente, que es síncrono en el contexto del scraper
|
||||
message = client.loop.run_until_complete(
|
||||
client.get_messages(group_id, ids=message_id)
|
||||
)
|
||||
|
||||
if message is None:
|
||||
raise ValueError(f"Mensaje {message_id} no encontrado en grupo {group_id}")
|
||||
|
||||
if not message.media:
|
||||
raise ValueError(f"El mensaje {message_id} no tiene media adjunta")
|
||||
|
||||
buffer = BytesIO()
|
||||
client.loop.run_until_complete(
|
||||
client.download_media(message, file=buffer)
|
||||
)
|
||||
buffer.seek(0)
|
||||
return buffer.read()
|
||||
|
||||
@router.get("/attachments/{attachment_id}/download", tags=["Attachments"])
|
||||
def download_attachment(
|
||||
attachment_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: str = Depends(get_current_user),
|
||||
):
|
||||
db_attachment = db.query(models.Attachment).filter(
|
||||
models.Attachment.id == attachment_id
|
||||
).first()
|
||||
if not db_attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
print(f"[DL] attachment encontrado: id={db_attachment.id}, type={db_attachment.type}, "
|
||||
f"group_id={db_attachment.group_id}, message_id={db_attachment.message_id}")
|
||||
|
||||
try:
|
||||
scraper = TelegramChatSingleton.get_tmp_scraper()
|
||||
print(f"[DL] scraper obtenido: {scraper}")
|
||||
except Exception as e:
|
||||
msg = f"No se pudo obtener el scraper: {type(e).__name__}: {e}"
|
||||
print(f"[DL] ERROR — {msg}")
|
||||
raise HTTPException(status_code=503, detail=msg)
|
||||
|
||||
try:
|
||||
buffer = scraper.download_attachment_to_buffer(
|
||||
db_attachment.group_id,
|
||||
db_attachment.message_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
msg = f"{type(e).__name__}: {e}"
|
||||
print(f"[DL] ERROR en download_attachment_to_buffer — {msg}")
|
||||
raise HTTPException(status_code=503, detail=msg)
|
||||
|
||||
content_type_map = {
|
||||
"photo": ("image/jpeg", "jpg"),
|
||||
"video": ("video/mp4", "mp4"),
|
||||
"audio": ("audio/mpeg", "mp3"),
|
||||
"voice": ("audio/ogg", "ogg"),
|
||||
"sticker": ("image/webp", "webp"),
|
||||
"document": ("application/octet-stream", "bin"),
|
||||
"gif": ("image/gif", "gif"),
|
||||
"zip": ("application/zip", ".zip"),
|
||||
"pdf": ("application/pdf", ".pdf"),
|
||||
"msword": ("application/msword", ".doc"),
|
||||
"vnd.openxmlformats-officedocument.wordprocessingml.document": ("application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"),
|
||||
"vnd.ms-excel": ("application/vnd.ms-excel", ".xls"),
|
||||
"vnd.openxmlformats-officedocument.spreadsheetml.sheet": ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"),
|
||||
"plain": ("text/plain", ".txt"),
|
||||
"csv": ("text/csv", ".csv"),
|
||||
}
|
||||
adj_type = db_attachment.type or "document"
|
||||
content_type, ext = content_type_map.get(adj_type, ("application/octet-stream", "bin"))
|
||||
filename = f"attachment_{attachment_id}.{ext}"
|
||||
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type=content_type,
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
audit.py
|
||||
Contiene la lógica de los endpoints utilizados para crear y consultar datos de auditoria.
|
||||
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from database import get_db
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
import models
|
||||
import schemas
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/audit/", response_model=List[schemas.AuditLogResponse], tags=["Audit"])
|
||||
def get_audit_logs(
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consulta el log de auditoría con filtros opcionales.
|
||||
Resultados ordenados del más reciente al más antiguo.
|
||||
"""
|
||||
query = db.query(models.AuditLog).order_by(desc(models.AuditLog.timestamp))
|
||||
|
||||
if entity_type:
|
||||
query = query.filter(models.AuditLog.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(models.AuditLog.entity_id == entity_id)
|
||||
if action:
|
||||
query = query.filter(models.AuditLog.action == action)
|
||||
if user_id:
|
||||
query = query.filter(models.AuditLog.user_id == user_id)
|
||||
if date_from:
|
||||
query = query.filter(models.AuditLog.timestamp >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(models.AuditLog.timestamp <= date_to)
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/audit/{log_id}", response_model=schemas.AuditLogResponse, tags=["Audit"])
|
||||
def get_audit_log(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: str = Depends(get_current_user),
|
||||
):
|
||||
log = db.query(models.AuditLog).filter(models.AuditLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="Audit log not found")
|
||||
return log
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
groups.py
|
||||
Contiene los endpoints de CRUD de Grupos.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List
|
||||
from auth import get_current_user
|
||||
from audit import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/groups/", response_model=schemas.GroupResponse, tags=['Groups'])
|
||||
def create_group(
|
||||
group: schemas.GroupCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
existing = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == group.id_telegram
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Group with Telegram ID {group.id_telegram} already exists"
|
||||
)
|
||||
|
||||
db_group = models.Group(**group.model_dump())
|
||||
db.add(db_group)
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='group', entity_id=db_group.id_telegram,
|
||||
action='create', user_id=current_user,
|
||||
after=db_group, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_group)
|
||||
return db_group
|
||||
|
||||
|
||||
@router.get("/groups/", response_model=List[schemas.GroupResponse], tags=['Groups'])
|
||||
def read_groups(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
return db.query(models.Group).filter(
|
||||
models.Group.type != 'P'
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/groups/{group_id}", response_model=schemas.GroupResponse, tags=['Groups'])
|
||||
def read_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_group = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == group_id
|
||||
).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
return db_group
|
||||
|
||||
@router.get("/groups/telegram/{id_telegram}", response_model=schemas.GroupResponse, tags=['Groups'])
|
||||
def read_group_telegram(id_telegram: int, db: Session = Depends(get_db)):
|
||||
db_group = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == id_telegram
|
||||
).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
return db_group
|
||||
|
||||
@router.patch("/groups/{group_id}/update-position", response_model=schemas.GroupResponse, tags=['Groups'])
|
||||
def update_group_message_position(
|
||||
group_id: int,
|
||||
update_data: schemas.GroupUpdatePosition,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_group = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == group_id
|
||||
).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail=f"Group with ID {group_id} not found")
|
||||
|
||||
before_pos = db_group.message_position
|
||||
db_group.message_position = update_data.message_position
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='group', entity_id=group_id,
|
||||
action='update', user_id=current_user,
|
||||
before={'message_position': before_pos},
|
||||
after={'message_position': update_data.message_position},
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_group)
|
||||
return db_group
|
||||
|
||||
@router.patch("/groups/{group_id}", response_model=schemas.GroupResponse, tags=['Groups'])
|
||||
def update_group(
|
||||
group_id: int,
|
||||
update_data: schemas.GroupUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_group = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == group_id
|
||||
).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail=f"Group with ID {group_id} not found")
|
||||
|
||||
before_snapshot = {
|
||||
'name': db_group.name,
|
||||
'description': db_group.description,
|
||||
'type': db_group.type,
|
||||
}
|
||||
|
||||
db_group.name = update_data.name
|
||||
db_group.description = update_data.description
|
||||
db_group.type = update_data.type
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='group', entity_id=group_id,
|
||||
action='update', user_id=current_user,
|
||||
before=before_snapshot, after=update_data.model_dump(),
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_group)
|
||||
return db_group
|
||||
|
||||
@router.delete("/groups/{group_id}", tags=['Groups'])
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_group = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == group_id
|
||||
).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='group', entity_id=group_id,
|
||||
action='delete', user_id=current_user,
|
||||
before=db_group, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.delete(db_group)
|
||||
db.commit()
|
||||
return {"message": "Group deleted successfully"}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Mannage.py
|
||||
Contiene los endpoints utilziados para adminitración o manejo de grupos o sesiones de telegram.
|
||||
"""
|
||||
|
||||
from time import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from integrations.chats import TelegramChatSingleton
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
import requests
|
||||
import asyncio
|
||||
from integrations.api_implementations import post_api_sync, create_telegram_group
|
||||
from telethon import TelegramClient
|
||||
from telethon.sessions import StringSession
|
||||
from telethon.errors import SessionPasswordNeededError
|
||||
import httpx
|
||||
import uuid
|
||||
from auth import get_current_user
|
||||
|
||||
#Almacenes temporales
|
||||
login_flows = {} # flow_id -> {"client": TelegramClient, "phone": str}
|
||||
user_sessions = {} # token -> session_string
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
flow_id: str
|
||||
code: str
|
||||
password: Optional[str] = None
|
||||
|
||||
class VerifyCodeResponse(BaseModel):
|
||||
token: str
|
||||
message: str
|
||||
|
||||
router = APIRouter()
|
||||
load_dotenv()
|
||||
|
||||
|
||||
API_BASE_URL = os.getenv('API_URL')
|
||||
HEADERS = {"Content-Type": "application/json"}
|
||||
TIMEOUT = 5.0
|
||||
cache_status = {"connected": None, "last_check": 0}
|
||||
TTL = 10 # segundos
|
||||
|
||||
router = APIRouter()
|
||||
load_dotenv()
|
||||
|
||||
@router.post("/manage/", tags=['Manage'])
|
||||
def add_pending_chat(channel: int, current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
Agrega un canal/grupo viendo si existe en telegram anteriormente.
|
||||
Primero valida que el ID sea accesible en Telegram,
|
||||
luego lo guarda con los datos reales (nombre, tipo).
|
||||
"""
|
||||
# 1. Verificar que el cliente de Telegram está disponible
|
||||
try:
|
||||
scraper = TelegramChatSingleton.get_scraper()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"El cliente de Telegram no está disponible: {str(e)}"
|
||||
)
|
||||
|
||||
# 2. Validar el canal contra Telegram
|
||||
try:
|
||||
validation = scraper.validate_chat(channel)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Error al validar en Telegram: {str(e)}"
|
||||
)
|
||||
|
||||
if not validation["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Canal no válido o inaccesible: {validation.get('error', 'desconocido')}"
|
||||
)
|
||||
|
||||
info = validation["info"]
|
||||
|
||||
# 3. Guardar con datos reales de Telegram — sin pasar por estado pendiente
|
||||
from integrations.api_implementations import refresh_telegram_group
|
||||
|
||||
tipo_interno = info["type"]
|
||||
nombre = info["username"] if info["username"] != str(info["id_telegram"]) else info["title"] or str(info["id_telegram"])
|
||||
|
||||
# Intentar crear primero (caso nuevo)
|
||||
new_group = {
|
||||
"id_telegram": info["id_telegram"],
|
||||
"name": nombre,
|
||||
"description": info["description"] or info["title"] or "",
|
||||
"type": tipo_interno,
|
||||
"message_position": 0,
|
||||
}
|
||||
created_group = create_telegram_group(new_group)
|
||||
|
||||
if created_group:
|
||||
return {
|
||||
"status": "success",
|
||||
"group": created_group,
|
||||
"message": "Grupo validado y agregado correctamente",
|
||||
"details": {
|
||||
"title": info["title"],
|
||||
"type": info["type"],
|
||||
"id_telegram": info["id_telegram"],
|
||||
}
|
||||
}
|
||||
|
||||
# Si ya existía, actualizar sus datos con los de Telegram
|
||||
update_data = {
|
||||
"name": nombre,
|
||||
"description": info["description"] or info["title"] or "",
|
||||
"type": tipo_interno,
|
||||
}
|
||||
updated_group = refresh_telegram_group(info["id_telegram"], update_data)
|
||||
|
||||
if updated_group:
|
||||
return {
|
||||
"status": "updated",
|
||||
"group": updated_group,
|
||||
"message": "El grupo ya existía y fue actualizado con los datos de Telegram",
|
||||
"details": {
|
||||
"title": info["title"],
|
||||
"type": info["type"],
|
||||
"id_telegram": info["id_telegram"],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "exists",
|
||||
"message": f"El grupo con ID {info['id_telegram']} ya existe en el sistema"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router.get("/manage/connection-status", tags=['Manage'])
|
||||
async def telegram_status():
|
||||
ahora = time()
|
||||
if ahora - cache_status["last_check"] < TTL and cache_status["connected"] is not None:
|
||||
return {"connected": cache_status["connected"], "cached": True}
|
||||
|
||||
# Realizar la verificación real
|
||||
try:
|
||||
response = requests.get("https://api.telegram.org", timeout=2)
|
||||
cache_status["connected"] = response.status_code == 200 or 302
|
||||
except:
|
||||
cache_status["connected"] = False
|
||||
cache_status["last_check"] = ahora
|
||||
return {"connected": cache_status["connected"], "cached": False}
|
||||
|
||||
@router.get("/manage/init-session", tags=['Manage'])
|
||||
async def init_session(current_user: str = Depends(get_current_user)):
|
||||
flow_id = str(uuid.uuid4())
|
||||
|
||||
session_dir = os.path.join(os.getcwd(), "telegram_sessions")
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
|
||||
# --- Limpiar sesiones anteriores de forma segura ---
|
||||
active_session_files = {
|
||||
f"{data['client'].session.filename}"
|
||||
for data in login_flows.values()
|
||||
if "client" in data
|
||||
}
|
||||
|
||||
for filename in os.listdir(session_dir):
|
||||
if not filename.endswith(".session"):
|
||||
continue
|
||||
filepath = os.path.join(session_dir, filename)
|
||||
# Verificar que es un archivo real y no está en uso
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
if filepath in active_session_files:
|
||||
print(f"[INFO] Sesión en uso, se omite: {filename}")
|
||||
continue
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[INFO] Sesión eliminada: {filename}")
|
||||
except Exception as e:
|
||||
print(f"[WARN] No se pudo eliminar {filename}: {e}")
|
||||
|
||||
# Usar el flow_id como nombre de sesión para evitar conflictos entre flujos
|
||||
session_file = os.path.join(session_dir, f"session_{flow_id}")
|
||||
|
||||
client = TelegramClient(
|
||||
session_file,
|
||||
int(os.getenv('TELEGRAM_API_ID')), # debe ser int
|
||||
os.getenv('TELEGRAM_API_HASH')
|
||||
)
|
||||
|
||||
# ✅ await directamente, nunca asyncio.run() dentro de async def
|
||||
await client.connect()
|
||||
|
||||
if not await client.is_user_authorized():
|
||||
try:
|
||||
await client.send_code_request(phone=os.getenv('TELEGRAM_TELEPHONE'))
|
||||
except Exception as e:
|
||||
await client.disconnect()
|
||||
raise HTTPException(status_code=400, detail=f"Error enviando código: {str(e)}")
|
||||
|
||||
login_flows[flow_id] = {
|
||||
"client": client,
|
||||
"phone": os.getenv('TELEGRAM_TELEPHONE')
|
||||
}
|
||||
|
||||
return {
|
||||
"flow_id": flow_id,
|
||||
"message": "Código enviado. Usa /verify-code para completar la autenticación."
|
||||
}
|
||||
|
||||
|
||||
@router.post("/manage/verify-code", tags=['Manage'])
|
||||
async def verify_code(request: VerifyCodeRequest, current_user: str = Depends(get_current_user)):
|
||||
flow = login_flows.get(request.flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="Flow no encontrado o expirado")
|
||||
|
||||
client: TelegramClient = flow["client"]
|
||||
phone = flow["phone"]
|
||||
|
||||
# Reconectar si el cliente se desconectó entre requests
|
||||
if not client.is_connected():
|
||||
await client.connect()
|
||||
|
||||
try:
|
||||
await client.sign_in(phone=phone, code=request.code)
|
||||
|
||||
except SessionPasswordNeededError:
|
||||
if not request.password:
|
||||
raise HTTPException(
|
||||
status_code=428,
|
||||
detail="Se requiere contraseña de dos factores"
|
||||
)
|
||||
try:
|
||||
await client.sign_in(password=request.password)
|
||||
except Exception as e:
|
||||
await client.disconnect()
|
||||
del login_flows[request.flow_id]
|
||||
raise HTTPException(status_code=400, detail=f"Error en contraseña 2FA: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
await client.disconnect()
|
||||
del login_flows[request.flow_id]
|
||||
raise HTTPException(status_code=400, detail=f"Error verificando código: {str(e)}")
|
||||
|
||||
# Éxito: guardar sesión y limpiar
|
||||
session_string = client.session.save()
|
||||
token = str(uuid.uuid4())
|
||||
user_sessions[token] = session_string
|
||||
await asyncio.sleep(1)
|
||||
await client.disconnect()
|
||||
del login_flows[request.flow_id]
|
||||
|
||||
|
||||
# Limpiar la instancia singleton para que tome la nueva sesión
|
||||
TelegramChatSingleton.cleanup()
|
||||
#Inicializa scraper.
|
||||
TelegramChatSingleton.get_scraper()
|
||||
|
||||
return {"token": token, "message": "Autenticación exitosa"}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Messages.py
|
||||
Contiene los endpoints de CRUD de mensajes.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from auth import get_current_user
|
||||
from audit import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/messages/", response_model=schemas.MessageResponse, tags=['Messages'])
|
||||
def create_message(
|
||||
message: schemas.MessageCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
existing = db.query(models.Message).filter(
|
||||
models.Message.id_mess_g == message.id_mess_g,
|
||||
models.Message.group_id == message.group_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Message in Group ID {message.group_id} with Message ID {message.id_mess_g} already exists"
|
||||
)
|
||||
|
||||
db_message = models.Message(**message.model_dump())
|
||||
db.add(db_message)
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='message',
|
||||
entity_id=f"{db_message.id_mess_g}_{db_message.group_id}",
|
||||
action='create', user_id=current_user,
|
||||
after={'id_mess_g': db_message.id_mess_g, 'group_id': db_message.group_id,
|
||||
'sender_id': db_message.sender_id, 'date': str(db_message.date)},
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
rules = db.query(models.Rule).filter(models.Rule.is_active == True).all()
|
||||
alerts = []
|
||||
for rule in rules:
|
||||
try:
|
||||
if re.search(rule.regex, db_message.content, re.IGNORECASE):
|
||||
new_alert = models.Alert(
|
||||
message_id=db_message.id_mess_g,
|
||||
group_id=db_message.group_id,
|
||||
rule_id=rule.id,
|
||||
status="open",
|
||||
notes=f"Detected pattern: {rule.regex}",
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(new_alert)
|
||||
alerts.append(new_alert)
|
||||
except re.error:
|
||||
print(f"[WARN] Regex inválida en regla {rule.id}: {rule.regex}")
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_message)
|
||||
return db_message
|
||||
|
||||
|
||||
@router.get("/messages/", response_model=List[schemas.MessageResponse], tags=['Messages'])
|
||||
def read_messages(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
return db.query(models.Message).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/messages/search/", response_model=List[schemas.MessageResponse], tags=['Messages'])
|
||||
def search_messages(
|
||||
q: str = "",
|
||||
group_id: int = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
query = db.query(models.Message)
|
||||
if q:
|
||||
query = query.filter(models.Message.content.ilike(f"%{q}%"))
|
||||
if group_id:
|
||||
query = query.filter(models.Message.group_id == group_id)
|
||||
if date_from:
|
||||
query = query.filter(models.Message.date >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(models.Message.date <= date_to)
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/messages/", response_model=List[schemas.MessageResponse], tags=['Messages'])
|
||||
def read_messages_by_group(
|
||||
group_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_group = db.query(models.Group).filter(
|
||||
models.Group.id_telegram == group_id
|
||||
).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
return db.query(models.Message).filter(
|
||||
models.Message.group_id == group_id
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/senders/{sender_id}/messages/", response_model=List[schemas.MessageResponse], tags=['Messages'])
|
||||
def read_messages_by_sender(
|
||||
sender_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_sender = db.query(models.Sender).filter(
|
||||
models.Sender.id_telegram == sender_id
|
||||
).first()
|
||||
if not db_sender:
|
||||
raise HTTPException(status_code=404, detail="Sender not found")
|
||||
return db.query(models.Message).filter(
|
||||
models.Message.sender_id == sender_id
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/messages/{message_id}", response_model=schemas.MessageResponse, tags=['Messages'])
|
||||
def read_message(
|
||||
group_id: int,
|
||||
message_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_message = db.query(models.Message).filter(
|
||||
models.Message.id_mess_g == message_id,
|
||||
models.Message.group_id == group_id
|
||||
).first()
|
||||
if not db_message:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Message with ID {message_id} in group {group_id} not found"
|
||||
)
|
||||
return db_message
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/messages/{message_id}", tags=['Messages'])
|
||||
def delete_message(
|
||||
group_id: int,
|
||||
message_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_message = db.query(models.Message).filter(
|
||||
models.Message.id_mess_g == message_id,
|
||||
models.Message.group_id == group_id
|
||||
).first()
|
||||
if not db_message:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Message with ID {message_id} in group {group_id} not found"
|
||||
)
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='message',
|
||||
entity_id=f"{message_id}_{group_id}",
|
||||
action='delete', user_id=current_user,
|
||||
before={'id_mess_g': message_id, 'group_id': group_id},
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.delete(db_message)
|
||||
db.commit()
|
||||
return {"message": "Message deleted successfully"}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
notes.py
|
||||
Contiene los endpoints de CRUD de notas.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/notes/", response_model=schemas.NoteResponse, tags=['Notes'])
|
||||
def create_note(note: schemas.NoteCreate, db: Session = Depends(get_db)):
|
||||
# verificar que la alerta existe
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == note.alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
db_note = models.Note(
|
||||
alert_id=note.alert_id,
|
||||
user_id=note.user_id,
|
||||
content=note.content,
|
||||
creation_date=datetime.utcnow()
|
||||
)
|
||||
db.add(db_note)
|
||||
db.commit()
|
||||
db.refresh(db_note)
|
||||
return db_note
|
||||
|
||||
@router.get("/notes/", response_model=List[schemas.NoteResponse], tags=['Notes'])
|
||||
def read_notes(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
notes = db.query(models.Note).offset(skip).limit(limit).all()
|
||||
return notes
|
||||
|
||||
@router.get("/notes/{note_id}", response_model=schemas.NoteResponse, tags=['Notes'])
|
||||
def read_note(note_id: int, db: Session = Depends(get_db)):
|
||||
db_note = db.query(models.Note).filter(models.Note.id == note_id).first()
|
||||
if not db_note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
return db_note
|
||||
|
||||
@router.put("/notes/{note_id}", response_model=schemas.NoteResponse, tags=['Notes'])
|
||||
def update_note(note_id: int, note: schemas.NoteCreate, db: Session = Depends(get_db)):
|
||||
db_note = db.query(models.Note).filter(models.Note.id == note_id).first()
|
||||
if not db_note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
# validar cambios de alerta
|
||||
if note.alert_id != db_note.alert_id:
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == note.alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="New alert not found")
|
||||
# validar cambio de usuario
|
||||
if note.user_id != db_note.user_id:
|
||||
existing = db.query(models.Note).filter(models.Note.user_id == note.user_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Note for user {note.user_id} already exists"
|
||||
)
|
||||
for field, value in note.model_dump().items():
|
||||
setattr(db_note, field, value)
|
||||
db.commit()
|
||||
db.refresh(db_note)
|
||||
return db_note
|
||||
|
||||
@router.delete("/notes/{note_id}", tags=['Notes'])
|
||||
def delete_note(note_id: int, db: Session = Depends(get_db)):
|
||||
db_note = db.query(models.Note).filter(models.Note.id == note_id).first()
|
||||
if not db_note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
db.delete(db_note)
|
||||
db.commit()
|
||||
return {"message": "Note deleted successfully"}
|
||||
|
||||
# Special endpoints
|
||||
|
||||
@router.get("/alerts/{alert_id}/notes", response_model=List[schemas.NoteResponse], tags=['Notes'])
|
||||
def get_alert_notes(alert_id: int, db: Session = Depends(get_db)):
|
||||
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
|
||||
if not db_alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
return db.query(models.Note).filter(models.Note.alert_id == alert_id).all()
|
||||
|
||||
@router.get("/users/{user_id}/notes", response_model=List[schemas.NoteResponse], tags=['Notes'])
|
||||
def get_user_notes(user_id: int, db: Session = Depends(get_db)):
|
||||
notes = db.query(models.Note).filter(models.Note.user_id == user_id).all()
|
||||
return notes
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Rules.py
|
||||
Contiene los endpoints de CRUD de reglas.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from auth import get_current_user
|
||||
from audit import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/rules/", response_model=schemas.RuleResponse, tags=['Rules'])
|
||||
def create_rule(
|
||||
rule: schemas.RuleCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
# Validar que la regex compile antes de guardar
|
||||
try:
|
||||
re.compile(rule.regex)
|
||||
except re.error as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Expresión regular inválida: {e}"
|
||||
)
|
||||
|
||||
db_rule = models.Rule(**rule.model_dump(exclude={"apply_to_history"}))
|
||||
db.add(db_rule)
|
||||
db.flush() # para tener db_rule.id antes del commit
|
||||
|
||||
# Auditoría
|
||||
log_action(
|
||||
db=db, entity_type='rule', entity_id=db_rule.id,
|
||||
action='create', user_id=current_user,
|
||||
after=db_rule, ip_address=request.client.host
|
||||
)
|
||||
|
||||
|
||||
if rule.apply_to_history:
|
||||
# Traer mensajes en batches para no cargar todo en memoria
|
||||
batch_size = 500
|
||||
offset = 0
|
||||
while True:
|
||||
messages = db.query(models.Message).offset(offset).limit(batch_size).all()
|
||||
if not messages:
|
||||
break
|
||||
for message in messages:
|
||||
try:
|
||||
if re.search(rule.regex, message.content, re.IGNORECASE):
|
||||
new_alert = models.Alert(
|
||||
message_id=message.id_mess_g,
|
||||
group_id=message.group_id,
|
||||
rule_id=db_rule.id,
|
||||
status="open",
|
||||
notes=f"Detected pattern: {rule.regex}",
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(new_alert)
|
||||
except re.error:
|
||||
break
|
||||
offset += batch_size
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_rule)
|
||||
return db_rule
|
||||
|
||||
@router.get("/rules/", response_model=List[schemas.RuleResponse], tags=['Rules'])
|
||||
def read_rules(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
return db.query(models.Rule).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/rules/search/", response_model=List[schemas.RuleResponse], tags=['Rules'])
|
||||
def search_rules(
|
||||
q: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
return db.query(models.Rule).filter(
|
||||
models.Rule.description.ilike(f"%{q}%")
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/rules/{rule_id}", response_model=schemas.RuleResponse, tags=['Rules'])
|
||||
def read_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_rule = db.query(models.Rule).filter(models.Rule.id == rule_id).first()
|
||||
if not db_rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return db_rule
|
||||
|
||||
@router.put("/rules/{rule_id}", response_model=schemas.RuleResponse, tags=['Rules'])
|
||||
def update_rule(
|
||||
rule_id: int,
|
||||
rule: schemas.RuleCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_rule = db.query(models.Rule).filter(models.Rule.id == rule_id).first()
|
||||
if not db_rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
# Capturar estado anterior antes de modificar
|
||||
before_snapshot = {
|
||||
'id': db_rule.id,
|
||||
'description': db_rule.description,
|
||||
'regex': db_rule.regex,
|
||||
'severity': db_rule.severity,
|
||||
'is_active': db_rule.is_active,
|
||||
}
|
||||
|
||||
for field, value in rule.model_dump().items():
|
||||
setattr(db_rule, field, value)
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='rule', entity_id=rule_id,
|
||||
action='update', user_id=current_user,
|
||||
before=before_snapshot, after=db_rule,
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_rule)
|
||||
return db_rule
|
||||
|
||||
@router.delete("/rules/{rule_id}", tags=['Rules'])
|
||||
def delete_rule(
|
||||
rule_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_rule = db.query(models.Rule).filter(models.Rule.id == rule_id).first()
|
||||
if not db_rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='rule', entity_id=rule_id,
|
||||
action='delete', user_id=current_user,
|
||||
before=db_rule, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.delete(db_rule)
|
||||
db.commit()
|
||||
return {"message": "Rule deleted successfully"}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Senders.py
|
||||
Contiene los endpoints de CRUD de remitentes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
import models
|
||||
import schemas
|
||||
from typing import List
|
||||
from auth import get_current_user
|
||||
from audit import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/senders/", response_model=schemas.SenderResponse, tags=['Senders'])
|
||||
def create_sender(
|
||||
sender: schemas.SenderCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
existing = db.query(models.Sender).filter(
|
||||
models.Sender.id_telegram == sender.id_telegram
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Sender with Telegram ID {sender.id_telegram} already exists"
|
||||
)
|
||||
|
||||
db_sender = models.Sender(**sender.model_dump())
|
||||
db.add(db_sender)
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='sender', entity_id=db_sender.id_telegram,
|
||||
action='create', user_id=current_user,
|
||||
after=db_sender, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_sender)
|
||||
return db_sender
|
||||
|
||||
@router.get("/senders/", response_model=List[schemas.SenderResponse], tags=['Senders'])
|
||||
def read_senders(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
return db.query(models.Sender).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/senders/{sender_id}", response_model=schemas.SenderResponse, tags=['Senders'])
|
||||
def read_sender(
|
||||
sender_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_sender = db.query(models.Sender).filter(
|
||||
models.Sender.id_telegram == sender_id
|
||||
).first()
|
||||
if not db_sender:
|
||||
raise HTTPException(status_code=404, detail="Sender not found")
|
||||
return db_sender
|
||||
|
||||
@router.put("/senders/{sender_id}", response_model=schemas.SenderResponse, tags=['Senders'])
|
||||
def update_sender(
|
||||
sender_id: int,
|
||||
sender: schemas.SenderCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_sender = db.query(models.Sender).filter(
|
||||
models.Sender.id_telegram == sender_id
|
||||
).first()
|
||||
if not db_sender:
|
||||
raise HTTPException(status_code=404, detail="Sender not found")
|
||||
|
||||
before_snapshot = {
|
||||
'id_telegram': db_sender.id_telegram,
|
||||
'type': db_sender.type,
|
||||
'username': db_sender.username,
|
||||
'first_name': db_sender.first_name,
|
||||
'last_name': db_sender.last_name,
|
||||
'phone': db_sender.phone,
|
||||
}
|
||||
|
||||
for field, value in sender.model_dump().items():
|
||||
setattr(db_sender, field, value)
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='sender', entity_id=sender_id,
|
||||
action='update', user_id=current_user,
|
||||
before=before_snapshot, after=db_sender,
|
||||
ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_sender)
|
||||
return db_sender
|
||||
|
||||
@router.delete("/senders/{sender_id}", tags=['Senders'])
|
||||
def delete_sender(
|
||||
sender_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: int = Depends(get_current_user)
|
||||
):
|
||||
db_sender = db.query(models.Sender).filter(
|
||||
models.Sender.id_telegram == sender_id
|
||||
).first()
|
||||
if not db_sender:
|
||||
raise HTTPException(status_code=404, detail="Sender not found")
|
||||
|
||||
log_action(
|
||||
db=db, entity_type='sender', entity_id=sender_id,
|
||||
action='delete', user_id=current_user,
|
||||
before=db_sender, ip_address=request.client.host
|
||||
)
|
||||
|
||||
db.delete(db_sender)
|
||||
db.commit()
|
||||
return {"message": "Sender deleted successfully"}
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Stats.py
|
||||
Contiene la lógica de los endpoints utilizados para crear y consultar datos estadísticos.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, cast, Integer, extract
|
||||
from database import get_db
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
import models
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats/", tags=["Stats"])
|
||||
def get_stats(
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Devuelve todas las estadísticas agregadas en una sola llamada.
|
||||
Si no se pasan fechas, usa los últimos 30 días.
|
||||
"""
|
||||
if date_to is None:
|
||||
date_to = datetime.utcnow()
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=30)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Resumen ejecutivo
|
||||
# ------------------------------------------------------------------
|
||||
total_alertas = db.query(models.Alert).filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to
|
||||
).count()
|
||||
|
||||
alertas_abiertas = db.query(models.Alert).filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to,
|
||||
models.Alert.status == "open"
|
||||
).count()
|
||||
|
||||
alertas_en_progreso = db.query(models.Alert).filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to,
|
||||
models.Alert.status == "in_progress"
|
||||
).count()
|
||||
|
||||
alertas_cerradas = total_alertas - alertas_abiertas - alertas_en_progreso
|
||||
|
||||
total_mensajes = db.query(models.Message).filter(
|
||||
models.Message.date >= date_from,
|
||||
models.Message.date <= date_to
|
||||
).count()
|
||||
|
||||
grupos_activos = db.query(models.Group).filter(
|
||||
models.Group.type != "P"
|
||||
).count()
|
||||
|
||||
reglas_activas = db.query(models.Rule).filter(
|
||||
models.Rule.is_active == True
|
||||
).count()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Alertas por día (para gráfico de línea)
|
||||
# ------------------------------------------------------------------
|
||||
alertas_por_dia_raw = (
|
||||
db.query(
|
||||
func.date(models.Alert.created_at).label("dia"),
|
||||
models.Alert.status,
|
||||
func.count(models.Alert.id).label("total")
|
||||
)
|
||||
.filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to
|
||||
)
|
||||
.group_by(
|
||||
func.date(models.Alert.created_at),
|
||||
models.Alert.status
|
||||
)
|
||||
.order_by(func.date(models.Alert.created_at))
|
||||
.all()
|
||||
)
|
||||
|
||||
alertas_por_dia = {}
|
||||
for row in alertas_por_dia_raw:
|
||||
dia = str(row.dia)
|
||||
if dia not in alertas_por_dia:
|
||||
alertas_por_dia[dia] = {"abiertas": 0, "cerradas": 0}
|
||||
if row.status == "open":
|
||||
alertas_por_dia[dia]["abiertas"] = row.total
|
||||
else:
|
||||
alertas_por_dia[dia]["cerradas"] = row.total
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Distribución por severidad
|
||||
# ------------------------------------------------------------------
|
||||
sev_raw = (
|
||||
db.query(
|
||||
models.Rule.severity,
|
||||
func.count(models.Alert.id).label("total")
|
||||
)
|
||||
.join(models.Alert, models.Alert.rule_id == models.Rule.id)
|
||||
.filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to
|
||||
)
|
||||
.group_by(models.Rule.severity)
|
||||
.all()
|
||||
)
|
||||
distribucion_severidad = {row.severity: row.total for row in sev_raw}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Alertas por grupo
|
||||
# ------------------------------------------------------------------
|
||||
alertas_por_grupo_raw = (
|
||||
db.query(
|
||||
models.Alert.group_id,
|
||||
func.count(models.Alert.id).label("total")
|
||||
)
|
||||
.filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to
|
||||
)
|
||||
.group_by(models.Alert.group_id)
|
||||
.order_by(func.count(models.Alert.id).desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Resolver nombres de grupos
|
||||
group_ids = [r.group_id for r in alertas_por_grupo_raw]
|
||||
grupos_map = {
|
||||
g.id_telegram: g.name
|
||||
for g in db.query(models.Group).filter(
|
||||
models.Group.id_telegram.in_(group_ids)
|
||||
).all()
|
||||
}
|
||||
alertas_por_grupo = [
|
||||
{
|
||||
"grupo": grupos_map.get(r.group_id, str(r.group_id)),
|
||||
"total": r.total
|
||||
}
|
||||
for r in alertas_por_grupo_raw
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Reglas más disparadas (top 10)
|
||||
# ------------------------------------------------------------------
|
||||
reglas_top_raw = (
|
||||
db.query(
|
||||
models.Rule.id,
|
||||
models.Rule.description,
|
||||
models.Rule.severity,
|
||||
func.count(models.Alert.id).label("total")
|
||||
)
|
||||
.join(models.Alert, models.Alert.rule_id == models.Rule.id)
|
||||
.filter(
|
||||
models.Alert.created_at >= date_from,
|
||||
models.Alert.created_at <= date_to
|
||||
)
|
||||
.group_by(models.Rule.id, models.Rule.description, models.Rule.severity)
|
||||
.order_by(func.count(models.Alert.id).desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
reglas_top = [
|
||||
{
|
||||
"id": row.id,
|
||||
"description": row.description,
|
||||
"severity": row.severity,
|
||||
"total": row.total
|
||||
}
|
||||
for row in reglas_top_raw
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Volumen de mensajes por día
|
||||
# ------------------------------------------------------------------
|
||||
mensajes_por_dia_raw = (
|
||||
db.query(
|
||||
func.date(models.Message.date).label("dia"),
|
||||
func.count(models.Message.id_mess_g).label("total")
|
||||
)
|
||||
.filter(
|
||||
models.Message.date >= date_from,
|
||||
models.Message.date <= date_to
|
||||
)
|
||||
.group_by(func.date(models.Message.date))
|
||||
.order_by(func.date(models.Message.date))
|
||||
.all()
|
||||
)
|
||||
mensajes_por_dia = {str(row.dia): row.total for row in mensajes_por_dia_raw}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Heatmap: mensajes por hora × día de semana
|
||||
# ------------------------------------------------------------------
|
||||
# MariaDB usa DAYOFWEEK() (1=Dom..7=Sab) y HOUR() en lugar de extract("dow"/"hour")
|
||||
from sqlalchemy import text
|
||||
heatmap_raw = (
|
||||
db.query(
|
||||
func.dayofweek(models.Message.date).label("dia_semana"),
|
||||
func.hour(models.Message.date).label("hora"),
|
||||
func.count(models.Message.id_mess_g).label("total")
|
||||
)
|
||||
.filter(
|
||||
models.Message.date >= date_from,
|
||||
models.Message.date <= date_to
|
||||
)
|
||||
.group_by(
|
||||
func.dayofweek(models.Message.date),
|
||||
func.hour(models.Message.date)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# MariaDB DAYOFWEEK: 1=Dom, 2=Lun, ..., 7=Sáb
|
||||
DIAS = {1: "Dom", 2: "Lun", 3: "Mar", 4: "Mié", 5: "Jue", 6: "Vie", 7: "Sáb"}
|
||||
heatmap = [
|
||||
{
|
||||
"dia": DIAS.get(int(row.dia_semana), str(row.dia_semana)),
|
||||
"hora": int(row.hora),
|
||||
"total": row.total
|
||||
}
|
||||
for row in heatmap_raw
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Top senders por actividad
|
||||
# ------------------------------------------------------------------
|
||||
top_senders_raw = (
|
||||
db.query(
|
||||
models.Message.sender_id,
|
||||
func.count(models.Message.id_mess_g).label("mensajes")
|
||||
)
|
||||
.filter(
|
||||
models.Message.date >= date_from,
|
||||
models.Message.date <= date_to
|
||||
)
|
||||
.group_by(models.Message.sender_id)
|
||||
.order_by(func.count(models.Message.id_mess_g).desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
sender_ids = [r.sender_id for r in top_senders_raw]
|
||||
senders_map = {
|
||||
s.id_telegram: s
|
||||
for s in db.query(models.Sender).filter(
|
||||
models.Sender.id_telegram.in_(sender_ids)
|
||||
).all()
|
||||
}
|
||||
|
||||
# Contar alertas por sender
|
||||
alertas_sender_raw = (
|
||||
db.query(
|
||||
models.Message.sender_id,
|
||||
func.count(models.Alert.id).label("alertas")
|
||||
)
|
||||
.join(models.Alert, (
|
||||
models.Alert.message_id == models.Message.id_mess_g) & (
|
||||
models.Alert.group_id == models.Message.group_id)
|
||||
)
|
||||
.filter(
|
||||
models.Message.date >= date_from,
|
||||
models.Message.date <= date_to,
|
||||
models.Message.sender_id.in_(sender_ids)
|
||||
)
|
||||
.group_by(models.Message.sender_id)
|
||||
.all()
|
||||
)
|
||||
alertas_por_sender = {r.sender_id: r.alertas for r in alertas_sender_raw}
|
||||
|
||||
top_senders = []
|
||||
for row in top_senders_raw:
|
||||
s = senders_map.get(row.sender_id)
|
||||
nombre = ""
|
||||
if s:
|
||||
nombre = f"{s.first_name or ''} {s.last_name or ''}".strip() or s.username or str(row.sender_id)
|
||||
else:
|
||||
nombre = str(row.sender_id)
|
||||
top_senders.append({
|
||||
"id_telegram": row.sender_id,
|
||||
"nombre": nombre,
|
||||
"username": s.username if s else None,
|
||||
"mensajes": row.mensajes,
|
||||
"alertas": alertas_por_sender.get(row.sender_id, 0),
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Respuesta final
|
||||
# ------------------------------------------------------------------
|
||||
return {
|
||||
"periodo": {
|
||||
"desde": date_from.isoformat(),
|
||||
"hasta": date_to.isoformat(),
|
||||
},
|
||||
"resumen": {
|
||||
"total_alertas": total_alertas,
|
||||
"alertas_abiertas": alertas_abiertas,
|
||||
"alertas_cerradas": alertas_cerradas,
|
||||
"total_mensajes": total_mensajes,
|
||||
"grupos_activos": grupos_activos,
|
||||
"reglas_activas": reglas_activas,
|
||||
},
|
||||
"alertas_por_dia": alertas_por_dia,
|
||||
"distribucion_severidad": distribucion_severidad,
|
||||
"alertas_por_grupo": alertas_por_grupo,
|
||||
"reglas_top": reglas_top,
|
||||
"mensajes_por_dia": mensajes_por_dia,
|
||||
"heatmap": heatmap,
|
||||
"top_senders": top_senders,
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Group Schemas
|
||||
class GroupBase(BaseModel):
|
||||
id_telegram: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
type: str
|
||||
message_position: int
|
||||
|
||||
class GroupCreate(GroupBase):
|
||||
pass
|
||||
|
||||
class GroupResponse(GroupBase):
|
||||
# Eliminar la lista de messages para evitar recursión
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class GroupWithMessages(GroupBase):
|
||||
# Solo incluir información básica de los mensajes, no completa
|
||||
messages: List["MessageSimple"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class GroupUpdatePosition(BaseModel):
|
||||
message_position: int
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
type: str
|
||||
|
||||
# Sender Schemas
|
||||
class SenderBase(BaseModel):
|
||||
id_telegram: int
|
||||
type: str
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
class SenderCreate(SenderBase):
|
||||
pass
|
||||
|
||||
class SenderResponse(SenderBase):
|
||||
# Eliminar la lista de messages para evitar recursión
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class SenderWithMessages(SenderBase):
|
||||
# Solo incluir información básica de los mensajes
|
||||
messages: List["MessageSimple"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Message Schemas - Versiones simples para evitar recursión
|
||||
class MessageSimple(BaseModel):
|
||||
id_mess_g: int
|
||||
group_id: int
|
||||
content: str
|
||||
date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class MessageBase(BaseModel):
|
||||
id_mess_g: int
|
||||
content: str
|
||||
date: datetime
|
||||
sender_id: int
|
||||
group_id: int
|
||||
|
||||
class MessageCreate(MessageBase):
|
||||
pass
|
||||
|
||||
class MessageResponse(MessageBase):
|
||||
sender: Optional[SenderResponse] = None
|
||||
group: Optional[GroupResponse] = None
|
||||
attachments: List["AttachmentResponse"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class MessageWithRelations(MessageBase):
|
||||
# Versión con relaciones completas (usar con cuidado)
|
||||
sender: Optional[SenderResponse] = None
|
||||
group: Optional[GroupResponse] = None
|
||||
attachments: List["AttachmentResponse"] = []
|
||||
alerts: List["AlertResponse"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class AttachmentBase(BaseModel):
|
||||
type: str
|
||||
description: Optional[str] = None
|
||||
isDownloaded: bool
|
||||
|
||||
class AttachmentCreate(AttachmentBase):
|
||||
message_id: int
|
||||
group_id: int # necesario para la FK compuesta
|
||||
|
||||
class AttachmentResponse(AttachmentBase):
|
||||
id: int
|
||||
message_id: int
|
||||
group_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Rule Schemas
|
||||
class RuleBase(BaseModel):
|
||||
description: str
|
||||
regex: str
|
||||
severity: str = "media"
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def description_not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("La descripción no puede estar vacía")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("regex")
|
||||
@classmethod
|
||||
def regex_not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("La regex no puede estar vacía")
|
||||
import re
|
||||
try:
|
||||
re.compile(v)
|
||||
except re.error as e:
|
||||
raise ValueError(f"La regex no es válida: {e}")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("severity")
|
||||
@classmethod
|
||||
def severity_valid(cls, v: str) -> str:
|
||||
allowed = {"baja", "media", "alta"}
|
||||
if v.lower() not in allowed:
|
||||
raise ValueError(f"Severidad debe ser una de: {', '.join(allowed)}")
|
||||
return v.lower()
|
||||
|
||||
class RuleCreate(RuleBase):
|
||||
apply_to_history: bool = False # nuevo campo
|
||||
pass
|
||||
|
||||
class RuleResponse(RuleBase):
|
||||
id: int
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Alert Schemas
|
||||
class AlertBase(BaseModel):
|
||||
message_id: int
|
||||
group_id: int
|
||||
rule_id: int
|
||||
status: str = "open"
|
||||
notes: Optional[str] = None
|
||||
|
||||
class AlertCreate(AlertBase):
|
||||
pass
|
||||
|
||||
class AlertResponse(AlertBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
message: Optional[MessageSimple] = None
|
||||
rule: Optional[RuleResponse] = None
|
||||
notes_list: List["NoteResponse"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Note Schemas
|
||||
class NoteBase(BaseModel):
|
||||
alert_id: int
|
||||
user_id: int
|
||||
content: str
|
||||
creation_date: datetime
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
alert_id: int
|
||||
user_id: int
|
||||
content: str
|
||||
|
||||
class NoteResponse(NoteBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
id: int
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
action: str
|
||||
user_id: Optional[int] = None
|
||||
before_value: Optional[str] = None
|
||||
after_value: Optional[str] = None
|
||||
timestamp: datetime
|
||||
ip_address: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Resolver referencias circulares
|
||||
MessageSimple.model_rebuild()
|
||||
MessageResponse.model_rebuild()
|
||||
MessageWithRelations.model_rebuild()
|
||||
AttachmentResponse.model_rebuild()
|
||||
AlertResponse.model_rebuild()
|
||||
GroupWithMessages.model_rebuild()
|
||||
SenderWithMessages.model_rebuild()
|
||||
NoteResponse.model_rebuild()
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /backup_service
|
||||
|
||||
# Instalar dependencias del sistema:
|
||||
# - default-mysql-client → provee mysqldump y mysqladmin
|
||||
# - gcc / pkg-config → para compilar algunas dependencias de Python
|
||||
RUN apt-get update && apt-get install -y \
|
||||
default-mysql-client \
|
||||
gcc \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copiar e instalar dependencias Python
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar el código
|
||||
COPY main.py .
|
||||
|
||||
RUN mkdir -p \
|
||||
/config/nginx \
|
||||
/config/ssl \
|
||||
/config/telegram_sessions \
|
||||
/backups
|
||||
|
||||
# Usuario no-root
|
||||
RUN useradd -m -u 1000 backupuser && \
|
||||
chown -R backupuser:backupuser /backup_service /backups
|
||||
USER backupuser
|
||||
|
||||
EXPOSE 8099
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8099"]
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
main.py — Backup Microservice
|
||||
Centraliza el backup de todos los componentes del sistema TIP:
|
||||
- Base de datos (mysqldump de feeder y users)
|
||||
- Archivos de configuración (nginx.conf, docker-compose.yml)
|
||||
- Certificados SSL
|
||||
- Sesiones de Telegram
|
||||
- Logs de auditoría (export JSON)
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuración
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY no configurada. Abortando inicio.")
|
||||
if len(SECRET_KEY) < 20:
|
||||
raise RuntimeError("SECRET_KEY demasiado corta (mínimo 20 caracteres).")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
# Bases de datos
|
||||
FEEDER_DB_URL = os.getenv("FEEDER_DATABASE_URL", "")
|
||||
USERS_DB_URL = os.getenv("USERS_DATABASE_URL", "")
|
||||
|
||||
# Rutas montadas en el contenedor (ver Dockerfile / docker-compose)
|
||||
PATH_NGINX_CONF = Path(os.getenv("PATH_NGINX_CONF", "/config/nginx/nginx.conf"))
|
||||
PATH_DOCKER_COMPOSE = Path(os.getenv("PATH_DOCKER_COMPOSE", "/config/docker-compose.yml"))
|
||||
PATH_SSL_DIR = Path(os.getenv("PATH_SSL_DIR", "/config/ssl"))
|
||||
PATH_TELEGRAM_SESS = Path(os.getenv("PATH_TELEGRAM_SESSIONS", "/config/telegram_sessions"))
|
||||
|
||||
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/backups"))
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||
"""
|
||||
Valida el JWT firmado con SECRET_KEY compartida.
|
||||
Devuelve el user_id (puede ser 'system' o un entero).
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id = payload.get("user_id")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido: falta user_id",
|
||||
)
|
||||
return str(user_id)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido o expirado",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="TIP — Backup Service",
|
||||
description="Microservicio centralizado de backups del sistema Threat Intelligence Platform.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de DB dump
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_db_url(url: str) -> dict:
|
||||
"""
|
||||
Parsea una URL tipo:
|
||||
mariadb+pymysql://user:pass@host:port/dbname
|
||||
y devuelve un dict con los componentes.
|
||||
"""
|
||||
# Quitar prefijo de driver
|
||||
if "://" in url:
|
||||
url = url.split("://", 1)[1]
|
||||
|
||||
user_pass, rest = url.split("@", 1)
|
||||
user, password = user_pass.split(":", 1)
|
||||
host_port, dbname = rest.split("/", 1)
|
||||
|
||||
if ":" in host_port:
|
||||
host, port = host_port.split(":", 1)
|
||||
else:
|
||||
host, port = host_port, "3306"
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"password": password,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"dbname": dbname,
|
||||
}
|
||||
|
||||
|
||||
def _mysqldump(db_url: str, label: str) -> Optional[bytes]:
|
||||
"""
|
||||
Ejecuta mysqldump y devuelve los bytes del dump.
|
||||
Devuelve None si la URL está vacía o el comando falla.
|
||||
|
||||
Nota: se pasa --skip-ssl / --ssl=FALSE para evitar el error
|
||||
"SSL is required but the server does not support it" que aparece
|
||||
cuando el cliente mysqldump intenta negociar TLS con un MariaDB
|
||||
local que no tiene SSL habilitado.
|
||||
"""
|
||||
if not db_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
cfg = _parse_db_url(db_url)
|
||||
except Exception as e:
|
||||
print(f"[BACKUP] No se pudo parsear {label} DB URL: {e}")
|
||||
return None
|
||||
|
||||
cmd = [
|
||||
"mysqldump",
|
||||
f"-h{cfg['host']}",
|
||||
f"-P{cfg['port']}",
|
||||
f"-u{cfg['user']}",
|
||||
f"-p{cfg['password']}",
|
||||
# Deshabilitar SSL — MariaDB en el compose no tiene TLS configurado
|
||||
"--skip-ssl",
|
||||
"--single-transaction",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
cfg["dbname"],
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr_msg = result.stderr.decode(errors="replace").strip()
|
||||
print(f"[BACKUP] mysqldump {label} falló: {stderr_msg}")
|
||||
return None
|
||||
print(f"[BACKUP] mysqldump {label} OK ({len(result.stdout)} bytes)")
|
||||
return result.stdout
|
||||
except FileNotFoundError:
|
||||
print("[BACKUP] mysqldump no encontrado en el contenedor.")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[BACKUP] mysqldump {label} superó el timeout (300 s).")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[BACKUP] Error inesperado en mysqldump {label}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de archivos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_file_to_zip(zf: zipfile.ZipFile, src: Path, arcname: str):
|
||||
"""Agrega un archivo al zip si existe. Registra advertencia si no."""
|
||||
if src.exists() and src.is_file():
|
||||
zf.write(src, arcname)
|
||||
else:
|
||||
print(f"[BACKUP] Archivo no encontrado, se omite: {src}")
|
||||
|
||||
|
||||
def _add_dir_to_zip(zf: zipfile.ZipFile, src: Path, prefix: str):
|
||||
"""Agrega recursivamente un directorio al zip."""
|
||||
if not src.exists() or not src.is_dir():
|
||||
print(f"[BACKUP] Directorio no encontrado, se omite: {src}")
|
||||
return
|
||||
for file in src.rglob("*"):
|
||||
if file.is_file():
|
||||
arcname = str(Path(prefix) / file.relative_to(src))
|
||||
zf.write(file, arcname)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint principal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/backup", tags=["Backup"])
|
||||
def create_backup(
|
||||
include_db: bool = Query(True, description="Incluir dump de bases de datos"),
|
||||
include_config: bool = Query(True, description="Incluir nginx.conf y docker-compose.yml"),
|
||||
include_ssl: bool = Query(True, description="Incluir certificados SSL"),
|
||||
include_sessions: bool = Query(True, description="Incluir archivos de sesión de Telegram"),
|
||||
current_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera un archivo ZIP con los componentes seleccionados del sistema y lo descarga.
|
||||
|
||||
Componentes disponibles:
|
||||
- **DB**: mysqldump de la BD del feeder y de usuarios.
|
||||
- **Config**: nginx.conf y docker-compose.yml.
|
||||
- **SSL**: directorio de certificados montado en /config/ssl.
|
||||
- **Sessions**: archivos de sesión de Telegram de /config/telegram_sessions.
|
||||
"""
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
zip_name = f"tip_backup_{timestamp}.zip"
|
||||
|
||||
buffer = io.BytesIO()
|
||||
manifest = {
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_by": current_user,
|
||||
"components": [],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
|
||||
# ---- Bases de datos ----
|
||||
if include_db:
|
||||
for label, url in [("feeder", FEEDER_DB_URL), ("users", USERS_DB_URL)]:
|
||||
dump = _mysqldump(url, label)
|
||||
if dump:
|
||||
arcname = f"databases/{label}_{timestamp}.sql"
|
||||
zf.writestr(arcname, dump)
|
||||
manifest["components"].append(f"database:{label}")
|
||||
print(f"[BACKUP] DB {label} OK ({len(dump)} bytes)")
|
||||
else:
|
||||
warn = f"DB {label}: dump no disponible (URL vacía o mysqldump falló)"
|
||||
manifest["warnings"].append(warn)
|
||||
print(f"[BACKUP] WARN — {warn}")
|
||||
|
||||
# ---- Configuración ----
|
||||
if include_config:
|
||||
_add_file_to_zip(zf, PATH_NGINX_CONF, "config/nginx.conf")
|
||||
_add_file_to_zip(zf, PATH_DOCKER_COMPOSE, "config/docker-compose.yml")
|
||||
|
||||
existing = []
|
||||
if PATH_NGINX_CONF.exists():
|
||||
existing.append("nginx.conf")
|
||||
if PATH_DOCKER_COMPOSE.exists():
|
||||
existing.append("docker-compose.yml")
|
||||
|
||||
if existing:
|
||||
manifest["components"].append(f"config:{','.join(existing)}")
|
||||
else:
|
||||
manifest["warnings"].append("Config: ningún archivo de configuración encontrado")
|
||||
|
||||
# ---- Certificados SSL ----
|
||||
if include_ssl:
|
||||
_add_dir_to_zip(zf, PATH_SSL_DIR, "ssl")
|
||||
if PATH_SSL_DIR.exists():
|
||||
manifest["components"].append("ssl")
|
||||
else:
|
||||
manifest["warnings"].append(f"SSL: directorio no encontrado ({PATH_SSL_DIR})")
|
||||
|
||||
# ---- Sesiones de Telegram ----
|
||||
if include_sessions:
|
||||
_add_dir_to_zip(zf, PATH_TELEGRAM_SESS, "telegram_sessions")
|
||||
if PATH_TELEGRAM_SESS.exists():
|
||||
manifest["components"].append("telegram_sessions")
|
||||
else:
|
||||
manifest["warnings"].append(
|
||||
f"Sessions: directorio no encontrado ({PATH_TELEGRAM_SESS})"
|
||||
)
|
||||
|
||||
# ---- Manifiesto ----
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False))
|
||||
|
||||
buffer.seek(0)
|
||||
print(f"[BACKUP] ZIP generado: {zip_name} — componentes: {manifest['components']}")
|
||||
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={zip_name}",
|
||||
"X-Backup-Timestamp": timestamp,
|
||||
"X-Backup-Components": ",".join(manifest["components"]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint de estado
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/backup/status", tags=["Backup"])
|
||||
def backup_status(current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
Devuelve el estado de disponibilidad de cada componente de backup
|
||||
sin generar el archivo.
|
||||
"""
|
||||
|
||||
def _db_reachable(url: str) -> bool:
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
cfg = _parse_db_url(url)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"mysqladmin",
|
||||
f"-h{cfg['host']}",
|
||||
f"-P{cfg['port']}",
|
||||
f"-u{cfg['user']}",
|
||||
f"-p{cfg['password']}",
|
||||
"--skip-ssl",
|
||||
"ping",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return {
|
||||
"service": "backup",
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
"components": {
|
||||
"db_feeder": {
|
||||
"url_configured": bool(FEEDER_DB_URL),
|
||||
"reachable": _db_reachable(FEEDER_DB_URL),
|
||||
},
|
||||
"db_users": {
|
||||
"url_configured": bool(USERS_DB_URL),
|
||||
"reachable": _db_reachable(USERS_DB_URL),
|
||||
},
|
||||
"nginx_conf": {
|
||||
"path": str(PATH_NGINX_CONF),
|
||||
"exists": PATH_NGINX_CONF.exists(),
|
||||
},
|
||||
"docker_compose": {
|
||||
"path": str(PATH_DOCKER_COMPOSE),
|
||||
"exists": PATH_DOCKER_COMPOSE.exists(),
|
||||
},
|
||||
"ssl": {
|
||||
"path": str(PATH_SSL_DIR),
|
||||
"exists": PATH_SSL_DIR.exists(),
|
||||
"files": [f.name for f in PATH_SSL_DIR.iterdir()] if PATH_SSL_DIR.exists() else [],
|
||||
},
|
||||
"telegram_sessions": {
|
||||
"path": str(PATH_TELEGRAM_SESS),
|
||||
"exists": PATH_TELEGRAM_SESS.exists(),
|
||||
"files": [
|
||||
f.name for f in PATH_TELEGRAM_SESS.iterdir()
|
||||
if f.suffix == ".session"
|
||||
] if PATH_TELEGRAM_SESS.exists() else [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
def health():
|
||||
return {"status": "healthy", "service": "backup"}
|
||||
|
||||
|
||||
@app.get("/", tags=["Health"])
|
||||
def root():
|
||||
return {"message": "TIP Backup Service — use GET /backup para generar un backup"}
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.6
|
||||
python-dotenv==1.0.0
|
||||
@@ -0,0 +1,208 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile_frontend
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
STREAMLIT_SERVER_PORT: 8501
|
||||
STREAMLIT_SERVER_ADDRESS: 0.0.0.0
|
||||
STREAMLIT_BROWSER_GATHER_USAGE_STATS: false
|
||||
PYTHONPATH: /frontend
|
||||
USERS_URL: http://api_users:8090
|
||||
FEEDER_URL: http://api:8000
|
||||
ports:
|
||||
- "8501:8501"
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- feeder_network
|
||||
- users_network
|
||||
volumes:
|
||||
- ./frontend:/frontend
|
||||
command: streamlit run app.py --server.port=8501 --server.address=0.0.0.0
|
||||
|
||||
# Base de datos MariaDB
|
||||
database:
|
||||
image: mariadb:10.11
|
||||
container_name: feeder_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: YOUR-DB-PASSWORD
|
||||
MYSQL_DATABASE: feeder
|
||||
MYSQL_USER: feeder_user
|
||||
MYSQL_PASSWORD: feeder_password
|
||||
MARIADB_AUTO_UPGRADE: 1
|
||||
ports:
|
||||
- "3306:3305"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- feeder_network
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# API Feeder
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: feeder_api
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.docker
|
||||
environment:
|
||||
# valores explícitos siguen teniendo prioridad
|
||||
DATABASE_URL: mariadb+pymysql://root:YOUR-DB-PASSWORD@database:3306/feeder
|
||||
TELEGRAM_API_ID: YOUR-TELEGRAM-API-ID
|
||||
TELEGRAM_API_HASH: YOUR-TELEGRAM-API-HASH
|
||||
TELEGRAM_TELEPHONE: YOUR-TELEGRAM-PHONE-NUMBER
|
||||
API_URL: http://api:8000
|
||||
PYTHONPATH: /app
|
||||
SECRET_KEY: YOUR-SECRET-KEY ##Misma que para users
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- feeder_network
|
||||
volumes:
|
||||
- ./app:/app
|
||||
entrypoint: ["/entrypoint.sh"]
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
#Database usuarios
|
||||
database_users:
|
||||
image: mariadb:10.11
|
||||
container_name: users_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: YOUR-OTHER-DB-PASSWORD
|
||||
MYSQL_DATABASE: users
|
||||
MYSQL_USER: users
|
||||
MYSQL_PASSWORD: users_password
|
||||
MYSQL_TCP_PORT: 3307
|
||||
MARIADB_AUTO_UPGRADE: 1
|
||||
ports:
|
||||
- "3307:3307"
|
||||
volumes:
|
||||
- db_data_users:/var/lib/mysql
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- users_network
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# API Usuarios
|
||||
api_users:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile_users
|
||||
container_name: users_api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SECRET_KEY: YOUR-SECRET-KEY ##Misma que para feeder.
|
||||
DATABASE_URL: mariadb+pymysql://root:YOUR-OTHER-DB-PASSWORD@database_users:3307/users
|
||||
API_URL: http://api_users:8090
|
||||
ADMIN_EMAIL: YOUR-ADMIN-EMAIL
|
||||
ADMIN_PASSWORD: YOUR-ADMIN-PASSWORD
|
||||
FRONT_END_URL: http://frontend:8501
|
||||
ports:
|
||||
- "8090:8090"
|
||||
depends_on:
|
||||
database_users:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- users_network
|
||||
volumes:
|
||||
- ./usuarios:/usuarios
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8090 --reload
|
||||
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
container_name: nginx_proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
networks:
|
||||
- feeder_network
|
||||
- users_network
|
||||
depends_on:
|
||||
- frontend
|
||||
- api
|
||||
- api_users
|
||||
|
||||
backup:
|
||||
build:
|
||||
context: /backup_service # directorio con Dockerfile + main.py
|
||||
dockerfile: Dockerfile
|
||||
container_name: tip_backup
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SECRET_KEY: YOUR-SECRET-KEY # misma que feeder y api_users
|
||||
|
||||
# URLs completas de las bases de datos (para mysqldump)
|
||||
FEEDER_DATABASE_URL: mariadb+pymysql://root:YOUR-DB-PASSWORD@database:3306/feeder
|
||||
USERS_DATABASE_URL: mariadb+pymysql://root:YOUR-OTHER-DB-PASSWORD@database_users:3307/users
|
||||
|
||||
# Rutas internas del contenedor (no cambiar salvo que redefinas los volúmenes)
|
||||
PATH_NGINX_CONF: /config/nginx/nginx.conf
|
||||
PATH_DOCKER_COMPOSE: /config/docker-compose.yml
|
||||
PATH_SSL_DIR: /config/ssl
|
||||
PATH_TELEGRAM_SESSIONS: /config/telegram_sessions
|
||||
BACKUP_DIR: /backups
|
||||
|
||||
ports:
|
||||
- "8099:8099"
|
||||
|
||||
volumes:
|
||||
# ── Archivos de configuración ──────────────────────────────────────────
|
||||
# nginx.conf real (no el _example)
|
||||
- ./nginx/nginx.conf:/config/nginx/nginx.conf:ro
|
||||
|
||||
# docker-compose.yml real (no el _example)
|
||||
- ./docker-compose.yml:/config/docker-compose.yml:ro
|
||||
|
||||
# Certificados SSL
|
||||
- ./nginx/ssl:/config/ssl:ro
|
||||
|
||||
# Sesiones de Telegram generadas por el feeder
|
||||
- ./app/telegram_sessions:/config/telegram_sessions:ro
|
||||
|
||||
# Directorio local donde el servicio puede guardar backups históricos (opcional)
|
||||
- ./backups:/backups
|
||||
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
database_users:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
db_data_orquestador:
|
||||
db_data_users:
|
||||
|
||||
networks:
|
||||
feeder_network:
|
||||
driver: bridge
|
||||
#orquestador_network:
|
||||
# driver: bridge
|
||||
users_network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# entrypoint: ejecutar migraciones antes de iniciar la app
|
||||
|
||||
# Esperar a la base de datos (simple loop)
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
echo "Running migrations using DATABASE_URL=$DATABASE_URL"
|
||||
else
|
||||
echo "No DATABASE_URL provided, skipping migrations"
|
||||
fi
|
||||
|
||||
# intentar correr alembic; ignorar errores por ahora
|
||||
alembic upgrade head || true
|
||||
|
||||
# ejecutar el comando original
|
||||
exec "$@"
|
||||
@@ -0,0 +1,15 @@
|
||||
[theme]
|
||||
# Color primario — verde TIP (botones, checkboxes, sliders, links activos)
|
||||
primaryColor = "#27500A"
|
||||
|
||||
# Fondo de la app
|
||||
backgroundColor = "#0e1117"
|
||||
|
||||
# Fondo de inputs, sidebars, expanders
|
||||
secondaryBackgroundColor = "#1a1a1a"
|
||||
|
||||
# Color del texto principal
|
||||
textColor = "#fafafa"
|
||||
|
||||
# Fuente
|
||||
font = "sans serif"
|
||||
@@ -0,0 +1,11 @@
|
||||
# .streamlit/secrets.toml
|
||||
# Configuración de la API
|
||||
[api]
|
||||
base_url = "http://localhost:8000"
|
||||
timeout = 30
|
||||
|
||||
# Configuración de la app
|
||||
[app]
|
||||
name = "Sistema de Inteligencia de Amenazas"
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import streamlit as st
|
||||
from src.auth import is_authenticated, mostrar_pantalla_login
|
||||
import os
|
||||
|
||||
st.set_page_config(
|
||||
page_title="TIP — Threat Intelligence Platform",
|
||||
page_icon="🛡️",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
def main():
|
||||
if not is_authenticated():
|
||||
if st.session_state.get("show_help_before_login"):
|
||||
from src.pages.views.help import show_documentation
|
||||
show_documentation()
|
||||
if st.button("Volver al login", key="back_to_login"):
|
||||
st.session_state.pop("show_help_before_login", None)
|
||||
st.session_state.tip_tab = "login"
|
||||
st.rerun()
|
||||
else:
|
||||
mostrar_pantalla_login(str(os.getenv('USERS_URL')))
|
||||
else:
|
||||
from src.pages.dashboard import mostrar_dashboard
|
||||
mostrar_dashboard()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,439 @@
|
||||
# src/api.py
|
||||
import requests
|
||||
import streamlit as st
|
||||
from typing import List, Optional, Dict, Any
|
||||
import os
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""Cliente simple para interactuar con la API"""
|
||||
|
||||
def __init__(self, user_url: str, feeder_url: str, backup_url: str):
|
||||
self.user_url = user_url
|
||||
self.feeder_url = feeder_url
|
||||
self.backup_url = backup_url
|
||||
|
||||
def _get_headers(self) -> Optional[Dict[str, str]]:
|
||||
if "token" not in st.session_state:
|
||||
return None
|
||||
return {
|
||||
"Authorization": f"Bearer {st.session_state['token']}",
|
||||
"accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def _request(self, method: str, url: str, **kwargs) -> Optional[Any]:
|
||||
"""Realiza petición HTTP con manejo común de errores y headers.
|
||||
|
||||
Args:
|
||||
method: 'get', 'post', 'put', 'delete'
|
||||
url: URL completa
|
||||
**kwargs: parámetros (json, params, etc.) para requests
|
||||
"""
|
||||
headers = self._get_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
func = getattr(requests, method)
|
||||
response = func(url, headers=headers, timeout=10, **kwargs)
|
||||
|
||||
if response.status_code in (200, 201, 204):
|
||||
if response.content:
|
||||
return response.json()
|
||||
return {}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error en {method.upper()} {url}: {str(e)}")
|
||||
return None
|
||||
|
||||
## Verificaciones ##
|
||||
|
||||
def has_connection(self):
|
||||
"""Verifica la conexión con la API de Telegram (Para funcionamiento del feeder)"""
|
||||
try:
|
||||
response = requests.get(f"{self.feeder_url}/manage/connection-status", timeout=5)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
## USERS ##
|
||||
def login(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""Realiza login en la API"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.user_url}/api/v1/login",
|
||||
json={"email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
return response.json() if response.status_code == 200 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_pending_users(self, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/users/pending",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def activate_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.user_url}/api/v1/users/{user_id}/activate"
|
||||
)
|
||||
|
||||
def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"delete",
|
||||
f"{self.user_url}/api/v1/users/{user_id}"
|
||||
)
|
||||
|
||||
def update_user(self, user_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"put",
|
||||
f"{self.user_url}/api/v1/users/{user_id}",
|
||||
json=payload
|
||||
)
|
||||
|
||||
def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/users/{user_id}"
|
||||
)
|
||||
|
||||
def search_users(self, q: str = "", skip: int = 0, limit: int = 50) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/users",
|
||||
params={"q": q, "skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def search_messages(self, q: str = "", group_id=None, date_from=None, date_to=None, skip: int = 0, limit: int = 100):
|
||||
if not q.strip() and not group_id and not date_from and not date_to:
|
||||
return self._request("get", f"{self.feeder_url}/messages/",
|
||||
params={"skip": skip, "limit": limit})
|
||||
params = {"q": q, "skip": skip, "limit": limit}
|
||||
if group_id:
|
||||
params["group_id"] = group_id
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
return self._request("get", f"{self.feeder_url}/messages/search/", params=params)
|
||||
|
||||
def get_user_modifications(self, user_id: Optional[int] = None, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.user_url}/api/v1/logs/users",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
### FEEDER ###
|
||||
## Alerts ##
|
||||
def get_alerts(self, skip: int = 0, limit: int = 100, status=None, severity=None, date_from=None, date_to=None):
|
||||
params = {"skip": skip, "limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if severity:
|
||||
params["severity"] = severity
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
return self._request("get", f"{self.feeder_url}/alerts/", params=params)
|
||||
|
||||
def get_message(self, group_id: int, message_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/groups/{group_id}/messages/{message_id}"
|
||||
)
|
||||
|
||||
def add_note_to_alert(self, alert_id: int, user_id: int, content: str) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {"alert_id": alert_id,"user_id": user_id, "content": f"{content}"}
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/notes/",
|
||||
json=payload
|
||||
)
|
||||
|
||||
def get_notes_for_alert(self, alert_id: int) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/notes"
|
||||
)
|
||||
|
||||
def mark_alert_as_resolved(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/resolve"
|
||||
)
|
||||
|
||||
def mark_alert_as_pending(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/reopen"
|
||||
)
|
||||
|
||||
def set_alert_in_progress(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/alerts/{alert_id}/in-progress"
|
||||
)
|
||||
|
||||
## Groups ##
|
||||
def get_groups(self) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/groups/"
|
||||
)
|
||||
|
||||
def get_group(self, group_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/groups/{group_id}"
|
||||
)
|
||||
|
||||
|
||||
def add_channel(self, channel_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/manage/",
|
||||
params={"channel": channel_id}
|
||||
)
|
||||
|
||||
## Telegram Session ##
|
||||
def telegram_status(self) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/telegram-status"
|
||||
)
|
||||
|
||||
def init_session_telegram(self) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/manage/init-session"
|
||||
)
|
||||
|
||||
def verify_code(self, flow_id: str, code: str, password: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {"flow_id": flow_id, "code": code}
|
||||
if password:
|
||||
payload["password"] = password
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/manage/verify-code",
|
||||
json=payload
|
||||
)
|
||||
|
||||
## Rules ##
|
||||
def get_rules(self, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/rules/",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/rules/{rule_id}"
|
||||
)
|
||||
|
||||
def search_rules(self, q: str = "", skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/rules/search",
|
||||
params={"q": q, "skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def create_rule(self, rule_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"post",
|
||||
f"{self.feeder_url}/rules/",
|
||||
json=rule_data
|
||||
)
|
||||
|
||||
def update_rule(self, rule_id: int, rule_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._request(
|
||||
"put",
|
||||
f"{self.feeder_url}/rules/{rule_id}",
|
||||
json=rule_data
|
||||
)
|
||||
|
||||
##Attachment
|
||||
def get_attachment(self, attachment_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene los metadatos de un adjunto por su ID."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/attachments/{attachment_id}"
|
||||
)
|
||||
|
||||
def download_attachment(self, attachment_id: int) -> Optional[bytes]:
|
||||
"""
|
||||
Descarga el contenido binario de un adjunto directamente desde Telegram vía la API.
|
||||
Devuelve los bytes del archivo, o None si falla.
|
||||
"""
|
||||
headers = self._get_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
# El endpoint de descarga no requiere Content-Type JSON
|
||||
download_headers = {
|
||||
"Authorization": headers["Authorization"],
|
||||
"accept": "*/*"
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.feeder_url}/attachments/{attachment_id}/download",
|
||||
headers=download_headers,
|
||||
timeout=(10, 600) # tiempo de espera más alto para archivos grandes
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
except Exception as e:
|
||||
st.error(f"Error al descargar adjunto {attachment_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_attachments_by_message(self, message_id: int, group_id: int) -> Optional[list]:
|
||||
"""
|
||||
Obtiene los adjuntos asociados a un mensaje específico.
|
||||
Filtra desde el listado general de adjuntos por message_id y group_id.
|
||||
Nota: si en el futuro la API expone un endpoint dedicado, reemplazar esta implementación.
|
||||
"""
|
||||
# Por ahora los adjuntos vienen embebidos en el mensaje via MessageResponse.attachments
|
||||
# Este método es un helper para obtenerlos si solo tenemos los IDs
|
||||
message = self.get_message(group_id, message_id)
|
||||
if message is None:
|
||||
return None
|
||||
return message.get("attachments", [])
|
||||
|
||||
## Audit ##
|
||||
def get_audit_logs(
|
||||
self,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
date_from=None,
|
||||
date_to=None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
params = {"skip": skip, "limit": limit}
|
||||
if entity_type:
|
||||
params["entity_type"] = entity_type
|
||||
if entity_id:
|
||||
params["entity_id"] = entity_id
|
||||
if action:
|
||||
params["action"] = action
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
return self._request("get", f"{self.feeder_url}/audit/", params=params)
|
||||
|
||||
## Stats ##
|
||||
def get_stats(
|
||||
self,
|
||||
date_from: str = None,
|
||||
date_to: str = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene todas las estadísticas agregadas del sistema."""
|
||||
params = {}
|
||||
if date_from: params["date_from"] = date_from
|
||||
if date_to: params["date_to"] = date_to
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/stats/",
|
||||
params=params
|
||||
)
|
||||
|
||||
## Senders ##
|
||||
def get_senders(self, skip: int = 0, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Obtiene la lista de remitentes cargados por el feeder."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/senders/",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
def get_sender(self, sender_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Obtiene un remitente por su ID de Telegram."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/senders/{sender_id}"
|
||||
)
|
||||
|
||||
def get_messages_by_sender(
|
||||
self,
|
||||
sender_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Obtiene todos los mensajes enviados por un sender específico."""
|
||||
return self._request(
|
||||
"get",
|
||||
f"{self.feeder_url}/senders/{sender_id}/messages/",
|
||||
params={"skip": skip, "limit": limit}
|
||||
)
|
||||
|
||||
|
||||
## Backup ##
|
||||
def get_backup_status(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Consulta el estado de disponibilidad de cada componente de backup
|
||||
sin generar el archivo.
|
||||
"""
|
||||
if not self.backup_url:
|
||||
return None
|
||||
return self._request("get", f"{self.backup_url}/backup/status")
|
||||
|
||||
def download_backup(
|
||||
self,
|
||||
include_db: bool = True,
|
||||
include_config: bool = True,
|
||||
include_ssl: bool = True,
|
||||
include_sessions: bool = True,
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Llama al endpoint /backup del microservicio y devuelve los bytes del ZIP.
|
||||
Usa un timeout largo (10 min) porque mysqldump puede tardar.
|
||||
"""
|
||||
if not self.backup_url:
|
||||
return None
|
||||
|
||||
headers = self._get_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
|
||||
dl_headers = {
|
||||
"Authorization": headers["Authorization"],
|
||||
"accept": "application/zip",
|
||||
}
|
||||
params = {
|
||||
"include_db": str(include_db).lower(),
|
||||
"include_config": str(include_config).lower(),
|
||||
"include_ssl": str(include_ssl).lower(),
|
||||
"include_sessions": str(include_sessions).lower(),
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.backup_url}/backup",
|
||||
headers=dl_headers,
|
||||
params=params,
|
||||
timeout=(15, 600), # (connect, read) — dump puede tardar
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
except requests.exceptions.Timeout:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Crear instancia global
|
||||
api_client = APIClient(str(os.getenv('USERS_URL')),str(os.getenv('FEEDER_URL')), str(os.getenv('BACKUP_URL')))
|
||||
@@ -0,0 +1,346 @@
|
||||
import streamlit as st
|
||||
import streamlit.components.v1 as components
|
||||
import functools
|
||||
import requests
|
||||
from typing import Callable
|
||||
import os
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoradores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def requiere_login(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_authenticated():
|
||||
mostrar_pantalla_login(str(os.getenv('USERS_URL', '')))
|
||||
st.stop()
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def requiere_rol(roles_permitidos: list[str]):
|
||||
def decorador(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
@requiere_login
|
||||
def wrapper(*args, **kwargs):
|
||||
rol_usuario = get_user_role()
|
||||
if rol_usuario not in roles_permitidos:
|
||||
st.error(f"Acceso denegado. Se requiere: {', '.join(roles_permitidos)}")
|
||||
st.info(f"Tu rol actual: {rol_usuario}")
|
||||
st.stop()
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorador
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de sesión
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return "token" in st.session_state and "usuario" in st.session_state
|
||||
|
||||
def get_user_info() -> dict:
|
||||
return st.session_state.get("usuario", {})
|
||||
|
||||
def get_user_role() -> str:
|
||||
return st.session_state.get("usuario", {}).get("rol", "invitado")
|
||||
|
||||
def get_auth_token() -> str:
|
||||
return st.session_state.get("token", "")
|
||||
|
||||
def logout():
|
||||
for key in ["token", "usuario", "token_type", "show_register",
|
||||
"registration_success", "tip_tab"]:
|
||||
st.session_state.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Solo el panel izquierdo en iframe — sin inputs ni botones
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LEFT_PANEL_HTML = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
body { background: #27500A; border-radius: 12px; padding: 40px 36px; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.logo-box { width: 38px; height: 38px; border-radius: 10px; background: rgba(255,255,255,0.15); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.brand-name { font-size: 18px; font-weight: 600; color: white; line-height: 1.1; }
|
||||
.brand-sub { font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: 0.08em; }
|
||||
.copy { flex: 1; padding: 32px 0 0; }
|
||||
.tagline { font-size: 22px; font-weight: 500; color: white; line-height: 1.4; margin-bottom: 14px; }
|
||||
.tagline span { color: #C0DD97; }
|
||||
.desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.7; margin-bottom: 22px; }
|
||||
.features { display: flex; flex-direction: column; gap: 12px; }
|
||||
.feature { display: flex; align-items: center; gap: 10px; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: #C0DD97; flex-shrink: 0; }
|
||||
.feature span { font-size: 12px; color: rgba(255,255,255,0.65); }
|
||||
.footer { font-size: 11px; color: rgba(255,255,255,0.28); padding-top: 28px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brand">
|
||||
<div class="logo-box">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="rgba(255,255,255,0.7)" stroke-width="1.5"/>
|
||||
<path d="M7 10l2.5 2.5L14 7" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">TIP</div>
|
||||
<div class="brand-sub">THREAT INTELLIGENCE PLATFORM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copy">
|
||||
<div class="tagline">Monitoreo de inteligencia<br><span>en tiempo real</span></div>
|
||||
<div class="desc">Plataforma interna para el seguimiento, análisis y gestión de amenazas detectadas en canales de Telegram.</div>
|
||||
<div class="features">
|
||||
<div class="feature"><div class="dot"></div><span>Detección automática por reglas y regex</span></div>
|
||||
<div class="feature"><div class="dot"></div><span>Alertas con trazabilidad completa</span></div>
|
||||
<div class="feature"><div class="dot"></div><span>Auditoría de todas las acciones del sistema</span></div>
|
||||
<div class="feature"><div class="dot"></div><span>Exportación de reportes PDF</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">Municipio de Paraná · Uso interno</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS para estilizar los formularios nativos de Streamlit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FORM_CSS = """
|
||||
<style>
|
||||
[data-testid="stSidebar"] { display: none; }
|
||||
[data-testid="stHeader"] { display: none; }
|
||||
.block-container { padding: 8vh 2rem 0 !important; max-width: 100% !important; }
|
||||
|
||||
/* ---- Tabs: unidad visual conectada ---- */
|
||||
/* Tab activo */
|
||||
div[data-testid="column"]:nth-child(1) button[kind="primary"],
|
||||
div[data-testid="column"]:nth-child(2) button[kind="primary"] {
|
||||
background-color: #27500A !important;
|
||||
border: 0.5px solid #27500A !important;
|
||||
color: #fff !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
/* Tab inactivo */
|
||||
div[data-testid="column"]:nth-child(1) button[kind="secondary"],
|
||||
div[data-testid="column"]:nth-child(2) button[kind="secondary"] {
|
||||
background-color: #1a1a1a !important;
|
||||
border: 0.5px solid rgba(255,255,255,0.15) !important;
|
||||
color: rgba(255,255,255,0.5) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
div[data-testid="column"]:nth-child(1) button[kind="secondary"]:hover,
|
||||
div[data-testid="column"]:nth-child(2) button[kind="secondary"]:hover {
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
background-color: rgba(255,255,255,0.05) !important;
|
||||
}
|
||||
/* Redondear extremos como una sola unidad */
|
||||
div[data-testid="column"]:nth-child(1) button {
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
div[data-testid="column"]:nth-child(2) button {
|
||||
border-radius: 0 6px 6px 0 !important;
|
||||
}
|
||||
|
||||
/* ---- Botón link "Registrarse / Ingresar" ---- */
|
||||
div[data-testid="stButton"]:not(div[data-testid="stFormSubmitButton"]) button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: #3B6D11 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 0 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
div[data-testid="stButton"]:not(div[data-testid="stFormSubmitButton"]) button:hover {
|
||||
color: #639922 !important;
|
||||
}
|
||||
|
||||
/* ---- Botón submit del form ---- */
|
||||
div[data-testid="stFormSubmitButton"] button {
|
||||
width: 100% !important;
|
||||
height: 38px !important;
|
||||
background-color: #27500A !important;
|
||||
border: none !important;
|
||||
border-radius: 6px !important;
|
||||
color: #fff !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
div[data-testid="stFormSubmitButton"] button:hover {
|
||||
background-color: #3B6D11 !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pantalla de login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mostrar_pantalla_login(user_url: str):
|
||||
if "tip_tab" not in st.session_state:
|
||||
st.session_state.tip_tab = "login"
|
||||
|
||||
if st.session_state.get("registration_success"):
|
||||
st.session_state.tip_tab = "login"
|
||||
st.session_state.registration_success = False
|
||||
|
||||
st.markdown(FORM_CSS, unsafe_allow_html=True)
|
||||
|
||||
# Centrar verticalmente en la pantalla
|
||||
_, center, _ = st.columns([0.5, 9, 0.5])
|
||||
with center:
|
||||
col_left, col_right = st.columns(2, gap="small")
|
||||
|
||||
# Panel izquierdo — iframe con fondo verde
|
||||
with col_left:
|
||||
components.html(LEFT_PANEL_HTML, height=560, scrolling=False)
|
||||
|
||||
# Panel derecho — formularios 100% nativos de Streamlit
|
||||
with col_right:
|
||||
st.markdown("<div style='padding: 40px 16px 0;'>", unsafe_allow_html=True)
|
||||
|
||||
if st.session_state.tip_tab == "login":
|
||||
_form_login(user_url)
|
||||
else:
|
||||
_form_registro(user_url)
|
||||
|
||||
st.markdown("</div>", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _form_login(user_url: str):
|
||||
st.subheader("Bienvenido")
|
||||
st.caption("Ingresá con tu cuenta institucional")
|
||||
|
||||
with st.form("tip_login_form", clear_on_submit=False):
|
||||
email = st.text_input("Correo electrónico", placeholder="Ingresá tu correo")
|
||||
password = st.text_input("Contraseña", type="password", placeholder="••••••••")
|
||||
submitted = st.form_submit_button(
|
||||
"Ingresar al sistema",
|
||||
use_container_width=True,
|
||||
type="primary"
|
||||
)
|
||||
|
||||
if submitted:
|
||||
if not email or not password:
|
||||
st.warning("Completá ambos campos para continuar.")
|
||||
return
|
||||
with st.spinner("Verificando credenciales..."):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{user_url}/api/v1/login",
|
||||
json={"email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
datos = response.json()
|
||||
st.session_state.update({
|
||||
"token": datos["access_token"],
|
||||
"token_type": datos.get("token_type", "bearer"),
|
||||
"usuario": datos["user"]
|
||||
})
|
||||
st.rerun()
|
||||
elif response.status_code == 401:
|
||||
st.error("Credenciales incorrectas. Verificá tu correo y contraseña.")
|
||||
else:
|
||||
st.error(f"Error del servidor ({response.status_code}). Intentá más tarde.")
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error("No se puede conectar al servidor.")
|
||||
except requests.exceptions.Timeout:
|
||||
st.error("El servidor está tardando demasiado.")
|
||||
except Exception as e:
|
||||
st.error(f"Error inesperado: {str(e)}")
|
||||
|
||||
st.markdown("""
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:16px 0 8px;">
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
<span style="font-size:11px;color:rgba(255,255,255,0.35);">¿No tenés cuenta?</span>
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
if st.button("Registrarse", key="link_to_register", use_container_width=False):
|
||||
st.session_state.tip_tab = "register"
|
||||
st.rerun()
|
||||
|
||||
st.markdown("<div style='height:10px;'></div>", unsafe_allow_html=True)
|
||||
if st.button("Ayuda", key="login_help", use_container_width=False):
|
||||
st.session_state.show_help_before_login = True
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _form_registro(user_url: str):
|
||||
st.subheader("Crear cuenta")
|
||||
st.caption("Tu cuenta quedará pendiente de aprobación")
|
||||
|
||||
with st.form("tip_register_form", clear_on_submit=True):
|
||||
nombre = st.text_input("Nombre completo", placeholder="Juan Pérez")
|
||||
email = st.text_input("Correo electrónico", placeholder="Ingresá tu correo")
|
||||
password = st.text_input("Contraseña", type="password", placeholder="Mínimo 8 caracteres")
|
||||
submitted = st.form_submit_button(
|
||||
"Solicitar acceso",
|
||||
use_container_width=True,
|
||||
type="primary"
|
||||
)
|
||||
|
||||
st.info("Un administrador revisará tu solicitud antes de que puedas ingresar al sistema.")
|
||||
|
||||
if submitted:
|
||||
if not nombre or not email or not password:
|
||||
st.warning("Completá todos los campos.")
|
||||
return
|
||||
with st.spinner("Creando cuenta..."):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{user_url}/api/v1/users/common",
|
||||
json={"name": nombre, "email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code in (200, 201):
|
||||
st.success("Cuenta creada. Un administrador la activará pronto.")
|
||||
st.session_state.registration_success = True
|
||||
st.session_state.tip_tab = "login"
|
||||
st.rerun()
|
||||
else:
|
||||
try:
|
||||
detalle = response.json().get("detail", "")
|
||||
except Exception:
|
||||
detalle = str(response.status_code)
|
||||
st.error(f"Error al crear la cuenta: {detalle}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error("No se puede conectar al servidor.")
|
||||
except requests.exceptions.Timeout:
|
||||
st.error("El servidor está tardando demasiado.")
|
||||
except Exception as e:
|
||||
st.error(f"Error inesperado: {str(e)}")
|
||||
|
||||
st.markdown("""
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:16px 0 8px;">
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
<span style="font-size:11px;color:rgba(255,255,255,0.35);">¿Ya tenés cuenta?</span>
|
||||
<div style="flex:1;height:0.5px;background:rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
if st.button("Ingresar", key="link_to_login", use_container_width=False):
|
||||
st.session_state.tip_tab = "login"
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compatibilidad hacia atrás
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mostrar_pantalla_registro(user_url: str):
|
||||
st.session_state.tip_tab = "register"
|
||||
mostrar_pantalla_login(user_url)
|
||||
@@ -0,0 +1,484 @@
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.auth import get_user_info
|
||||
from os import getenv
|
||||
from src.api import api_client
|
||||
from src.pages.views.audit import mostrar_audit
|
||||
import requests
|
||||
|
||||
def _render_backup_status_badge(ok: bool, label: str):
|
||||
"""Renderiza un badge de estado inline."""
|
||||
color = "#27500A" if ok else "#6b1a1a"
|
||||
bg = "#EAF3DE" if ok else "#FAE8E8"
|
||||
icon = "✓" if ok else "✗"
|
||||
st.markdown(
|
||||
f'<span style="background:{bg};color:{color};font-size:11px;font-weight:600;'
|
||||
f'padding:2px 8px;border-radius:4px;font-family:monospace;">'
|
||||
f'{icon} {label}</span>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
def _mostrar_seccion_backup():
|
||||
"""
|
||||
Sección completa de Backup del Sistema.
|
||||
Se renderiza dentro de la pestaña Sistema, debajo del bloque del alimentador.
|
||||
"""
|
||||
api = api_client
|
||||
|
||||
st.subheader("Backup del Sistema")
|
||||
|
||||
backup_url_configured = bool(getenv("BACKUP_URL", ""))
|
||||
|
||||
if not backup_url_configured:
|
||||
st.warning(
|
||||
"La variable de entorno `BACKUP_URL` no está configurada. "
|
||||
"El servicio de backup no está disponible."
|
||||
)
|
||||
return
|
||||
|
||||
# ── Estado del servicio ───────────────────────────────────────────────
|
||||
col_status, col_refresh = st.columns([5, 1])
|
||||
with col_refresh:
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
if st.button("↻ Verificar", key="backup_refresh_status", use_container_width=True):
|
||||
st.session_state.pop("backup_status_cache", None)
|
||||
|
||||
with col_status:
|
||||
if "backup_status_cache" not in st.session_state:
|
||||
with st.spinner("Consultando servicio de backup..."):
|
||||
bk_status = api.get_backup_status()
|
||||
st.session_state.backup_status_cache = bk_status
|
||||
else:
|
||||
bk_status = st.session_state.backup_status_cache
|
||||
|
||||
if bk_status is None:
|
||||
st.error("No se pudo conectar al servicio de backup. Verificá que el contenedor esté activo.")
|
||||
return
|
||||
|
||||
# Tabla de estado de componentes
|
||||
comps = bk_status.get("components", {})
|
||||
checked_at = bk_status.get("checked_at", "")
|
||||
try:
|
||||
checked_at = datetime.fromisoformat(checked_at).strftime("%d/%m/%Y %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
st.caption(f"Estado verificado: {checked_at} UTC")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
st.markdown("**Bases de datos**")
|
||||
db_f = comps.get("db_feeder", {})
|
||||
db_u = comps.get("db_users", {})
|
||||
_render_backup_status_badge(
|
||||
db_f.get("reachable", False),
|
||||
"Feeder DB" + ("" if db_f.get("url_configured") else " (sin URL)")
|
||||
)
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
_render_backup_status_badge(
|
||||
db_u.get("reachable", False),
|
||||
"Users DB" + ("" if db_u.get("url_configured") else " (sin URL)")
|
||||
)
|
||||
|
||||
with col2:
|
||||
st.markdown("**Configuración**")
|
||||
nginx = comps.get("nginx_conf", {})
|
||||
dc = comps.get("docker_compose", {})
|
||||
ssl = comps.get("ssl", {})
|
||||
_render_backup_status_badge(nginx.get("exists", False), "nginx.conf")
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
_render_backup_status_badge(dc.get("exists", False), "docker-compose.yml")
|
||||
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
|
||||
ssl_files = ssl.get("files", [])
|
||||
_render_backup_status_badge(
|
||||
ssl.get("exists", False) and len(ssl_files) > 0,
|
||||
f"SSL ({len(ssl_files)} archivos)"
|
||||
)
|
||||
|
||||
with col3:
|
||||
st.markdown("**Sesiones Telegram**")
|
||||
sess = comps.get("telegram_sessions", {})
|
||||
sess_files = sess.get("files", [])
|
||||
_render_backup_status_badge(
|
||||
sess.get("exists", False) and len(sess_files) > 0,
|
||||
f"Sesiones ({len(sess_files)} archivos)"
|
||||
)
|
||||
|
||||
# ── Selector de componentes ───────────────────────────────────────────
|
||||
st.markdown("<div style='height:16px'></div>", unsafe_allow_html=True)
|
||||
st.markdown("**Seleccioná qué incluir en el backup:**")
|
||||
|
||||
col_c1, col_c2, col_c3, col_c4 = st.columns(4)
|
||||
with col_c1:
|
||||
inc_db = st.checkbox("Bases de datos", value=True, key="bk_inc_db")
|
||||
with col_c2:
|
||||
inc_cfg = st.checkbox("Configuración", value=True, key="bk_inc_cfg")
|
||||
with col_c3:
|
||||
inc_ssl = st.checkbox("Certificados SSL", value=True, key="bk_inc_ssl")
|
||||
with col_c4:
|
||||
inc_sess = st.checkbox("Sesiones Telegram", value=True, key="bk_inc_sess")
|
||||
|
||||
if not any([inc_db, inc_cfg, inc_ssl, inc_sess]):
|
||||
st.warning("Seleccioná al menos un componente para generar el backup.")
|
||||
return
|
||||
|
||||
# ── Botón de generación y descarga ───────────────────────────────────
|
||||
st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
|
||||
|
||||
col_btn, col_info_bk = st.columns([2, 3])
|
||||
with col_btn:
|
||||
generar = st.button(
|
||||
"⬇️ Generar y Descargar Backup",
|
||||
key="bk_generate_btn",
|
||||
type="primary",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
with col_info_bk:
|
||||
selected = []
|
||||
if inc_db: selected.append("DB")
|
||||
if inc_cfg: selected.append("Config")
|
||||
if inc_ssl: selected.append("SSL")
|
||||
if inc_sess: selected.append("Sesiones")
|
||||
st.caption(f"Se incluirá: {', '.join(selected)}")
|
||||
if inc_db:
|
||||
st.caption("⚠️ El dump de bases de datos puede tardar varios minutos según su tamaño.")
|
||||
|
||||
if generar:
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"tip_backup_{timestamp}.zip"
|
||||
|
||||
with st.spinner("Generando backup… esto puede tardar si incluye bases de datos."):
|
||||
zip_bytes = api.download_backup(
|
||||
include_db=inc_db,
|
||||
include_config=inc_cfg,
|
||||
include_ssl=inc_ssl,
|
||||
include_sessions=inc_sess,
|
||||
)
|
||||
|
||||
if zip_bytes:
|
||||
size_mb = len(zip_bytes) / (1024 * 1024)
|
||||
st.success(f"Backup generado correctamente ({size_mb:.2f} MB). Hacé clic en Guardar para descargarlo.")
|
||||
st.download_button(
|
||||
label=f"💾 Guardar {filename}",
|
||||
data=zip_bytes,
|
||||
file_name=filename,
|
||||
mime="application/zip",
|
||||
key="bk_download_btn",
|
||||
use_container_width=False,
|
||||
)
|
||||
else:
|
||||
st.error(
|
||||
"No se pudo generar el backup. "
|
||||
"Verificá que el servicio esté activo y que las bases de datos sean accesibles."
|
||||
)
|
||||
|
||||
def mostrar_users():
|
||||
"""Página para mostrar usuarios pendientes de aprobación."""
|
||||
api = api_client
|
||||
|
||||
if "user_page" not in st.session_state:
|
||||
st.session_state.user_page = 1
|
||||
if "user_per_page" not in st.session_state:
|
||||
st.session_state.user_per_page = 10
|
||||
|
||||
skip = (st.session_state.user_page - 1) * st.session_state.user_per_page
|
||||
limit = st.session_state.user_per_page
|
||||
|
||||
with st.spinner("Cargando usuarios..."):
|
||||
users_data = api.get_pending_users(skip=skip, limit=limit)
|
||||
|
||||
if users_data is None:
|
||||
st.error("No se pudieron cargar usuarios.")
|
||||
st.session_state.user_page = 1
|
||||
return
|
||||
|
||||
total_recibidos = len(users_data) if users_data else 0
|
||||
hay_mas_paginas = total_recibidos == limit
|
||||
|
||||
if total_recibidos > 0:
|
||||
st.info(f"Mostrando usuarios pendientes {skip + 1} - {skip + total_recibidos}")
|
||||
else:
|
||||
st.info("No hay usuarios pendientes de aprobación")
|
||||
if st.session_state.user_page > 1:
|
||||
st.session_state.user_page = 1
|
||||
st.rerun()
|
||||
|
||||
if users_data and total_recibidos > 0:
|
||||
for i, user in enumerate(users_data, 1):
|
||||
with st.expander(f"Usuario: {user.get('name')}", expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**Correo electrónico:** {user.get('email')}")
|
||||
st.write(f"**Rol:** {user.get('rol')}")
|
||||
st.write(f"**Activo:** {user.get('active', 'N/A')}")
|
||||
with col2:
|
||||
try:
|
||||
fc = datetime.fromisoformat(str(user.get("creation_time", ""))).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
fc = user.get("creation_time", "")
|
||||
try:
|
||||
fm = datetime.fromisoformat(str(user.get("update_time", ""))).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
fm = user.get("update_time", "")
|
||||
st.write(f"**Fecha de creación:** {fc}")
|
||||
st.write(f"**Fecha de modificación:** {fm}")
|
||||
|
||||
col_activate, col_reject = st.columns(2)
|
||||
with col_activate:
|
||||
if st.button("Activar Usuario", key=f"res_{user.get('id', i)}_{skip}"):
|
||||
result = api.activate_user(user.get('id', i))
|
||||
if result is not None:
|
||||
st.success(f"Usuario {user.get('id', 'N/A')} activado con éxito")
|
||||
else:
|
||||
st.error(f"No se pudo activar al usuario {user.get('id', 'N/A')}")
|
||||
with col_reject:
|
||||
if st.button("Rechazar Usuario", key=f"rej_{user.get('id', i)}_{skip}"):
|
||||
result = api.delete_user(user.get('id', i))
|
||||
if result is not None:
|
||||
st.success(f"Usuario {user.get('name', 'N/A')} eliminado con éxito")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"No se pudo eliminar al usuario {user.get('name', 'N/A')}")
|
||||
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.user_page <= 1, use_container_width=True):
|
||||
st.session_state.user_page = max(1, st.session_state.user_page - 1)
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.user_page}**")
|
||||
st.caption(f"Usuarios pendientes: {total_recibidos}")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas_paginas, use_container_width=True):
|
||||
st.session_state.user_page += 1
|
||||
st.rerun()
|
||||
|
||||
if total_recibidos == 0 and st.session_state.user_page > 1:
|
||||
st.warning("No hay usuarios en esta página.")
|
||||
if st.button("Volver a la primera página"):
|
||||
st.session_state.user_page = 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _form_crear_usuario(user_url: str):
|
||||
"""
|
||||
Formulario nativo para crear un usuario desde el panel admin.
|
||||
Reemplaza mostrar_pantalla_registro() que pisaba el layout completo.
|
||||
"""
|
||||
api = api_client
|
||||
st.subheader("Crear usuario")
|
||||
|
||||
with st.form("admin_create_user_form", clear_on_submit=True):
|
||||
nombre = st.text_input("Nombre completo", placeholder="Juan Pérez")
|
||||
email = st.text_input("Correo electrónico", placeholder="Ingresá el correo")
|
||||
password = st.text_input("Contraseña", type="password", placeholder="Mínimo 8 caracteres")
|
||||
col_rol, col_active = st.columns(2)
|
||||
with col_rol:
|
||||
rol = st.selectbox("Rol", ["operator", "admin"])
|
||||
with col_active:
|
||||
active = st.checkbox("Activar inmediatamente", value=True)
|
||||
submitted = st.form_submit_button("Crear usuario", use_container_width=True, type="primary")
|
||||
|
||||
if submitted:
|
||||
if not nombre or not email or not password:
|
||||
st.warning("Completá todos los campos.")
|
||||
return
|
||||
with st.spinner("Creando usuario..."):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{user_url}/api/v1/users/common",
|
||||
json={"name": nombre, "email": email, "password": password},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code in (200, 201):
|
||||
st.success(f"Usuario {nombre} creado correctamente.")
|
||||
else:
|
||||
try:
|
||||
detalle = response.json().get("detail", "")
|
||||
except Exception:
|
||||
detalle = str(response.status_code)
|
||||
st.error(f"Error: {detalle}")
|
||||
except Exception as e:
|
||||
st.error(f"Error de conexión: {str(e)}")
|
||||
|
||||
|
||||
def mostrar_panel_admin():
|
||||
"""Panel de administración."""
|
||||
usuario = get_user_info()
|
||||
|
||||
tab1, tab2, tab3, tab4 = st.tabs(["Usuarios", "Logs", "Sistema", "Auditoría"])
|
||||
|
||||
with tab1:
|
||||
st.subheader("Gestión de Usuarios")
|
||||
stab1, stab2, stab3 = st.tabs(["Usuarios pendientes", "Crear usuario", "Editar usuario"])
|
||||
with stab1:
|
||||
mostrar_users()
|
||||
with stab2:
|
||||
_form_crear_usuario(getenv("USERS_URL", ""))
|
||||
with stab3:
|
||||
editar_usuario_admin()
|
||||
|
||||
with tab2:
|
||||
st.subheader("Historial de modificaciones de usuarios")
|
||||
api = api_client
|
||||
with st.spinner("Cargando historial..."):
|
||||
mods = api.get_user_modifications(skip=0, limit=100)
|
||||
if not mods:
|
||||
st.info("No se encontraron modificaciones recientes.")
|
||||
else:
|
||||
try:
|
||||
import pandas as pd
|
||||
st.dataframe(pd.DataFrame(mods), use_container_width=True)
|
||||
except Exception:
|
||||
for m in mods:
|
||||
st.write(m)
|
||||
|
||||
with tab3:
|
||||
tab1, tab2 = st.tabs(["Alimentador", "Backup del Sistema"])
|
||||
with tab1:
|
||||
st.subheader("Ajustes de Alimentador")
|
||||
api = api_client
|
||||
with st.spinner("Verificando estado..."):
|
||||
status = api.telegram_status()
|
||||
|
||||
if status is None:
|
||||
st.error("No se pudo verificar el estado del alimentador.")
|
||||
elif status.get("is_active"):
|
||||
st.success("El alimentador está activo y funcionando correctamente.")
|
||||
else:
|
||||
st.error("El alimentador está caído o no disponible.")
|
||||
st.subheader("Reiniciar Sesión de Telegram")
|
||||
st.info("Si el alimentador está caído, podés reiniciar la sesión de Telegram acá.")
|
||||
|
||||
feeder_ok = api.has_connection()
|
||||
|
||||
if not feeder_ok:
|
||||
st.warning("No hay conexión con la API de Telegram. Révisa la conexión a Internet")
|
||||
|
||||
if st.button("Iniciar Nueva Sesión", type="primary", disabled=not feeder_ok):
|
||||
with st.spinner("Iniciando sesión..."):
|
||||
init_result = api.init_session_telegram()
|
||||
if init_result:
|
||||
st.success("Sesión iniciada. Revisá Telegram para el código de verificación.")
|
||||
st.session_state.waiting_for_code = True
|
||||
st.session_state.flow_id = init_result.get("flow_id")
|
||||
else:
|
||||
st.error("Error al iniciar la sesión.")
|
||||
|
||||
if st.session_state.get("waiting_for_code"):
|
||||
st.subheader("Verificar Código")
|
||||
code = st.text_input("Código de verificación de Telegram:", type="password")
|
||||
password = st.text_input("Contraseña 2FA (si aplica):", type="password")
|
||||
if st.button("Verificar Código", type="primary"):
|
||||
if code:
|
||||
with st.spinner("Verificando..."):
|
||||
verify_result = api.verify_code(
|
||||
st.session_state.flow_id, code,
|
||||
password if password else None
|
||||
)
|
||||
if verify_result:
|
||||
st.success("Sesión verificada. El alimentador debería estar activo.")
|
||||
st.session_state.waiting_for_code = False
|
||||
st.session_state.flow_id = None
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al verificar el código. Intentá de nuevo.")
|
||||
datetime.time.sleep(2)
|
||||
st.rerun()
|
||||
else:
|
||||
st.warning("Ingresá el código de verificación.")
|
||||
with tab2:
|
||||
_mostrar_seccion_backup()
|
||||
|
||||
with tab4:
|
||||
mostrar_audit()
|
||||
|
||||
|
||||
def editar_usuario_admin():
|
||||
"""Interfaz admin para buscar y editar usuarios."""
|
||||
api = api_client
|
||||
q = ""
|
||||
|
||||
with st.spinner("Cargando usuarios..."):
|
||||
try:
|
||||
users = api.search_users(q="", limit=500) or []
|
||||
except Exception:
|
||||
users = []
|
||||
|
||||
if not users:
|
||||
st.info("No se encontraron usuarios.")
|
||||
return
|
||||
|
||||
options = []
|
||||
id_map = {}
|
||||
q_lower = (q or "").strip().lower()
|
||||
|
||||
for u in users:
|
||||
name = (u.get('name') or '').strip()
|
||||
email = (u.get('email') or '').strip()
|
||||
uid = u.get('id')
|
||||
label = f"{name or '<sin-nombre>'} — {email} (id:{uid})"
|
||||
if q_lower:
|
||||
if q_lower in name.lower() or q_lower in email.lower() or q_lower in str(uid):
|
||||
options.append(label)
|
||||
id_map[label] = uid
|
||||
else:
|
||||
options.append(label)
|
||||
id_map[label] = uid
|
||||
|
||||
if not options:
|
||||
st.info("No hay usuarios que coincidan.")
|
||||
return
|
||||
|
||||
selected_label = st.selectbox("Seleccioná un usuario para editar", options, key="user_select")
|
||||
selected_id = id_map.get(selected_label)
|
||||
|
||||
if selected_id:
|
||||
user_detail = api.get_user(selected_id)
|
||||
if not user_detail:
|
||||
st.error("No se pudo obtener el detalle del usuario.")
|
||||
return
|
||||
|
||||
st.subheader(f"Editar: {user_detail.get('name', '')} (id:{selected_id})")
|
||||
|
||||
with st.form("admin_edit_user"):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
name_val = st.text_input("Nombre", value=user_detail.get('name', ''))
|
||||
email_val = st.text_input("Email", value=user_detail.get('email', ''))
|
||||
with col2:
|
||||
rol_options = ["operator", "admin"]
|
||||
current_rol = user_detail.get('rol', "operator") or "operator"
|
||||
try:
|
||||
idx = rol_options.index(current_rol)
|
||||
except ValueError:
|
||||
idx = 0
|
||||
rol_val = st.selectbox("Rol", options=rol_options, index=idx)
|
||||
active_val = st.checkbox("Activo", value=bool(user_detail.get('active', False)))
|
||||
|
||||
password_val = st.text_input("Nueva contraseña (opcional)", value="", type="password")
|
||||
submitted = st.form_submit_button("Guardar cambios", type="primary")
|
||||
|
||||
if submitted:
|
||||
payload = {
|
||||
"name": name_val,
|
||||
"email": email_val,
|
||||
"rol": rol_val,
|
||||
"active": bool(active_val)
|
||||
}
|
||||
if password_val:
|
||||
payload['password'] = password_val
|
||||
with st.spinner("Aplicando cambios..."):
|
||||
res = api.update_user(selected_id, payload)
|
||||
if res is not None:
|
||||
st.success("Usuario actualizado correctamente.")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al actualizar usuario.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mostrar_panel_admin()
|
||||
@@ -0,0 +1,296 @@
|
||||
import streamlit as st
|
||||
from src.auth import requiere_login, get_user_info, logout
|
||||
from src.api import api_client
|
||||
from src.pages import admin
|
||||
from src.pages.views.rules import editar_reglas, crear_regla
|
||||
from src.pages.views.groups import agregar_grupo, listar_grupos
|
||||
from src.pages.views.messages import mostrar_mensajes
|
||||
from src.pages.views.alerts import mostrar_alertas
|
||||
from src.pages.views.home import mostrar_inicio
|
||||
from src.pages.views.audit import mostrar_audit
|
||||
from src.pages.views.senders import mostrar_senders
|
||||
from src.pages.views.stats import mostrar_estadisticas
|
||||
from src.pages.views.components import safe_html
|
||||
from src.pages.views.help import show_documentation
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS global — identidad TIP
|
||||
# ---------------------------------------------------------------------------
|
||||
TIP_CSS = """
|
||||
<style>
|
||||
/* Sidebar background */
|
||||
[data-testid="stSidebar"] {
|
||||
background-color: #27500A !important;
|
||||
}
|
||||
[data-testid="stSidebar"] > div:first-child {
|
||||
background-color: #27500A !important;
|
||||
}
|
||||
|
||||
/* Todos los textos del sidebar en blanco */
|
||||
[data-testid="stSidebar"] * {
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
}
|
||||
|
||||
/* Radio buttons — ocultar los círculos nativos y darle estilo de nav */
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] > div {
|
||||
gap: 2px !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] label {
|
||||
background: transparent !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 7px 12px !important;
|
||||
cursor: pointer !important;
|
||||
font-size: 13px !important;
|
||||
transition: background 0.15s !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] label:hover {
|
||||
background: rgba(255,255,255,0.08) !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] label[data-checked="true"],
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] [aria-checked="true"] ~ div {
|
||||
background: rgba(255,255,255,0.14) !important;
|
||||
border-right: 2px solid #C0DD97 !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
/* Ocultar el círculo del radio */
|
||||
[data-testid="stSidebar"] [data-testid="stRadio"] [data-testid="stMarkdownContainer"] p {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Dividers del sidebar */
|
||||
[data-testid="stSidebar"] hr {
|
||||
border-color: rgba(255,255,255,0.12) !important;
|
||||
}
|
||||
|
||||
/* Captions / labels en sidebar */
|
||||
[data-testid="stSidebar"] .stCaption,
|
||||
[data-testid="stSidebar"] small {
|
||||
color: rgba(255,255,255,0.45) !important;
|
||||
}
|
||||
|
||||
/* Botón logout en sidebar */
|
||||
[data-testid="stSidebar"] button {
|
||||
background: rgba(255,255,255,0.08) !important;
|
||||
border: 0.5px solid rgba(255,255,255,0.2) !important;
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
[data-testid="stSidebar"] button:hover {
|
||||
background: rgba(255,255,255,0.15) !important;
|
||||
}
|
||||
|
||||
/* Métricas — acento verde en la primera */
|
||||
[data-testid="metric-container"] {
|
||||
background: var(--background-color) !important;
|
||||
border: 0.5px solid rgba(128,128,128,0.2) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def _sidebar_logo():
|
||||
"""Renderiza el logo TIP en el sidebar."""
|
||||
st.markdown("""
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:4px 0 12px;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.15);
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="rgba(255,255,255,0.6)" stroke-width="1.5"/>
|
||||
<path d="M7 10l2.5 2.5L14 7" stroke="white" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600;color:white;line-height:1.1;">TIP</div>
|
||||
<div style="font-size:9px;color:rgba(255,255,255,0.45);letter-spacing:0.07em;
|
||||
text-transform:uppercase;">Threat Intelligence Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _sidebar_user(usuario: dict):
|
||||
"""Renderiza el bloque de usuario al pie del sidebar."""
|
||||
nombre = safe_html(usuario.get("name", "Usuario"))
|
||||
rol = safe_html(usuario.get("rol", "operator").capitalize())
|
||||
iniciales = safe_html("".join(p[0].upper() for p in nombre.split()[:2]))
|
||||
st.markdown(f"""
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 0 4px;
|
||||
border-top:0.5px solid rgba(255,255,255,0.12);margin-top:8px;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;
|
||||
background:rgba(255,255,255,0.18);display:flex;align-items:center;
|
||||
justify-content:center;font-size:11px;font-weight:500;
|
||||
color:white;flex-shrink:0;">{iniciales}</div>
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:500;color:white;">{nombre}</div>
|
||||
<div style="font-size:10px;color:rgba(255,255,255,0.45);">{rol}</div>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _nav_section(label: str):
|
||||
"""Renderiza un separador de sección en el sidebar."""
|
||||
st.markdown(f"""
|
||||
<div style="font-size:10px;color:rgba(255,255,255,0.38);letter-spacing:0.08em;
|
||||
text-transform:uppercase;padding:10px 0 2px;">
|
||||
{safe_html(label)}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
@requiere_login
|
||||
def mostrar_dashboard():
|
||||
usuario = get_user_info()
|
||||
es_admin = usuario.get("rol") == "admin"
|
||||
|
||||
# Inyectar CSS
|
||||
st.markdown(TIP_CSS, unsafe_allow_html=True)
|
||||
|
||||
with st.sidebar:
|
||||
_sidebar_logo()
|
||||
st.markdown("<hr style='margin:0 0 8px;'>", unsafe_allow_html=True)
|
||||
|
||||
# --- Navegación ---
|
||||
_nav_section("Principal")
|
||||
|
||||
opciones_principales = [
|
||||
"🏠 Inicio",
|
||||
"🔔 Alertas",
|
||||
"💬 Mensajes",
|
||||
"📊 Estadísticas",
|
||||
"👥 Remitentes",
|
||||
]
|
||||
|
||||
opciones_config = [
|
||||
"📋 Reglas",
|
||||
"📡 Grupos y Canales",
|
||||
]
|
||||
|
||||
opciones_admin = [
|
||||
"📝 Panel de Administrador",
|
||||
] if es_admin else []
|
||||
|
||||
opciones_cuenta = ["⚙️ Mi Cuenta", "📋 Ayuda"]
|
||||
|
||||
todas = opciones_principales + opciones_config + (opciones_admin if es_admin else []) + opciones_cuenta
|
||||
|
||||
opcion = st.radio(
|
||||
"nav",
|
||||
todas,
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
st.markdown("<hr style='margin:8px 0;'>", unsafe_allow_html=True)
|
||||
_sidebar_user(usuario)
|
||||
st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
|
||||
if st.button("Cerrar sesión", use_container_width=True):
|
||||
logout()
|
||||
st.rerun()
|
||||
|
||||
# --- Contenido principal ---
|
||||
opcion_clean = opcion.strip().split(" ", 1)[-1].strip()
|
||||
if opcion_clean != "Inicio":
|
||||
st.session_state.pop("show_help_from_home", None)
|
||||
|
||||
if opcion_clean == "Inicio":
|
||||
mostrar_inicio(usuario)
|
||||
|
||||
elif opcion_clean == "Alertas":
|
||||
st.title("Alertas")
|
||||
mostrar_alertas()
|
||||
|
||||
elif opcion_clean == "Mensajes":
|
||||
st.title("Mensajes")
|
||||
mostrar_mensajes()
|
||||
|
||||
elif opcion_clean == "Reglas":
|
||||
_mostrar_reglas()
|
||||
|
||||
elif opcion_clean == "Estadísticas":
|
||||
mostrar_estadisticas()
|
||||
|
||||
elif opcion_clean == "Grupos y Canales":
|
||||
_mostrar_grupos()
|
||||
|
||||
elif opcion_clean == "Remitentes":
|
||||
mostrar_senders()
|
||||
|
||||
elif opcion_clean == "Panel de Administrador" and es_admin:
|
||||
st.title("Panel de Administración")
|
||||
admin.mostrar_panel_admin()
|
||||
|
||||
elif opcion_clean == "Mi Cuenta":
|
||||
_mostrar_mi_cuenta(usuario)
|
||||
elif opcion_clean == "Ayuda":
|
||||
_mostrar_ayuda()
|
||||
else:
|
||||
mostrar_inicio(usuario)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secciones internas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mostrar_ayuda():
|
||||
show_documentation()
|
||||
|
||||
def _mostrar_reglas():
|
||||
st.title("Gestión de Reglas")
|
||||
tab1, tab2 = st.tabs(["Listar reglas", "Crear una regla"])
|
||||
with tab1:
|
||||
editar_reglas()
|
||||
with tab2:
|
||||
crear_regla()
|
||||
|
||||
def _mostrar_grupos():
|
||||
st.title("Grupos y Canales")
|
||||
tab1, tab3 = st.tabs(["Grupos activos", "Agregar grupo"])
|
||||
with tab1:
|
||||
listar_grupos()
|
||||
with tab3:
|
||||
agregar_grupo()
|
||||
|
||||
|
||||
def _mostrar_mi_cuenta(usuario: dict):
|
||||
st.title("Mi Cuenta")
|
||||
api = api_client
|
||||
|
||||
editar = st.checkbox("Activar edición")
|
||||
|
||||
with st.form("form_mi_cuenta"):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
name_val = st.text_input("Nombre completo", value=usuario.get("name", ""), disabled=not editar)
|
||||
email_val = st.text_input("Email", value=usuario.get("email", ""), disabled=not editar)
|
||||
with col2:
|
||||
st.text_input("Rol", value=usuario.get("rol", ""), disabled=True)
|
||||
st.text_input("ID usuario", value=str(usuario.get("id", "")), disabled=True)
|
||||
|
||||
password_val = st.text_input("Nueva contraseña (opcional)", value="", type="password", disabled=not editar)
|
||||
submitted = st.form_submit_button("Guardar cambios")
|
||||
|
||||
if submitted and editar:
|
||||
user_id = usuario.get("id")
|
||||
if not user_id:
|
||||
st.error("ID de usuario no disponible.")
|
||||
return
|
||||
payload = {"name": name_val, "email": email_val, "rol": usuario.get("rol"), "active": True}
|
||||
if password_val:
|
||||
payload["password"] = password_val
|
||||
with st.spinner("Guardando..."):
|
||||
res = api.update_user(user_id, payload)
|
||||
if res:
|
||||
st.success("Datos actualizados correctamente.")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al actualizar. Revisá los datos o contactá al administrador.")
|
||||
elif not editar:
|
||||
st.info("Marcá 'Activar edición' para modificar tus datos.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mostrar_dashboard()
|
||||
@@ -0,0 +1,547 @@
|
||||
from asyncio import sleep
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.auth import get_user_info
|
||||
|
||||
from io import BytesIO
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib import colors
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||
from reportlab.lib.enums import TA_LEFT, TA_CENTER
|
||||
from xml.sax.saxutils import escape
|
||||
from src.pages.views.components import render_adjuntos
|
||||
from datetime import time
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fmt_fecha(fecha_str: str) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(str(fecha_str)).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return str(fecha_str) or "N/A"
|
||||
|
||||
|
||||
def _estado_label(status: str) -> str:
|
||||
return "Resuelto" if status == "close" else "Pendiente" if status == "open" else "En Curso"
|
||||
|
||||
|
||||
def _p(text, style) -> Paragraph:
|
||||
"""Crea un Paragraph escapando caracteres especiales para evitar errores de XML."""
|
||||
return Paragraph(escape(str(text or "")), style)
|
||||
|
||||
|
||||
def generar_reporte_alerta_pdf(
|
||||
alert: dict,
|
||||
notes: list,
|
||||
motivo_cierre: str,
|
||||
usuarios: dict,
|
||||
message: dict = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Genera un reporte PDF de cierre de alerta.
|
||||
Retorna los bytes del PDF para usar con st.download_button.
|
||||
"""
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=letter,
|
||||
rightMargin=2 * cm,
|
||||
leftMargin=2 * cm,
|
||||
topMargin=2 * cm,
|
||||
bottomMargin=2 * cm,
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
titulo_style = ParagraphStyle("Titulo", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#1a1a2e"), spaceAfter=6)
|
||||
subtitulo_style = ParagraphStyle("Subtitulo", parent=styles["Heading2"],fontSize=12, textColor=colors.HexColor("#16213e"), spaceBefore=14, spaceAfter=4)
|
||||
label_style = ParagraphStyle("Label", parent=styles["Normal"], fontSize=9, textColor=colors.HexColor("#555555"))
|
||||
valor_style = ParagraphStyle("Valor", parent=styles["Normal"], fontSize=10, textColor=colors.HexColor("#1a1a1a"))
|
||||
motivo_style = ParagraphStyle("Motivo", parent=styles["Normal"], fontSize=10, textColor=colors.HexColor("#1a1a1a"), backColor=colors.HexColor("#fff3cd"), borderPadding=(6, 8, 6, 8), leading=14)
|
||||
nota_content_style = ParagraphStyle("NotaContent", parent=styles["Normal"], fontSize=10, leading=14)
|
||||
footer_style = ParagraphStyle("Footer", parent=label_style, alignment=TA_CENTER, fontSize=8)
|
||||
right_style = ParagraphStyle("Right", parent=label_style, alignment=2)
|
||||
|
||||
story = []
|
||||
|
||||
# Encabezado
|
||||
story.append(Paragraph("Reporte de Cierre de Alerta", titulo_style))
|
||||
story.append(Paragraph(f"Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}", label_style))
|
||||
story.append(HRFlowable(width="100%", thickness=2, color=colors.HexColor("#1a1a2e"), spaceAfter=12))
|
||||
|
||||
# Información general
|
||||
story.append(Paragraph("Información de la Alerta", subtitulo_style))
|
||||
|
||||
rule = alert.get("rule") or {}
|
||||
group = alert.get("group") or {}
|
||||
info_data = [
|
||||
["ID Alerta", str(alert.get("id", "N/A")), "Estado", _estado_label("Cerrada")],
|
||||
["Severidad", str(rule.get("severity", "N/A")), "Fecha", _fmt_fecha(alert.get("created_at", ""))],
|
||||
["Grupo", str(alert.get("group_id", "N/A")) + f" ({group.get('username', 'N/A')})", "Mensaje ID", str(alert.get("message_id", "N/A"))],
|
||||
]
|
||||
info_table = Table(info_data, colWidths=[3.5*cm, 6.5*cm, 3.5*cm, 6.5*cm])
|
||||
info_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#e8ecf0")),
|
||||
("BACKGROUND", (2, 0), (2, -1), colors.HexColor("#e8ecf0")),
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (2, 0), (2, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("PADDING", (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(info_table)
|
||||
|
||||
if rule.get("description"):
|
||||
story.append(Paragraph("Descripción de la Regla", subtitulo_style))
|
||||
story.append(_p(rule.get("description", ""), valor_style))
|
||||
|
||||
if rule.get("regex"):
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(Paragraph("<b>Regex:</b>", label_style))
|
||||
story.append(_p(rule.get("regex", ""), valor_style))
|
||||
|
||||
# Mensaje que originó la alerta
|
||||
if message:
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#cccccc"), spaceBefore=14, spaceAfter=6))
|
||||
story.append(Paragraph("Mensaje que Originó la Alerta", subtitulo_style))
|
||||
|
||||
sender = message.get("sender") or {}
|
||||
|
||||
story.append(Paragraph("<b>Remitente del mensaje:</b>", label_style))
|
||||
nombre_sender = f"{'N/A' if sender.get('first_name') == 'None' else sender.get('first_name', 'N/A')} {'' if sender.get('last_name') == 'None' else sender.get('last_name', 'N/A')}".strip() or "N/A"
|
||||
phone = "Sin teléfono" if sender.get("phone") == "None" else sender.get("phone", "N/A")
|
||||
sender_data = [
|
||||
["ID Telegram", str(sender.get("id_telegram", "N/A")), "Username", f"@{sender.get('username', 'N/A')}"],
|
||||
["Nombre", nombre_sender, "Tipo", str(sender.get("user", "N/A"))],
|
||||
["Teléfono", phone, "Fecha", _fmt_fecha(message.get("date", ""))],
|
||||
]
|
||||
sender_table = Table(sender_data, colWidths=[3.5*cm, 6.5*cm, 3.5*cm, 6.5*cm])
|
||||
sender_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#e8ecf0")),
|
||||
("BACKGROUND", (2, 0), (2, -1), colors.HexColor("#e8ecf0")),
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (2, 0), (2, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("PADDING", (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(sender_table)
|
||||
|
||||
story.append(Spacer(1, 8))
|
||||
story.append(Paragraph("<b>Contenido del mensaje:</b>", label_style))
|
||||
content_table = Table(
|
||||
[[_p(message.get("content") or "Mensaje vacío", nota_content_style)]],
|
||||
colWidths=[doc.width]
|
||||
)
|
||||
content_table.setStyle(TableStyle([
|
||||
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("PADDING", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f9f9f9")),
|
||||
]))
|
||||
story.append(content_table)
|
||||
|
||||
# Adjuntos en el PDF: solo listamos metadata (no podemos embeber binarios aquí)
|
||||
adjuntos = message.get("attachments") or []
|
||||
if adjuntos:
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(Paragraph("<b>Adjuntos:</b>", label_style))
|
||||
for adj in adjuntos:
|
||||
adj_line = (
|
||||
f"• [{adj.get('type', 'N/A').upper()}] "
|
||||
f"{adj.get('description', 'Sin descripción')} "
|
||||
f"(ID: {adj.get('id', 'N/A')})"
|
||||
)
|
||||
story.append(_p(adj_line, valor_style))
|
||||
|
||||
# Motivo de cierre
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#cccccc"), spaceBefore=14, spaceAfter=6))
|
||||
story.append(Paragraph("Motivo de Cierre", subtitulo_style))
|
||||
story.append(_p(motivo_cierre or "Sin motivo especificado.", motivo_style))
|
||||
|
||||
# Historial de notas
|
||||
story.append(Paragraph("Historial de Notas", subtitulo_style))
|
||||
|
||||
if not notes:
|
||||
story.append(Paragraph("No hay notas registradas para esta alerta.", label_style))
|
||||
else:
|
||||
for note in notes:
|
||||
user_id = note.get("user_id")
|
||||
user = usuarios.get(user_id) or {}
|
||||
nombre = escape(user.get("name", f"Usuario #{user_id}"))
|
||||
rol = escape(user.get("rol", ""))
|
||||
fecha_nota = _fmt_fecha(note.get("creation_date", ""))
|
||||
|
||||
header_table = Table(
|
||||
[[Paragraph(f"<b>{nombre}</b> — {rol}", label_style),
|
||||
Paragraph(fecha_nota, right_style)]],
|
||||
colWidths=[doc.width * 0.7, doc.width * 0.3]
|
||||
)
|
||||
header_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f0f4f8")),
|
||||
("PADDING", (0, 0), (-1, -1), 5),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
]))
|
||||
story.append(header_table)
|
||||
|
||||
content_table = Table(
|
||||
[[_p(note.get("content", ""), nota_content_style)]],
|
||||
colWidths=[doc.width]
|
||||
)
|
||||
content_table.setStyle(TableStyle([
|
||||
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
|
||||
("PADDING", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.white),
|
||||
]))
|
||||
story.append(content_table)
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
# Pie de página
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#cccccc"), spaceBefore=10))
|
||||
story.append(Paragraph(
|
||||
f"Documento generado automáticamente por TIP — Alerta #{alert.get('id', 'N/A')}",
|
||||
footer_style
|
||||
))
|
||||
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dialogs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("Ver mensaje", width="large")
|
||||
def _render_detalle_mensaje(message: dict):
|
||||
"""Renderiza el detalle completo del mensaje, su sender y sus adjuntos con descarga."""
|
||||
api = api_client
|
||||
|
||||
if not message:
|
||||
st.error("No se pudo cargar el mensaje.")
|
||||
return
|
||||
|
||||
# Datos del sender
|
||||
sender = message.get("sender") or {}
|
||||
st.subheader("👤 Remitente")
|
||||
mcol1, mcol2 = st.columns(2)
|
||||
with mcol1:
|
||||
st.write(f"**ID Telegram:** {sender.get('id_telegram', 'N/A')}")
|
||||
st.write(f"**Username:** @{sender.get('username', 'N/A')}")
|
||||
with mcol2:
|
||||
st.write(f"**Nombre:** {'N/A' if sender.get('first_name') == 'None' else sender.get('first_name', 'N/A')} {'' if sender.get('last_name') == 'None' else sender.get('last_name', '')}")
|
||||
st.write(f"**Teléfono:** {'Sin teléfono' if sender.get('phone') == 'None' else sender.get('phone', 'N/A')}")
|
||||
try:
|
||||
fecha = datetime.fromisoformat(str(message.get("date", "")))
|
||||
st.write(f"**Fecha:** {fecha.strftime('%d/%m/%Y %H:%M')}")
|
||||
except Exception:
|
||||
st.write(f"**Fecha:** {message.get('date', 'N/A')}")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Contenido del mensaje
|
||||
st.subheader("💬 Contenido")
|
||||
st.write(message.get("content") or "_Mensaje vacío_")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Adjuntos con descarga
|
||||
st.subheader("📎 Adjuntos")
|
||||
adjuntos = message.get("attachments") or []
|
||||
context_key = f"dialog_{message.get('id_mess_g', '')}_{message.get('group_id', '')}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
|
||||
@st.dialog("Administrar Alerta", width="large")
|
||||
def dialog_administrar_alerta(alert):
|
||||
api = api_client
|
||||
alert_id = alert.get("id")
|
||||
|
||||
if alert.get("status") == "open":
|
||||
updated = api.set_alert_in_progress(alert_id)
|
||||
if updated:
|
||||
alert = updated
|
||||
|
||||
rule = alert.get("rule", {})
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
alerta = "Pendiente" if alert.get("status") == "open" else "En Curso" if alert.get("status") == "in_progress" else "Resuelto"
|
||||
st.write(f"**Estado:** {alerta}")
|
||||
st.write(f"**Severidad:** {rule.get('severity', 'N/A') if rule else 'N/A'}")
|
||||
with col2:
|
||||
fecha = datetime.fromisoformat(str(alert.get("created_at", "")))
|
||||
st.write(f"**Fecha:** {fecha.strftime('%d/%m/%Y %H:%M')}")
|
||||
|
||||
# Mensaje + adjuntos dentro del dialog de administración
|
||||
message = api.get_message(alert.get("group_id"), alert.get("message_id"))
|
||||
if message:
|
||||
with st.expander("📨 Ver mensaje que originó la alerta", expanded=False):
|
||||
sender = message.get("sender") or {}
|
||||
st.write(f"**De:** {'N/A' if sender.get('first_name') == 'None' else sender.get('first_name', 'N/A')} {'' if sender.get('last_name') == 'None' else sender.get('last_name', '')} (@{sender.get('username', 'N/A')})")
|
||||
st.write(f"**Contenido:** {message.get('content', '_Sin contenido_')}")
|
||||
|
||||
adjuntos = message.get("attachments") or []
|
||||
if adjuntos:
|
||||
st.divider()
|
||||
context_key = f"adm_{alert_id}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
st.divider()
|
||||
st.subheader("📋 Historial de notas")
|
||||
notes = api.get_notes_for_alert(alert_id=alert_id)
|
||||
|
||||
if not notes:
|
||||
st.info("Sin notas todavía.")
|
||||
else:
|
||||
for note in notes:
|
||||
with st.container(border=True):
|
||||
col_user, col_date = st.columns([3, 1])
|
||||
user = api.get_user(user_id=note.get("user_id"))
|
||||
with col_user:
|
||||
if user:
|
||||
col_user_info1, col_user_info2 = st.columns(2)
|
||||
col_user_info1.write(f"Usuario: {user.get('name', 'Usuario')}")
|
||||
col_user_info2.write(f"Rol: {user.get('rol', 'Sin rol')}")
|
||||
else:
|
||||
st.write("Usuario: **No encontrado**")
|
||||
with col_date:
|
||||
fecha_nota = datetime.fromisoformat(str(note.get("creation_date", "")))
|
||||
st.caption(fecha_nota.strftime("%d/%m/%Y %H:%M"))
|
||||
st.write(note.get("content", ""))
|
||||
|
||||
st.divider()
|
||||
|
||||
with st.form("form_nota_dialog", clear_on_submit=True):
|
||||
content = st.text_area("Agregar nota a la alerta")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
submitted = st.form_submit_button("Guardar")
|
||||
with col2:
|
||||
if alert.get("status") == "open" or alert.get("status") == "in_progress":
|
||||
marcar_resuelta = st.checkbox("Marcar como resuelta")
|
||||
state = "open"
|
||||
elif alert.get("status") == "close":
|
||||
marcar_resuelta = st.checkbox("Volver a abrir alerta")
|
||||
state = "close"
|
||||
else:
|
||||
marcar_resuelta = False
|
||||
state = "open"
|
||||
|
||||
if submitted and content:
|
||||
usuario = get_user_info()
|
||||
user_id = usuario.get("id")
|
||||
result = api.add_note_to_alert(alert_id=alert_id, user_id=user_id, content=content)
|
||||
|
||||
if marcar_resuelta:
|
||||
if state == "open":
|
||||
marked = api.mark_alert_as_resolved(alert_id=alert_id)
|
||||
if result and marked:
|
||||
st.success("Alerta cerrada correctamente, generando reporte PDF...")
|
||||
notes = api.get_notes_for_alert(alert_id=alert_id) or []
|
||||
usuarios = {}
|
||||
for note in notes:
|
||||
uid = note.get("user_id")
|
||||
if uid and uid not in usuarios:
|
||||
u = api.get_user(user_id=uid)
|
||||
if u:
|
||||
usuarios[uid] = u
|
||||
pdf_bytes = generar_reporte_alerta_pdf(
|
||||
alert=alert,
|
||||
notes=notes,
|
||||
motivo_cierre=content,
|
||||
usuarios=usuarios,
|
||||
message=message
|
||||
)
|
||||
st.download_button(
|
||||
label="📄 Descargar reporte PDF",
|
||||
data=pdf_bytes,
|
||||
file_name=f"reporte_alerta_{alert_id}.pdf",
|
||||
mime="application/pdf"
|
||||
)
|
||||
else:
|
||||
st.error("Error al guardar o actualizar estado de alerta.")
|
||||
elif state == "close":
|
||||
marked = api.mark_alert_as_pending(alert_id=alert_id)
|
||||
if result and marked:
|
||||
st.success("Nota y estado actualizados.")
|
||||
sleep(1)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al guardar o actualizar estado de alerta.")
|
||||
else:
|
||||
if result:
|
||||
st.success("Nota guardada.")
|
||||
st.session_state.open_dialog = True
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al guardar.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filtros, rendering de alertas y paginación
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_filtros() -> tuple:
|
||||
"""Renderiza los controles de filtro y devuelve los valores seleccionados."""
|
||||
col1, col_filtro1, col_filtro2 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
per_page = st.selectbox(
|
||||
"Alertas por página",
|
||||
options=[5, 10, 20, 50, 100],
|
||||
index=1,
|
||||
key="per_page_select"
|
||||
)
|
||||
if per_page != st.session_state.alerts_per_page:
|
||||
st.session_state.alerts_per_page = per_page
|
||||
st.session_state.alert_page = 1
|
||||
|
||||
with col_filtro1:
|
||||
filtro_severidad = st.selectbox("Filtrar por severidad", ["Todas", "Alta", "Media", "Baja"])
|
||||
with col_filtro2:
|
||||
filtro_estado = st.selectbox("Filtrar por estado", ["Todos", "Pendiente", "En Curso" ,"Resuelto"])
|
||||
|
||||
col_f1, col_h1, col_f2, col_h2 = st.columns(4)
|
||||
with col_f1:
|
||||
fecha_desde = st.date_input("Desde (fecha)", value=None, key="fecha_desde")
|
||||
with col_h1:
|
||||
hora_desde = st.time_input("Desde (hora)", value=time(0, 0), key="hora_desde",
|
||||
disabled=fecha_desde is None)
|
||||
with col_f2:
|
||||
fecha_hasta = st.date_input("Hasta (fecha)", value=None, key="fecha_hasta")
|
||||
with col_h2:
|
||||
hora_hasta = st.time_input("Hasta (hora)", value=time(23, 59, 59), key="hora_hasta",
|
||||
disabled=fecha_hasta is None)
|
||||
|
||||
# Combinar fecha + hora en datetime completo
|
||||
dt_desde = datetime.combine(fecha_desde, hora_desde) if fecha_desde else None
|
||||
dt_hasta = datetime.combine(fecha_hasta, hora_hasta) if fecha_hasta else None
|
||||
|
||||
return filtro_severidad, filtro_estado, dt_desde, dt_hasta
|
||||
|
||||
|
||||
def _render_alerta(alert: dict, i: int, skip: int, api):
|
||||
"""Renderiza una alerta individual con su expander y botones."""
|
||||
rule = alert.get("rule", {})
|
||||
descripcion = rule.get("description", "Sin descripción") if rule else "Sin descripción"
|
||||
message_id = alert.get("message_id")
|
||||
group_id = alert.get("group_id")
|
||||
message = api.get_message(group_id, message_id)
|
||||
|
||||
# Indica si el mensaje tiene adjuntos para mostrarlo en el título
|
||||
adjuntos = (message.get("attachments") or []) if message else []
|
||||
adj_label = f" 📎 {len(adjuntos)}" if adjuntos else ""
|
||||
|
||||
with st.expander(
|
||||
f"Alerta #{skip + i} - {descripcion[:50]}{'...' if len(descripcion) > 50 else ''}{adj_label}",
|
||||
expanded=False
|
||||
):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**ID:** {message_id}")
|
||||
st.write(f"**Grupo:** {group_id}")
|
||||
if rule:
|
||||
st.write(f"**Severidad:** {rule.get('severity', 'N/A')}")
|
||||
with col2:
|
||||
fecha = datetime.fromisoformat(str(alert.get("created_at", "")))
|
||||
st.write(f"**Fecha:** {fecha.strftime('%d/%m/%Y %H:%M')}")
|
||||
estado_label = "Pendiente" if alert.get("status") == "open" else "En Curso" if alert.get("status") == "in_progress" else "Resuelto"
|
||||
st.write(f"**Estado:** {estado_label}")
|
||||
|
||||
st.write("**Descripción Regla:**")
|
||||
st.write(descripcion if rule else "Sin descripción disponible")
|
||||
_render_botones_alerta(alert, message, i, skip)
|
||||
|
||||
|
||||
def _render_botones_alerta(alert: dict, message: dict, i: int, skip: int):
|
||||
"""Renderiza los botones de acción de una alerta."""
|
||||
col_btn1, col_btn2 = st.columns(2)
|
||||
|
||||
with col_btn1:
|
||||
if st.button("Ver mensaje", key=f"det_{alert.get('id', i)}_{skip}"):
|
||||
_render_detalle_mensaje(message)
|
||||
with col_btn2:
|
||||
if st.button("Administrar alerta", key=f"res_{alert.get('id', i)}_{skip}"):
|
||||
st.session_state.selected_alert = alert
|
||||
dialog_administrar_alerta(alert)
|
||||
|
||||
|
||||
def _render_paginacion(total_recibidas: int, limit: int):
|
||||
"""Renderiza los controles de paginación."""
|
||||
hay_mas_paginas = total_recibidas == limit
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
|
||||
with col_prev:
|
||||
if st.button("◀️ Anterior", disabled=st.session_state.alert_page <= 1, use_container_width=True):
|
||||
st.session_state.alert_page -= 1
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.alert_page}**")
|
||||
st.caption(f"Alertas mostradas: {total_recibidas}")
|
||||
with col_next:
|
||||
if st.button("Siguiente ▶️", disabled=not hay_mas_paginas, use_container_width=True):
|
||||
st.session_state.alert_page += 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def mostrar_alertas():
|
||||
api = api_client
|
||||
|
||||
if "alert_page" not in st.session_state:
|
||||
st.session_state.alert_page = 1
|
||||
if "alerts_per_page" not in st.session_state:
|
||||
st.session_state.alerts_per_page = 10
|
||||
|
||||
severidad, estado, dt_desde, dt_hasta = _render_filtros()
|
||||
|
||||
status_param = None
|
||||
if estado == "Pendiente":
|
||||
status_param = "open"
|
||||
elif estado == "En Curso":
|
||||
status_param = "in_progress"
|
||||
elif estado == "Resuelto":
|
||||
status_param = "close"
|
||||
|
||||
severity_param = None if severidad == "Todas" else severidad
|
||||
|
||||
skip = (st.session_state.alert_page - 1) * st.session_state.alerts_per_page
|
||||
limit = st.session_state.alerts_per_page
|
||||
|
||||
with st.spinner("Cargando alertas..."):
|
||||
alerts_data = api.get_alerts(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status_param,
|
||||
severity=severity_param,
|
||||
date_from=dt_desde.isoformat() if dt_desde else None,
|
||||
date_to=dt_hasta.isoformat() if dt_hasta else None,
|
||||
)
|
||||
|
||||
if alerts_data is None:
|
||||
st.error("No se pudieron cargar las alertas. Verifica tu autenticación.")
|
||||
st.session_state.alert_page = 1
|
||||
return
|
||||
|
||||
if st.session_state.get("open_dialog") and st.session_state.get("selected_alert"):
|
||||
st.session_state.open_dialog = False
|
||||
dialog_administrar_alerta(st.session_state.selected_alert)
|
||||
|
||||
total_recibidas = len(alerts_data)
|
||||
|
||||
if total_recibidas > 0:
|
||||
st.info(f"Mostrando {total_recibidas} alerta(s) — Página {st.session_state.alert_page}")
|
||||
else:
|
||||
st.info("No hay alertas para mostrar")
|
||||
if st.session_state.alert_page > 1:
|
||||
st.session_state.alert_page = 1
|
||||
st.rerun()
|
||||
|
||||
for i, alert in enumerate(alerts_data, 1):
|
||||
_render_alerta(alert, i, skip, api)
|
||||
|
||||
_render_paginacion(total_recibidas, limit)
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
|
||||
|
||||
ENTITY_TYPE_OPTIONS = ["Todas", "rule", "alert", "group", "message", "sender"]
|
||||
ACTION_OPTIONS = ["Todas", "create", "update", "delete", "status_change"]
|
||||
|
||||
|
||||
def _fmt_fecha(fecha_str: str) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(str(fecha_str)).strftime("%d/%m/%Y %H:%M:%S")
|
||||
except Exception:
|
||||
return str(fecha_str) or "N/A"
|
||||
|
||||
|
||||
def _fmt_json(value: Optional[str]) -> str:
|
||||
"""Intenta formatear un JSON string de forma legible."""
|
||||
if not value:
|
||||
return "—"
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def mostrar_audit():
|
||||
"""Vista de logs de auditoría con filtros."""
|
||||
api = api_client
|
||||
|
||||
st.title("📋 Logs de Auditoría")
|
||||
|
||||
# --- Filtros ---
|
||||
with st.expander("🔍 Filtros", expanded=True):
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
entity_type_sel = st.selectbox("Entidad", ENTITY_TYPE_OPTIONS, key="audit_entity")
|
||||
action_sel = st.selectbox("Acción", ACTION_OPTIONS, key="audit_action")
|
||||
with col2:
|
||||
entity_id_sel = st.text_input("ID de entidad (opcional)", key="audit_entity_id",
|
||||
placeholder="ej: 42 o 2709_-1002447866299")
|
||||
user_id_sel = st.number_input("ID de usuario (0 = todos)", min_value=0,
|
||||
value=0, key="audit_user_id")
|
||||
with col3:
|
||||
fecha_desde = st.date_input("Desde", value=None, key="audit_desde")
|
||||
fecha_hasta = st.date_input("Hasta", value=None, key="audit_hasta")
|
||||
|
||||
per_page = st.selectbox("Registros por página", [10, 25, 50, 100], key="audit_per_page")
|
||||
|
||||
# --- Paginación ---
|
||||
if "audit_page" not in st.session_state:
|
||||
st.session_state.audit_page = 1
|
||||
|
||||
skip = (st.session_state.audit_page - 1) * per_page
|
||||
|
||||
# --- Llamada a la API ---
|
||||
with st.spinner("Cargando logs..."):
|
||||
logs = api.get_audit_logs(
|
||||
entity_type = None if entity_type_sel == "Todas" else entity_type_sel,
|
||||
entity_id = entity_id_sel.strip() or None,
|
||||
action = None if action_sel == "Todas" else action_sel,
|
||||
user_id = int(user_id_sel) if user_id_sel > 0 else None,
|
||||
date_from = fecha_desde.isoformat() if fecha_desde else None,
|
||||
date_to = fecha_hasta.isoformat() if fecha_hasta else None,
|
||||
skip = skip,
|
||||
limit = per_page,
|
||||
)
|
||||
|
||||
if logs is None:
|
||||
st.error("No se pudieron cargar los logs de auditoría.")
|
||||
return
|
||||
|
||||
total = len(logs)
|
||||
|
||||
if total == 0:
|
||||
st.info("No hay registros para los filtros seleccionados.")
|
||||
else:
|
||||
st.info(f"Mostrando {skip + 1}–{skip + total} — Página {st.session_state.audit_page}")
|
||||
|
||||
# --- Tabla de logs ---
|
||||
for log in logs:
|
||||
action_icons = {
|
||||
"create": "🟢",
|
||||
"update": "🟡",
|
||||
"delete": "🔴",
|
||||
"status_change":"🔵",
|
||||
}
|
||||
icon = action_icons.get(log.get("action", ""), "⚪")
|
||||
label = (
|
||||
f"{icon} [{_fmt_fecha(log.get('timestamp', ''))}] "
|
||||
f"{log.get('entity_type','').upper()} #{log.get('entity_id','')} — "
|
||||
f"{log.get('action','').upper()}"
|
||||
)
|
||||
|
||||
with st.expander(label, expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**ID log:** {log.get('id')}")
|
||||
st.write(f"**Entidad:** {log.get('entity_type')} `{log.get('entity_id')}`")
|
||||
st.write(f"**Acción:** {log.get('action')}")
|
||||
user_display = "🤖 Feeder" if log.get("user_id") == -1 else str(log.get("user_id") or "—")
|
||||
st.write(f"**Usuario:** {user_display}")
|
||||
st.write(f"**IP:** {log.get('ip_address') or '—'}")
|
||||
with col2:
|
||||
st.write(f"**Fecha:** {_fmt_fecha(log.get('timestamp', ''))}")
|
||||
|
||||
if log.get("before_value"):
|
||||
st.write("**Estado anterior:**")
|
||||
st.code(_fmt_json(log.get("before_value")), language="json")
|
||||
|
||||
if log.get("after_value"):
|
||||
st.write("**Estado posterior:**")
|
||||
st.code(_fmt_json(log.get("after_value")), language="json")
|
||||
|
||||
# --- Paginación ---
|
||||
hay_mas = total == per_page
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("◀️ Anterior", disabled=st.session_state.audit_page <= 1,
|
||||
use_container_width=True, key="audit_prev"):
|
||||
st.session_state.audit_page -= 1
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.audit_page}**")
|
||||
st.caption(f"Registros en esta página: {total}")
|
||||
with col_next:
|
||||
if st.button("Siguiente ▶️", disabled=not hay_mas,
|
||||
use_container_width=True, key="audit_next"):
|
||||
st.session_state.audit_page += 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
src/pages/views/components.py
|
||||
Componentes reutilizables entre vistas.
|
||||
"""
|
||||
import streamlit as st
|
||||
import html
|
||||
|
||||
def safe_html(value: str, max_length: int = 3000) -> str:
|
||||
"""Escapa caracteres HTML y trunca el valor."""
|
||||
if not value:
|
||||
return ""
|
||||
return html.escape(str(value))[:max_length]
|
||||
|
||||
|
||||
ATTACHMENT_TYPE_MAP = {
|
||||
"photo": {"ext": "jpg", "mime": "image/jpeg", "icon": "🖼️"},
|
||||
"video": {"ext": "mp4", "mime": "video/mp4", "icon": "🎥"},
|
||||
"audio": {"ext": "mp3", "mime": "audio/mpeg", "icon": "🎵"},
|
||||
"document": {"ext": "bin", "mime": "application/octet-stream", "icon": "📄"},
|
||||
"sticker": {"ext": "webp", "mime": "image/webp", "icon": "🎭"},
|
||||
"voice": {"ext": "ogg", "mime": "audio/ogg", "icon": "🎙️"},
|
||||
"gif": {"ext": "gif", "mime": "image/gif", "icon": "🎞️"},
|
||||
}
|
||||
|
||||
|
||||
def render_adjuntos(adjuntos: list, api, context_key: str = ""):
|
||||
"""
|
||||
Renderiza la lista de adjuntos de un mensaje con botones de descarga.
|
||||
Reutilizable desde cualquier vista — alerts, messages, senders, etc.
|
||||
|
||||
Args:
|
||||
adjuntos: lista de dicts con datos del adjunto
|
||||
api: instancia de APIClient
|
||||
context_key: sufijo único para evitar colisión de keys de Streamlit
|
||||
"""
|
||||
if not adjuntos:
|
||||
st.caption("Sin adjuntos.")
|
||||
return
|
||||
|
||||
st.write(f"**📎 Adjuntos ({len(adjuntos)}):**")
|
||||
|
||||
for adj in adjuntos:
|
||||
adj_id = adj.get("id")
|
||||
adj_type = adj.get("type", "document")
|
||||
adj_desc = adj.get("description", "Sin descripción")
|
||||
|
||||
type_info = ATTACHMENT_TYPE_MAP.get(adj_type, ATTACHMENT_TYPE_MAP["document"])
|
||||
icon = type_info["icon"]
|
||||
ext = type_info["ext"]
|
||||
mime = type_info["mime"]
|
||||
|
||||
col_info, col_btn = st.columns([4, 2])
|
||||
with col_info:
|
||||
st.caption(
|
||||
f"{icon} **{adj_type.upper()}** — {adj_desc}"
|
||||
)
|
||||
with col_btn:
|
||||
if adj_id is None:
|
||||
st.caption("_sin ID_")
|
||||
continue
|
||||
|
||||
btn_key = f"dl_adj_{adj_id}_{context_key}"
|
||||
|
||||
if st.button("⬇️ Descargar", key=btn_key):
|
||||
with st.spinner(f"Descargando adjunto #{adj_id}..."):
|
||||
feeder_ok = api.has_connection()
|
||||
scraper_status = api.telegram_status()
|
||||
if not feeder_ok:
|
||||
st.error("No hay conexión con la API de Telegram. Révisa la conexión a Internet")
|
||||
continue
|
||||
if not scraper_status.get("is_active"):
|
||||
st.error("No se encontró una sesión de Telegram válida. Solicita ayuda a un admin")
|
||||
continue
|
||||
else:
|
||||
file_bytes = api.download_attachment(adj_id)
|
||||
|
||||
if file_bytes:
|
||||
filename = f"adjunto_{adj_id}.{ext}"
|
||||
st.download_button(
|
||||
label=f"💾 Guardar {filename}",
|
||||
data=file_bytes,
|
||||
file_name=filename,
|
||||
mime=mime,
|
||||
key=f"save_adj_{adj_id}_{context_key}"
|
||||
)
|
||||
else:
|
||||
st.error(
|
||||
f"No se pudo descargar el adjunto #{adj_id}. "
|
||||
"El archivo puede no estar disponible en Telegram."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.pages.views.components import safe_html
|
||||
|
||||
def agregar_grupo():
|
||||
api = api_client
|
||||
|
||||
col1, col2 = st.columns([3, 1])
|
||||
with col1:
|
||||
channel_url = st.text_input("ID del grupo/canal (número de Telegram)", value="", placeholder="Ej: https://web.telegram.org/a/#-10012142221")
|
||||
with col2:
|
||||
add_clicked = st.button("Agregar Grupo", key="add_group_btn", use_container_width=True)
|
||||
|
||||
if add_clicked:
|
||||
|
||||
channel_id_raw = channel_url.split(sep='#')[1]
|
||||
|
||||
feeder_ok = api.has_connection()
|
||||
scraper_status = api.telegram_status()
|
||||
|
||||
if not feeder_ok:
|
||||
st.error("No hay conexión con la API de Telegram. Révisa la conexión a Internet")
|
||||
return
|
||||
|
||||
if not scraper_status.get("is_active"):
|
||||
st.error("No se encontró una sesión de Telegram válida. Solicita ayuda a un admin")
|
||||
return
|
||||
|
||||
if not channel_id_raw:
|
||||
st.error("Debes indicar el ID del grupo/canal.")
|
||||
return
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id_raw)
|
||||
except Exception:
|
||||
st.error("Hubo un error al intentar procesar el ID de la URL.")
|
||||
return
|
||||
|
||||
with st.spinner("Agregando grupo/canal..."):
|
||||
res = api.add_channel(channel_id)
|
||||
|
||||
if res is None:
|
||||
st.error("No se pudo agregar el grupo. Revisa la conexión o permisos.")
|
||||
else:
|
||||
if res.get("status") == "exists":
|
||||
st.error("El grupo/canal ya existe en el sistema.")
|
||||
else:
|
||||
st.success("Grupo/canal agregado correctamente.")
|
||||
try:
|
||||
st.json(res)
|
||||
except Exception:
|
||||
st.write(res)
|
||||
|
||||
def listar_grupos():
|
||||
"""Función para listar grupos (puede ser usada en el futuro para mostrar los grupos existentes)"""
|
||||
api = api_client
|
||||
with st.spinner("Cargando grupos..."):
|
||||
grupos = api.get_groups()
|
||||
|
||||
if grupos is None:
|
||||
st.error("No se pudieron cargar los grupos. Verifica tu autenticación.")
|
||||
return
|
||||
|
||||
# Filtro de búsqueda
|
||||
search_term = st.text_input("Buscar grupos por nombre o ID:", key="search_grupos")
|
||||
|
||||
# Filtrar grupos si hay término de búsqueda
|
||||
if search_term:
|
||||
grupos_filtrados = [
|
||||
grupo for grupo in grupos
|
||||
if search_term.lower() in str(grupo.get('name', '')).lower() or
|
||||
search_term.lower() in str(grupo.get('id_telegram', '')).lower()
|
||||
]
|
||||
else:
|
||||
grupos_filtrados = grupos
|
||||
|
||||
st.write(f"Mostrando {len(grupos_filtrados)} de {len(grupos)} grupos")
|
||||
|
||||
tipo_map = {
|
||||
"public_supergroup": "Canal publico", "private_supergroup": "Canal privado",
|
||||
"public_channel": "Canal publico", "private_channel": "Canal privado",
|
||||
"group": "Grupo publico", "private_group": "Grupo privado"
|
||||
}
|
||||
|
||||
for grupo in grupos_filtrados:
|
||||
#st.write(f"**ID Telegram:** {grupo.get('id_telegram', 'N/A')} | **Nombre:** {grupo.get('name', 'N/A')} | **Tipo:** {tipo_map.get(grupo.get('type', 'N/A'), 'Desconocido')}")
|
||||
#st.write(f"**Descripción:** {grupo.get('description', 'N/A')}")
|
||||
#st.divider()
|
||||
name = safe_html(grupo.get('name', 'N/A'))
|
||||
type = safe_html(tipo_map.get(grupo.get('type', 'N/A'), 'Desconocido'))
|
||||
group_id = safe_html(grupo.get('id_telegram', 'N/A'))
|
||||
description = safe_html(grupo.get('description', 'N/A'))
|
||||
|
||||
col_info, col_btn = st.columns([5, 1])
|
||||
|
||||
with col_info:
|
||||
st.markdown(
|
||||
f"**{name}** · {type}"
|
||||
f"<br><span style='font-size:11px;opacity:0.5;'>ID: {group_id} · Descripción: {description}</span>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
with col_btn:
|
||||
st.link_button("Ir al grupo (Externo)", f"https://web.telegram.org/a/#{group_id}")
|
||||
#st.warning("Abriendo el grupo en una nueva pestaña...")
|
||||
|
||||
st.markdown(
|
||||
"<hr style='margin:6px 0;border:none;border-top:0.5px solid rgba(128,128,128,0.15);'>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
@@ -0,0 +1,736 @@
|
||||
import streamlit as st
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CUSTOM CSS (dark theme, fonts, components)
|
||||
# ----------------------------------------------------------------------
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
/* Import fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
/* Global dark background */
|
||||
.stApp {
|
||||
background: #0d1117;
|
||||
}
|
||||
body, .stApp, .main > div {
|
||||
background-color: #0d1117;
|
||||
color: #e8f0fe;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
[data-testid="stSidebar"] {
|
||||
background-color: #111920;
|
||||
border-right: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
[data-testid="stSidebar"] .sidebar-content {
|
||||
background-color: #111920;
|
||||
}
|
||||
|
||||
/* Hide default Streamlit branding */
|
||||
#MainMenu {visibility: hidden;}
|
||||
footer {visibility: hidden;}
|
||||
header {visibility: hidden;}
|
||||
|
||||
/* Buttons for module navigation */
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0 6px 6px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
margin-bottom: 2px;
|
||||
transition: all 0.15s;
|
||||
text-align: left;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
}
|
||||
.nav-button:hover {
|
||||
background: rgba(160,230,100,0.08);
|
||||
}
|
||||
.nav-button.active {
|
||||
background: rgba(160,230,100,0.1);
|
||||
border-left-color: #7ec030;
|
||||
color: #c8e6a0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
.solo-admin {
|
||||
font-size: 9px;
|
||||
color: #7ec030;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.badge-admin {
|
||||
background: linear-gradient(135deg, #2d5a0e, #3d7a12);
|
||||
color: #c8e6a0;
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(160,230,100,0.25);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.badge-operator {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.55);
|
||||
font-size: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Step cards */
|
||||
.step-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-left: 3px solid rgba(160,230,100,0.4);
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.step-icon {
|
||||
font-size: 16px;
|
||||
min-width: 22px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d4edb0;
|
||||
margin-bottom: 2px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.step-desc {
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Expanders (collapsible sections) */
|
||||
.streamlit-expanderHeader {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #c8e6a0 !important;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 0 !important;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
.streamlit-expanderHeader:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
.streamlit-expanderContent {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
.module-description {
|
||||
font-size: 13.5px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
line-height: 1.65;
|
||||
font-style: italic;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Header styling */
|
||||
.custom-header {
|
||||
background: linear-gradient(135deg, #1a2e0a 0%, #0d1a05 60%, #0d1117 100%);
|
||||
border-bottom: 1px solid rgba(160,230,100,0.15);
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.header-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(160,230,100,0.15);
|
||||
border: 1px solid rgba(160,230,100,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #c8e6a0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.header-version {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# DATA (same as original JSX)
|
||||
# ----------------------------------------------------------------------
|
||||
MODULES = [
|
||||
{
|
||||
"id": "inicio",
|
||||
"icon": "🏠",
|
||||
"label": "Inicio",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Panel de bienvenida con métricas del sistema y estado del alimentador.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "¿Qué muestra esta pantalla?",
|
||||
"content": "Al iniciar sesión, el sistema te lleva al panel de inicio. Desde aquí podés ver un resumen del estado actual de la plataforma sin necesidad de navegar a ningún lado."
|
||||
},
|
||||
{
|
||||
"title": "Métricas principales",
|
||||
"steps": [
|
||||
{"icon": "🔔", "label": "Alertas abiertas", "desc": "Cantidad de alertas activas que requieren atención. Las alertas de hoy aparecen como un delta."},
|
||||
{"icon": "📡", "label": "Grupos activos", "desc": "Canales y grupos de Telegram que el sistema está monitoreando actualmente."},
|
||||
{"icon": "📋", "label": "Reglas activas / totales", "desc": "Cuántas reglas de detección están habilitadas y cuántas existen en total."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Alertas recientes",
|
||||
"content": "La columna izquierda muestra las últimas 5 alertas generadas. Cada tarjeta indica descripción de la regla, grupo de origen, fecha y estado (Abierta / En curso / Cerrada)."
|
||||
},
|
||||
{
|
||||
"title": "Estado del Scraper (Alimentador)",
|
||||
"content": "En la parte inferior aparece un banner verde 🟢 o rojo 🔴 que indica si el alimentador de Telegram está activo. Si está caído, los administradores verán la opción de reiniciar la sesión desde el Panel de Administrador."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alertas",
|
||||
"icon": "🔔",
|
||||
"label": "Alertas",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Gestión del ciclo de vida de alertas: revisión, notas y cierre con reporte PDF.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "¿Qué es una alerta?",
|
||||
"content": "Una alerta se genera automáticamente cuando un mensaje de Telegram coincide con el patrón (regex) de alguna regla activa. Cada alerta está vinculada al mensaje original, la regla que la disparó y el grupo de procedencia."
|
||||
},
|
||||
{
|
||||
"title": "Filtros disponibles",
|
||||
"steps": [
|
||||
{"icon": "⚠️", "label": "Severidad", "desc": "Alta / Media / Baja — heredada de la regla que disparó la alerta."},
|
||||
{"icon": "🔄", "label": "Estado", "desc": "Pendiente (open) · En Curso (in_progress) · Resuelto (close)."},
|
||||
{"icon": "📅", "label": "Rango de fechas", "desc": "Combiná fecha y hora de inicio y fin para acotar resultados."},
|
||||
{"icon": "📄", "label": "Resultados por página", "desc": "Seleccioná 5 / 10 / 20 / 50 / 100 alertas por página."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Ver el mensaje original",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Expandí la alerta", "desc": "Hacé clic sobre la tarjeta de la alerta para desplegarla."},
|
||||
{"icon": "2️⃣", "label": "Botón «Ver mensaje»", "desc": "Abre un diálogo con el remitente, contenido completo y adjuntos del mensaje."},
|
||||
{"icon": "3️⃣", "label": "Descargar adjuntos", "desc": "Si el mensaje tiene archivos, aparece el botón «⬇️ Descargar» por cada adjunto."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Administrar una alerta",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Botón «Administrar alerta»", "desc": "Abre el panel de gestión. El sistema cambia el estado a En Curso automáticamente si estaba Pendiente."},
|
||||
{"icon": "2️⃣", "label": "Leer el historial", "desc": "El panel muestra todas las notas previas con nombre de usuario, rol y fecha."},
|
||||
{"icon": "3️⃣", "label": "Agregar una nota", "desc": "Escribí en el campo de texto y presioná «Guardar»."},
|
||||
{"icon": "4️⃣", "label": "Resolver o reabrir", "desc": "Marcá el checkbox «Marcar como resuelta» antes de guardar para cerrar la alerta."},
|
||||
{"icon": "5️⃣", "label": "Descargar PDF", "desc": "Al resolver, se genera automáticamente un reporte PDF descargable con toda la trazabilidad."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mensajes",
|
||||
"icon": "💬",
|
||||
"label": "Mensajes",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Búsqueda y exploración de todos los mensajes capturados por el alimentador.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Cómo buscar mensajes",
|
||||
"steps": [
|
||||
{"icon": "🔍", "label": "Texto a buscar", "desc": "Podés ingresar una palabra o frase. Si lo dejás vacío, trae todos los mensajes paginados."},
|
||||
{"icon": "📡", "label": "Grupo", "desc": "Seleccioná un grupo específico o dejá «Todos los grupos»."},
|
||||
{"icon": "👤", "label": "Sender", "desc": "Filtrá por username, nombre o ID de Telegram del remitente."},
|
||||
{"icon": "📅", "label": "Rango de fechas y hora", "desc": "Combiná fecha de inicio y fin con hora exacta para búsquedas precisas."},
|
||||
{"icon": "▶️", "label": "Botón «🔍 Buscar»", "desc": "Ejecuta la búsqueda con los filtros seleccionados. Reinicia la paginación a la página 1."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Interpretar los resultados",
|
||||
"content": "Cada tarjeta muestra: fecha y hora, username del remitente, grupo de origen y un contador de adjuntos 📎. Expandí la tarjeta para ver el contenido completo y descargar los archivos adjuntos si los hubiera."
|
||||
},
|
||||
{
|
||||
"title": "Descargar adjuntos",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Expandí el mensaje", "desc": "Hacé clic sobre la tarjeta para desplegarla."},
|
||||
{"icon": "2️⃣", "label": "Botón «⬇️ Descargar»", "desc": "Inicia la descarga del archivo desde Telegram a través del servidor."},
|
||||
{"icon": "3️⃣", "label": "Botón «💾 Guardar»", "desc": "Una vez descargado, aparece el botón para guardar el archivo en tu computadora."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reglas",
|
||||
"icon": "📋",
|
||||
"label": "Reglas",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Creación y edición de reglas de detección basadas en expresiones regulares.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "¿Qué es una regla?",
|
||||
"content": "Una regla define un patrón (expresión regular o texto simple) que el sistema evalúa contra cada nuevo mensaje entrante. Si hay coincidencia, se genera una alerta automáticamente."
|
||||
},
|
||||
{
|
||||
"title": "Crear una nueva regla",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ir a Reglas → pestaña «Crear una regla»", "desc": "Navegá desde el menú lateral a «📋 Reglas» y hacé clic en la pestaña «Crear una regla»."},
|
||||
{"icon": "2️⃣", "label": "Descripción", "desc": "Escribí una descripción clara del propósito. Ej: «Detecta menciones de transferencias bancarias»."},
|
||||
{"icon": "3️⃣", "label": "Regex / Palabra clave", "desc": "Ingresá el patrón. Puede ser texto simple (ej: transferencia) o una regex completa (ej: (banco|cbu|alias)). Podés usar https://regex101.com para probarla."},
|
||||
{"icon": "4️⃣", "label": "Severidad", "desc": "Seleccioná Alta / Media / Baja según el nivel de riesgo."},
|
||||
{"icon": "5️⃣", "label": "Activa", "desc": "Si está marcada, la regla evalúa mensajes desde el momento en que se guarda."},
|
||||
{"icon": "6️⃣", "label": "Aplicar al histórico", "desc": "Si lo marcás, el sistema recorre TODOS los mensajes ya guardados y genera alertas retroactivas. Puede tardar si hay muchos mensajes."},
|
||||
{"icon": "7️⃣", "label": "Botón «Crear regla»", "desc": "Guarda la regla. Si la regex es inválida, el sistema lo indica antes de guardar."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Editar o desactivar una regla existente",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Pestaña «Listar reglas»", "desc": "Buscá la regla por descripción usando el campo de búsqueda."},
|
||||
{"icon": "2️⃣", "label": "Expandí la regla", "desc": "Hacé clic sobre la tarjeta para verla."},
|
||||
{"icon": "3️⃣", "label": "Botón «Editar»", "desc": "Aparece el formulario de edición con los campos actuales pre-cargados."},
|
||||
{"icon": "4️⃣", "label": "Modificá los campos", "desc": "Podés cambiar descripción, regex, severidad o desactivarla desmarcando «Activa»."},
|
||||
{"icon": "5️⃣", "label": "«Guardar cambios» o «Cancelar»", "desc": "Confirma o descarta los cambios. La regla se actualiza inmediatamente."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "grupos",
|
||||
"icon": "📡",
|
||||
"label": "Grupos y Canales",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Alta y visualización de grupos o canales de Telegram monitoreados.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Ver grupos activos",
|
||||
"content": "La pestaña «Grupos activos» lista todos los grupos/canales que el sistema está monitoreando. Cada entrada muestra nombre, tipo (Canal público, Canal privado, Grupo) e ID de Telegram. El botón «Ir al grupo» abre Telegram Web en una nueva pestaña."
|
||||
},
|
||||
{
|
||||
"title": "Agregar un grupo o canal",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Obtener el ID de Telegram", "desc": "En Telegram Web (web.telegram.org), abrí el grupo/canal. La URL tendrá la forma .../#-100XXXXXXXXX. Copiá ese fragmento completo."},
|
||||
{"icon": "2️⃣", "label": "Pegar la URL", "desc": "En la pestaña «Agregar grupo», pegá la URL completa en el campo."},
|
||||
{"icon": "3️⃣", "label": "Botón «Agregar Grupo»", "desc": "El sistema valida el ID contra Telegram. Si es accesible, lo agrega a la base de datos con sus datos actuales (nombre, tipo, descripción)."},
|
||||
{"icon": "4️⃣", "label": "Resultado", "desc": "Verás un mensaje de éxito y los datos del grupo creado o actualizado. Si ya existía, se actualizan sus datos desde Telegram."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tipos de grupos soportados",
|
||||
"steps": [
|
||||
{"icon": "📢", "label": "Canal público / privado", "desc": "ID con prefijo -100. Monitorea mensajes del canal."},
|
||||
{"icon": "👥", "label": "Supergrupo público / privado", "desc": "ID con prefijo -100. Grupos grandes de Telegram."},
|
||||
{"icon": "💬", "label": "Grupo normal", "desc": "ID negativo simple sin el 100. Grupos pequeños de hasta 200 personas."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "estadisticas",
|
||||
"icon": "📊",
|
||||
"label": "Estadísticas",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Visualizaciones y métricas agregadas del período seleccionado.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Seleccionar período",
|
||||
"steps": [
|
||||
{"icon": "📅", "label": "Fecha y hora de inicio/fin", "desc": "Usá los cuatro controles (fecha desde, hora desde, fecha hasta, hora hasta) para definir el rango."},
|
||||
{"icon": "▶️", "label": "Botón «Aplicar»", "desc": "Recarga todas las métricas y gráficos para el período elegido. Por defecto muestra los últimos 30 días."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Resumen ejecutivo",
|
||||
"content": "Fila de 7 métricas: total de alertas, abiertas, en curso, cerradas, mensajes totales, grupos activos y reglas activas."
|
||||
},
|
||||
{
|
||||
"title": "Gráficos disponibles",
|
||||
"steps": [
|
||||
{"icon": "📈", "label": "Alertas en el tiempo", "desc": "Gráfico de líneas por día: abiertas (rojo) · en curso (naranja) · cerradas (verde)."},
|
||||
{"icon": "📊", "label": "Distribución por severidad", "desc": "Barras con cantidad de alertas por nivel de severidad y porcentaje."},
|
||||
{"icon": "📊", "label": "Alertas por grupo", "desc": "Top 10 grupos con más alertas generadas."},
|
||||
{"icon": "📊", "label": "Reglas más disparadas", "desc": "Top 10 reglas por cantidad de coincidencias, con su nivel de severidad."},
|
||||
{"icon": "📊", "label": "Volumen de mensajes por día", "desc": "Barras con la cantidad de mensajes capturados cada día."},
|
||||
{"icon": "🟩", "label": "Heatmap hora × día", "desc": "Tabla de calor que muestra en qué horas y días hay más actividad. El verde más oscuro = mayor volumen."},
|
||||
{"icon": "👤", "label": "Top remitentes", "desc": "Los 10 usuarios más activos, con su cantidad de mensajes y alertas generadas."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "remitentes",
|
||||
"icon": "👥",
|
||||
"label": "Remitentes",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Exploración de los usuarios de Telegram detectados por el alimentador.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Buscar un remitente",
|
||||
"steps": [
|
||||
{"icon": "🔍", "label": "Campo de búsqueda", "desc": "Filtrá por nombre, username o ID de Telegram. La búsqueda es local sobre la página actual."},
|
||||
{"icon": "📄", "label": "Por página", "desc": "Seleccioná 10 / 20 / 50 / 100 remitentes por página."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Ver el perfil de un remitente",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Botón «Ver»", "desc": "A la derecha de cada remitente. Abre su perfil con métricas."},
|
||||
{"icon": "2️⃣", "label": "Datos del perfil", "desc": "ID de Telegram, username, nombre completo, teléfono y tipo (👤 Usuario / 🤖 Bot / 📢 Canal)."},
|
||||
{"icon": "3️⃣", "label": "Mensajes enviados", "desc": "Lista paginada de todos los mensajes del remitente con grupo, fecha y adjuntos descargables."},
|
||||
{"icon": "4️⃣", "label": "← Volver", "desc": "Botón para regresar al listado de remitentes."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin",
|
||||
"icon": "📝",
|
||||
"label": "Panel de Administrador",
|
||||
"roles": ["admin"],
|
||||
"description": "Gestión de usuarios, historial de modificaciones, estado del alimentador y auditoría.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Acceso",
|
||||
"content": "Este módulo solo es visible para usuarios con rol admin. Aparece como «📝 Panel de Administrador» en el menú lateral."
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Usuarios → Usuarios pendientes",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ver solicitudes", "desc": "Lista los usuarios que se registraron pero aún no fueron activados (active = false)."},
|
||||
{"icon": "2️⃣", "label": "Botón «Activar Usuario»", "desc": "Cambia active a true. El usuario podrá iniciar sesión desde ese momento."},
|
||||
{"icon": "3️⃣", "label": "Botón «Rechazar Usuario»", "desc": "Elimina permanentemente al usuario del sistema."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Usuarios → Crear usuario",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Completar el formulario", "desc": "Nombre, correo, contraseña, rol (operator / admin) y si se activa inmediatamente."},
|
||||
{"icon": "2️⃣", "label": "Botón «Crear usuario»", "desc": "El usuario queda registrado y activo si se marcó la opción correspondiente."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Usuarios → Editar usuario",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Seleccionar usuario", "desc": "Buscá en el desplegable por nombre, email o ID."},
|
||||
{"icon": "2️⃣", "label": "Modificar campos", "desc": "Nombre, email, rol, estado activo y contraseña (opcional)."},
|
||||
{"icon": "3️⃣", "label": "Botón «Guardar cambios»", "desc": "Aplica los cambios inmediatamente. Se registra en el log de auditoría de usuarios."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Logs",
|
||||
"content": "Muestra el historial completo de modificaciones de usuarios: quién modificó qué, cuándo y desde qué IP. Se presenta como tabla con todos los campos de auditoría."
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Sistema → Alimentador",
|
||||
"steps": [
|
||||
{"icon": "✅", "label": "Alimentador activo", "desc": "Muestra «El alimentador está activo y funcionando correctamente»."},
|
||||
{"icon": "⚠️", "label": "Alimentador caído", "desc": "Aparece el panel de reinicio de sesión de Telegram."},
|
||||
{"icon": "1️⃣", "label": "Botón «Iniciar Nueva Sesión»", "desc": "El sistema envía un código de verificación al teléfono configurado en Telegram."},
|
||||
{"icon": "2️⃣", "label": "Ingresar el código", "desc": "Copiá el código que llegó a Telegram y pegalo en el campo «Código de verificación»."},
|
||||
{"icon": "3️⃣", "label": "2FA (si aplica)", "desc": "Si tu cuenta tiene contraseña de dos factores, ingresala en el campo correspondiente."},
|
||||
{"icon": "4️⃣", "label": "Botón «Verificar Código»", "desc": "Completa la autenticación y reactiva el alimentador."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Sistema → Backup del Sistema",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Campos «Bases de datos, Configuración, Certificados SSL y Sesiones Telegram»", "desc": "Selecciona la configuración del backup."},
|
||||
{"icon": "2️⃣", "label": "Botón «Generar y Descargar Backup»", "desc": "Descarga un archivo comprimido con lo incluido en los campos anteriores"},
|
||||
{"icon": "⚠️", "label": "Sobre el backup", "desc": "Para restaurar el sistema, se debe incluir el comprimido en la raíz y ejecutar el script restore_backup.ps1|sh según el sistema operativo"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pestaña: Auditoría",
|
||||
"content": "Log completo de acciones sobre todas las entidades del sistema (grupos, mensajes, remitentes, reglas, alertas). Ver sección «Auditoría» para más detalle."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "auditoria",
|
||||
"icon": "🔍",
|
||||
"label": "Auditoría",
|
||||
"roles": ["admin"],
|
||||
"description": "Consulta de logs de auditoría con filtros por entidad, acción, usuario y fecha.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Acceso",
|
||||
"content": "Disponible en la pestaña «Auditoría» dentro del Panel de Administrador. Solo accesible para administradores."
|
||||
},
|
||||
{
|
||||
"title": "Filtros disponibles",
|
||||
"steps": [
|
||||
{"icon": "🏷️", "label": "Entidad", "desc": "Filtrá por tipo: rule · alert · group · message · sender."},
|
||||
{"icon": "⚡", "label": "Acción", "desc": "create · update · delete · status_change."},
|
||||
{"icon": "🆔", "label": "ID de entidad", "desc": "ID específico de la entidad afectada. Para mensajes usa el formato id_mensaje_id_grupo."},
|
||||
{"icon": "👤", "label": "ID de usuario", "desc": "ID numérico del usuario que realizó la acción. El Feeder aparece como -1 🤖."},
|
||||
{"icon": "📅", "label": "Rango de fechas", "desc": "Desde / Hasta para acotar por período."},
|
||||
{"icon": "📄", "label": "Registros por página", "desc": "10 / 25 / 50 / 100."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Interpretar un registro",
|
||||
"steps": [
|
||||
{"icon": "🟢", "label": "create", "desc": "Se creó una entidad nueva."},
|
||||
{"icon": "🟡", "label": "update", "desc": "Se modificó una entidad existente. El registro muestra estado anterior y posterior."},
|
||||
{"icon": "🔴", "label": "delete", "desc": "Se eliminó una entidad. El registro conserva el estado previo a la eliminación."},
|
||||
{"icon": "🔵", "label": "status_change", "desc": "Cambio de estado en una alerta (open → in_progress → close)."},
|
||||
{"icon": "🤖", "label": "Usuario: Feeder", "desc": "La acción fue realizada automáticamente por el alimentador (system)."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cuenta",
|
||||
"icon": "⚙️",
|
||||
"label": "Mi Cuenta",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Edición de datos personales y contraseña del usuario autenticado.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Editar datos personales",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ir a «⚙️ Mi Cuenta»", "desc": "Desde el menú lateral."},
|
||||
{"icon": "2️⃣", "label": "Marcar «Activar edición»", "desc": "Los campos están bloqueados por defecto para evitar cambios accidentales."},
|
||||
{"icon": "3️⃣", "label": "Modificar nombre y/o email", "desc": "El rol y el ID no son editables desde esta pantalla."},
|
||||
{"icon": "4️⃣", "label": "Nueva contraseña (opcional)", "desc": "Si dejás el campo vacío, la contraseña actual no cambia."},
|
||||
{"icon": "5️⃣", "label": "Botón «Guardar cambios»", "desc": "Aplica los cambios. Si hubo un error (email duplicado, etc.) aparece un mensaje de error descriptivo."},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sesion",
|
||||
"icon": "🔐",
|
||||
"label": "Acceso al sistema",
|
||||
"roles": ["admin", "operator"],
|
||||
"description": "Registro, inicio de sesión y cierre de sesión.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Registrarse",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Pantalla de login → «Registrarse»", "desc": "En la pantalla inicial, hacé clic en el botón «Registrarse» debajo del formulario de ingreso."},
|
||||
{"icon": "2️⃣", "label": "Completar el formulario", "desc": "Nombre completo, correo electrónico y contraseña (mínimo 8 caracteres)."},
|
||||
{"icon": "3️⃣", "label": "Botón «Solicitar acceso»", "desc": "Crea la cuenta con rol operator y estado inactivo. Un administrador debe aprobarla antes de que puedas ingresar."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Iniciar sesión",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Ingresá tu correo y contraseña", "desc": "En la pantalla principal."},
|
||||
{"icon": "2️⃣", "label": "Botón «Ingresar al sistema»", "desc": "Valida las credenciales. Si la cuenta está inactiva, recibirás un error 401."},
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Cerrar sesión",
|
||||
"steps": [
|
||||
{"icon": "1️⃣", "label": "Botón «Cerrar sesión»", "desc": "Al pie del menú lateral. Limpia el token y todos los datos de sesión."},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# HELPER FUNCTIONS (sin sidebar, solo renderizado)
|
||||
# ----------------------------------------------------------------------
|
||||
def render_role_badges(roles):
|
||||
html = '<div style="display: flex; gap: 6px; margin-bottom: 12px;">'
|
||||
if "admin" in roles:
|
||||
html += '<span style="background: linear-gradient(135deg, #2d5a0e, #3d7a12); color: #c8e6a0; font-size: 10px; font-family: monospace; padding: 3px 10px; border-radius: 3px; border: 1px solid rgba(160,230,100,0.25);">ADMIN</span>'
|
||||
if "operator" in roles:
|
||||
html += '<span style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.55); font-size: 10px; font-family: monospace; padding: 3px 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,0.12);">OPERADOR</span>'
|
||||
html += '</div>'
|
||||
return html
|
||||
|
||||
def render_steps(steps):
|
||||
"""Renderiza cada paso como una tarjeta usando CSS nativo de Streamlit"""
|
||||
if not steps:
|
||||
return
|
||||
# Inyectar CSS una sola vez para estilizar las tarjetas
|
||||
st.markdown("""
|
||||
<style>
|
||||
.doc-step-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-left: 3px solid rgba(160,230,100,0.4);
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.doc-step-icon {
|
||||
font-size: 16px;
|
||||
min-width: 22px;
|
||||
}
|
||||
.doc-step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d4edb0;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.doc-step-desc {
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
for step in steps:
|
||||
html = f"""
|
||||
<div class="doc-step-card">
|
||||
<div class="doc-step-icon">{step['icon']}</div>
|
||||
<div>
|
||||
<div class="doc-step-label">{step['label']}</div>
|
||||
<div class="doc-step-desc">{step['desc']}</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
st.markdown(html, unsafe_allow_html=True)
|
||||
|
||||
def render_sections(sections):
|
||||
"""Renderiza cada sección como un expander"""
|
||||
for i, section in enumerate(sections):
|
||||
# Usamos una key única para cada expander basada en el índice
|
||||
with st.expander(section["title"], expanded=True):
|
||||
if "content" in section:
|
||||
st.markdown(f'<p style="font-size: 13px; color: rgba(255,255,255,0.6); line-height: 1.65;">{section["content"]}</p>', unsafe_allow_html=True)
|
||||
if "steps" in section:
|
||||
render_steps(section["steps"])
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# MAIN FUNCTION (sin sidebar)
|
||||
# ----------------------------------------------------------------------
|
||||
def show_documentation():
|
||||
# Inicializar estado del módulo activo
|
||||
if "doc_active_module" not in st.session_state:
|
||||
st.session_state.doc_active_module = MODULES[0]["id"]
|
||||
|
||||
# Título principal
|
||||
st.markdown("""
|
||||
<div style="margin-bottom: 24px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
|
||||
<div style="font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(160,230,100,0.12), rgba(160,230,100,0.04)); border: 1px solid rgba(160,230,100,0.2); border-radius: 12px;">📘</div>
|
||||
<h1 style="margin: 0; font-size: 22px; font-weight: 700; color: #e8f5da;">Documentación</h1>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: rgba(255,255,255,0.5);">Manual de usuario de la Threat Intelligence Platform</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Controles de búsqueda y filtro (en dos columnas)
|
||||
col1, col2 = st.columns([3, 2])
|
||||
with col1:
|
||||
search = st.text_input("🔍 Buscar módulo", placeholder="Ej: Alertas", key="doc_search", label_visibility="collapsed")
|
||||
with col2:
|
||||
role_filter = st.selectbox(
|
||||
"Filtrar por rol",
|
||||
options=["all", "admin", "operator"],
|
||||
format_func=lambda x: {"all": "Todos", "admin": "Admin", "operator": "Operador"}[x],
|
||||
key="doc_role_filter",
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
# Filtrar módulos con búsqueda en label, descripción, títulos, contenido y pasos
|
||||
search_lower = search.strip().lower()
|
||||
|
||||
def matches_query(module):
|
||||
if not search_lower:
|
||||
return True
|
||||
|
||||
if search_lower in module["label"].lower() or search_lower in module["description"].lower():
|
||||
return True
|
||||
|
||||
for section in module.get("sections", []):
|
||||
if search_lower in section.get("title", "").lower():
|
||||
return True
|
||||
if search_lower in section.get("content", "").lower():
|
||||
return True
|
||||
for step in section.get("steps", []):
|
||||
if any(search_lower in str(step.get(key, "")).lower() for key in ("label", "desc", "icon")):
|
||||
return True
|
||||
return False
|
||||
|
||||
filtered_modules = [
|
||||
m for m in MODULES
|
||||
if (role_filter == "all" or role_filter in m["roles"]) and matches_query(m)
|
||||
]
|
||||
|
||||
if not filtered_modules:
|
||||
st.warning("No hay módulos que coincidan con la búsqueda.")
|
||||
return
|
||||
|
||||
# Navegación por pestañas (usando radio horizontal)
|
||||
module_labels = [f"{m['icon']} {m['label']}" for m in filtered_modules]
|
||||
module_ids = [m["id"] for m in filtered_modules]
|
||||
|
||||
# Asegurar que el activo esté en la lista
|
||||
if st.session_state.doc_active_module not in module_ids:
|
||||
st.session_state.doc_active_module = module_ids[0]
|
||||
|
||||
default_index = module_ids.index(st.session_state.doc_active_module)
|
||||
selected_label = st.radio(
|
||||
"Módulo",
|
||||
options=module_labels,
|
||||
index=default_index,
|
||||
key="doc_module_radio",
|
||||
label_visibility="collapsed",
|
||||
horizontal=True,
|
||||
)
|
||||
selected_idx = module_labels.index(selected_label)
|
||||
st.session_state.doc_active_module = module_ids[selected_idx]
|
||||
|
||||
# Mostrar el módulo activo
|
||||
active_module = next(m for m in filtered_modules if m["id"] == st.session_state.doc_active_module)
|
||||
|
||||
st.markdown("---")
|
||||
# Badges y descripción
|
||||
st.markdown(render_role_badges(active_module["roles"]), unsafe_allow_html=True)
|
||||
st.markdown(f'<p style="font-size: 13.5px; color: rgba(255,255,255,0.55); font-style: italic; margin-bottom: 24px;">{active_module["description"]}</p>', unsafe_allow_html=True)
|
||||
|
||||
# Secciones expandibles
|
||||
render_sections(active_module["sections"])
|
||||
@@ -0,0 +1,179 @@
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.pages.views.help import show_documentation
|
||||
|
||||
|
||||
def _metric_card(label: str, value: str, delta: str = "", delta_up: bool = True, accent: bool = False):
|
||||
border_top = "border-top: 2px solid #3B6D11;" if accent else ""
|
||||
delta_color = "#3B6D11" if delta_up else "#A32D2D"
|
||||
delta_html = f'<div style="font-size:11px;color:{delta_color};margin-top:4px;">{delta}</div>' if delta else ""
|
||||
st.markdown(f"""
|
||||
<div style="background:var(--background-color);border:0.5px solid rgba(128,128,128,0.2);
|
||||
border-radius:8px;padding:14px 16px;{border_top}">
|
||||
<div style="font-size:11px;opacity:0.55;margin-bottom:6px;">{label}</div>
|
||||
<div style="font-size:22px;font-weight:500;line-height:1;">{value}</div>
|
||||
{delta_html}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _scraper_banner(status):
|
||||
"""Banner de estado del scraper usando componentes nativos."""
|
||||
if status is None:
|
||||
st.warning("No se pudo obtener el estado del scraper.")
|
||||
return
|
||||
|
||||
is_active = status.get("is_active", False)
|
||||
last_cycle = status.get("last_cycle")
|
||||
last_error = status.get("last_error")
|
||||
last_cycle_txt = ""
|
||||
if last_cycle:
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_cycle)
|
||||
diff = datetime.utcnow() - dt
|
||||
mins = int(diff.total_seconds() // 60)
|
||||
last_cycle_txt = f"Último ciclo hace {mins} min" if mins < 60 else f"Último ciclo hace {mins // 60} h"
|
||||
except Exception:
|
||||
last_cycle_txt = str(last_cycle)
|
||||
|
||||
sub_txt = f"{last_cycle_txt}"
|
||||
dot = "🟢" if is_active else "🔴"
|
||||
estado = "Activo" if is_active else "Inactivo"
|
||||
|
||||
with st.container(border=True):
|
||||
st.markdown(f"**{dot} Scraper de Telegram — {estado}**")
|
||||
st.caption(sub_txt)
|
||||
if last_error:
|
||||
st.error(f"Error: {last_error[:120]}")
|
||||
|
||||
|
||||
def mostrar_inicio(usuario: dict):
|
||||
api = api_client
|
||||
nombre = usuario.get("name", "").split()[0]
|
||||
|
||||
if st.session_state.get("show_help_from_home"):
|
||||
show_documentation()
|
||||
return
|
||||
|
||||
st.markdown(f"""
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="font-size:22px;font-weight:500;">Bienvenido, {nombre}</div>
|
||||
<div style="font-size:13px;opacity:0.5;margin-top:2px;">
|
||||
{datetime.now().strftime("%A %d de %B de %Y").capitalize()}
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
with st.spinner("Cargando..."):
|
||||
alertas_abiertas = api.get_alerts(status="open", limit=100) or []
|
||||
alertas_hoy = api.get_alerts(
|
||||
status="open",
|
||||
date_from=datetime.utcnow().replace(hour=0, minute=0, second=0).isoformat(),
|
||||
limit=100
|
||||
) or []
|
||||
grupos = api.get_groups() or []
|
||||
reglas = api.get_rules(limit=200) or []
|
||||
scraper_status = api.telegram_status()
|
||||
|
||||
reglas_activas = [r for r in reglas if r.get("is_active")]
|
||||
|
||||
# Métricas
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
with col1:
|
||||
_metric_card(
|
||||
label="Alertas abiertas",
|
||||
value=str(len(alertas_abiertas)),
|
||||
delta=f"+{len(alertas_hoy)} hoy" if alertas_hoy else "",
|
||||
delta_up=False,
|
||||
accent=True
|
||||
)
|
||||
with col2:
|
||||
_metric_card(label="Grupos activos", value=str(len(grupos)))
|
||||
with col3:
|
||||
_metric_card(label="Reglas activas", value=str(len(reglas_activas)))
|
||||
with col4:
|
||||
_metric_card(label="Reglas totales", value=str(len(reglas)))
|
||||
|
||||
st.markdown("<div style='height:16px'></div>", unsafe_allow_html=True)
|
||||
|
||||
# Dos columnas
|
||||
col_left, col_right = st.columns(2)
|
||||
|
||||
SEV_COLOR = {"alta": "#E24B4A", "media": "#BA7517", "baja": "#639922"}
|
||||
STATUS_BG = {"open": ("#FAECE7", "#993C1D"), "close": ("#EAF3DE", "#27500A"), "in_progress": ("#FFF3E0", "#8C5A00")}
|
||||
STATUS_LABEL = {"open": "Abierta", "close": "Cerrada", "in_progress": "En curso"}
|
||||
|
||||
with col_left:
|
||||
st.markdown("**Alertas recientes**")
|
||||
alertas_recientes = api.get_alerts(limit=5) or []
|
||||
if not alertas_recientes:
|
||||
st.info("Sin alertas recientes.")
|
||||
else:
|
||||
for alert in alertas_recientes:
|
||||
rule = alert.get("rule") or {}
|
||||
sev = (rule.get("severity") or "media").lower()
|
||||
status = alert.get("status", "open")
|
||||
desc = (rule.get("description") or "Sin descripcion")[:55]
|
||||
group_id = api.get_group(alert.get("group_id", "")).get("name", alert.get("group_id", "N/A"))
|
||||
fecha = ""
|
||||
try:
|
||||
fecha = datetime.fromisoformat(
|
||||
str(alert.get("created_at", ""))
|
||||
).strftime("%d/%m %H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dot_c = SEV_COLOR.get(sev, "#888")
|
||||
bg, txt = STATUS_BG.get(status, ("#f0f0f0", "#444"))
|
||||
slabel = STATUS_LABEL.get(status, status)
|
||||
|
||||
st.markdown(
|
||||
'<div style="display:flex;align-items:flex-start;gap:10px;padding:8px 0;'
|
||||
'border-bottom:0.5px solid rgba(128,128,128,0.15);">'
|
||||
f'<div style="width:7px;height:7px;border-radius:50%;background:{dot_c};'
|
||||
'flex-shrink:0;margin-top:5px;"></div>'
|
||||
'<div style="flex:1;min-width:0;">'
|
||||
f'<div style="font-size:12px;line-height:1.4;">{desc}</div>'
|
||||
f'<div style="font-size:11px;opacity:0.5;margin-top:2px;">Grupo {group_id} · {fecha}</div>'
|
||||
'</div>'
|
||||
f'<div style="font-size:10px;padding:2px 8px;border-radius:10px;'
|
||||
f'background:{bg};color:{txt};flex-shrink:0;white-space:nowrap;">{slabel}</div>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
with col_right:
|
||||
st.markdown("**Grupos monitoreados**")
|
||||
tipo_map = {
|
||||
"public_supergroup": "Canal publico", "private_supergroup": "Canal privado",
|
||||
"public_channel": "Canal publico", "private_channel": "Canal privado",
|
||||
"group": "Grupo publico", "private_group": "Grupo privado"
|
||||
}
|
||||
if not grupos:
|
||||
st.info("Sin grupos activos.")
|
||||
else:
|
||||
for grupo in grupos[:5]:
|
||||
nombre_g = grupo.get("name", "Sin nombre")
|
||||
tipo_txt = tipo_map.get(grupo.get("type", ""), "Desconocido")
|
||||
st.markdown(
|
||||
'<div style="display:flex;align-items:center;gap:10px;padding:8px 0;'
|
||||
'border-bottom:0.5px solid rgba(128,128,128,0.15);">'
|
||||
'<div style="width:28px;height:28px;border-radius:6px;background:#EAF3DE;'
|
||||
'display:flex;align-items:center;justify-content:center;flex-shrink:0;">'
|
||||
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">'
|
||||
'<path d="M2 10c0-2.76 1.34-4 5-4s5 1.24 5 4" stroke="#3B6D11" '
|
||||
'stroke-width="1.2" stroke-linecap="round"/>'
|
||||
'<circle cx="7" cy="4" r="2" stroke="#3B6D11" stroke-width="1.2"/>'
|
||||
'</svg></div>'
|
||||
'<div style="flex:1;min-width:0;">'
|
||||
f'<div style="font-size:12px;font-weight:500;white-space:nowrap;'
|
||||
f'overflow:hidden;text-overflow:ellipsis;">{nombre_g}</div>'
|
||||
f'<div style="font-size:10px;opacity:0.5;">{tipo_txt}</div>'
|
||||
'</div></div>',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
st.markdown("<div style='height:16px'></div>", unsafe_allow_html=True)
|
||||
|
||||
_scraper_banner(scraper_status)
|
||||
@@ -0,0 +1,193 @@
|
||||
from typing import Optional
|
||||
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from datetime import datetime, time
|
||||
from src.pages.views.components import render_adjuntos
|
||||
|
||||
def mostrar_mensajes():
|
||||
"""Página de mensajes con búsqueda y descarga de adjuntos."""
|
||||
api = api_client
|
||||
|
||||
# Inicializar estado
|
||||
if "messages_page" not in st.session_state:
|
||||
st.session_state.messages_page = 1
|
||||
if "messages_per_page" not in st.session_state:
|
||||
st.session_state.messages_per_page = 10
|
||||
if "messages_filters" not in st.session_state:
|
||||
st.session_state.messages_filters = {}
|
||||
|
||||
# Resultados por página
|
||||
per_page_options = [5, 10, 20, 50, 100]
|
||||
try:
|
||||
default_index = per_page_options.index(st.session_state.messages_per_page)
|
||||
except Exception:
|
||||
default_index = 1
|
||||
per_page = st.selectbox(
|
||||
"Resultados por página",
|
||||
options=per_page_options,
|
||||
index=default_index,
|
||||
key="messages_per_page_select"
|
||||
)
|
||||
if per_page != st.session_state.messages_per_page:
|
||||
st.session_state.messages_per_page = per_page
|
||||
st.session_state.messages_page = 1
|
||||
|
||||
# Cargar grupos para el selector
|
||||
with st.spinner("Cargando grupos..."):
|
||||
grupos = api.get_groups() or []
|
||||
grupos_map = {
|
||||
g.get("name", f"ID {g.get('id_telegram')}"): g.get("id_telegram")
|
||||
for g in grupos
|
||||
}
|
||||
|
||||
# Formulario de búsqueda
|
||||
with st.form("buscar_mensajes_form"):
|
||||
st.subheader("🔍 Filtros de búsqueda")
|
||||
|
||||
col_q, col_grupo = st.columns([3, 2])
|
||||
with col_q:
|
||||
q = st.text_input(
|
||||
"Texto a buscar (opcional)",
|
||||
placeholder="Dejar vacío para traer todos"
|
||||
)
|
||||
with col_grupo:
|
||||
opciones_grupos = ["Todos los grupos"] + list(grupos_map.keys())
|
||||
grupo_seleccionado = st.selectbox("Grupo", options=opciones_grupos)
|
||||
|
||||
col_sender, _ = st.columns([3, 2])
|
||||
with col_sender:
|
||||
sender_q = st.text_input(
|
||||
"Sender (username o ID)",
|
||||
placeholder="Ej: juanperez o 99887766"
|
||||
)
|
||||
|
||||
col_fecha1, col_hora1, col_fecha2, col_hora2 = st.columns(4)
|
||||
with col_fecha1:
|
||||
fecha_desde = st.date_input("Fecha desde", value=None)
|
||||
with col_hora1:
|
||||
hora_desde = st.time_input("Hora desde", value=None)
|
||||
with col_fecha2:
|
||||
fecha_hasta = st.date_input("Fecha hasta", value=None)
|
||||
with col_hora2:
|
||||
hora_hasta = st.time_input("Hora hasta", value=None)
|
||||
|
||||
submitted = st.form_submit_button("🔍 Buscar", use_container_width=True)
|
||||
|
||||
if submitted:
|
||||
group_id = (
|
||||
grupos_map.get(grupo_seleccionado)
|
||||
if grupo_seleccionado != "Todos los grupos"
|
||||
else None
|
||||
)
|
||||
|
||||
dt_desde = None
|
||||
dt_hasta = None
|
||||
if fecha_desde:
|
||||
dt_desde = datetime.combine(fecha_desde, hora_desde or time(0, 0))
|
||||
if fecha_hasta:
|
||||
dt_hasta = datetime.combine(fecha_hasta, hora_hasta or time(23, 59, 59))
|
||||
|
||||
st.session_state.messages_filters = {
|
||||
"submitted": True,
|
||||
"q": q.strip(),
|
||||
"group_id": group_id,
|
||||
"sender_q": sender_q.strip() if sender_q else None,
|
||||
"dt_desde": dt_desde.isoformat() if dt_desde else None,
|
||||
"dt_hasta": dt_hasta.isoformat() if dt_hasta else None,
|
||||
}
|
||||
st.session_state.messages_page = 1
|
||||
|
||||
filters = st.session_state.messages_filters
|
||||
if not filters.get("submitted"):
|
||||
st.info("Aplicá los filtros y presioná Buscar para ver resultados.")
|
||||
return
|
||||
|
||||
page = st.session_state.messages_page
|
||||
skip = (page - 1) * st.session_state.messages_per_page
|
||||
|
||||
with st.spinner("Buscando mensajes..."):
|
||||
results = api.search_messages(
|
||||
q=filters.get("q", ""),
|
||||
group_id=filters.get("group_id"),
|
||||
date_from=filters.get("dt_desde"),
|
||||
date_to=filters.get("dt_hasta"),
|
||||
skip=skip,
|
||||
limit=st.session_state.messages_per_page
|
||||
) or []
|
||||
|
||||
if results is None:
|
||||
st.error("No se pudieron obtener resultados.")
|
||||
return
|
||||
|
||||
# Filtro local por sender
|
||||
if filters.get("sender_q"):
|
||||
sq = filters["sender_q"].lower()
|
||||
results = [
|
||||
r for r in results
|
||||
if sq in str(r.get("sender", {}).get("username", "")).lower()
|
||||
or sq in str(r.get("sender", {}).get("id_telegram", "")).lower()
|
||||
or sq in str(r.get("sender", {}).get("first_name", "")).lower()
|
||||
]
|
||||
|
||||
if not results:
|
||||
st.info("No se encontraron mensajes con los filtros aplicados.")
|
||||
_render_paginacion_mensajes(page, 0, st.session_state.messages_per_page)
|
||||
return
|
||||
|
||||
total = len(results)
|
||||
st.subheader(f"Resultados — Página {page} ({total} mensaje{'s' if total != 1 else ''})")
|
||||
|
||||
for item in results:
|
||||
sender = item.get("sender") or {}
|
||||
grupo = item.get("group") or {}
|
||||
adjuntos = item.get("attachments") or []
|
||||
|
||||
fecha = ""
|
||||
if item.get("date"):
|
||||
try:
|
||||
fecha = datetime.fromisoformat(str(item["date"])).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
fecha = str(item["date"])
|
||||
|
||||
# Indicador de adjuntos en el título del expander
|
||||
adj_label = f" 📎 {len(adjuntos)}" if adjuntos else ""
|
||||
title = f"📨 {fecha} — {sender.get('username', 'N/A')} — {grupo.get('name', 'N/A')}{adj_label}"
|
||||
|
||||
with st.expander(title, expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.write(f"**Contenido:** {item.get('content', 'Sin contenido')}")
|
||||
st.write(
|
||||
f"**Sender:** {sender.get('first_name', '')} "
|
||||
f"{sender.get('last_name', '')} "
|
||||
f"(@{sender.get('username', 'N/A')})"
|
||||
)
|
||||
with col2:
|
||||
st.write(f"**Grupo:** {grupo.get('name', 'N/A')}")
|
||||
st.write(f"**Fecha:** {fecha}")
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Sección de adjuntos con descarga ---
|
||||
context_key = f"{item.get('id_mess_g', '')}_{item.get('group_id', '')}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
_render_paginacion_mensajes(page, total, st.session_state.messages_per_page)
|
||||
|
||||
|
||||
def _render_paginacion_mensajes(page: int, total: int, per_page: int):
|
||||
hay_mas = total == per_page
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("◀️ Anterior", disabled=page <= 1, key=f"msg_prev_{page}"):
|
||||
st.session_state.messages_page = max(1, page - 1)
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {page}**")
|
||||
st.caption(f"Resultados en esta página: {total}")
|
||||
with col_next:
|
||||
if st.button("Siguiente ▶️", disabled=not hay_mas, key=f"msg_next_{page}"):
|
||||
st.session_state.messages_page = page + 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,207 @@
|
||||
import time
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
|
||||
|
||||
def crear_regla():
|
||||
"""Interfaz para crear una nueva regla."""
|
||||
api = api_client
|
||||
|
||||
st.subheader("Crear nueva regla")
|
||||
|
||||
submitted = False
|
||||
|
||||
with st.form("form_crear_regla", clear_on_submit=True):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
description_val = st.text_input("Descripción", placeholder="Ej: Detecta menciones de transferencias")
|
||||
regex_val = st.text_input("Regex / Palabra clave", placeholder="Ej: transferencia")
|
||||
with col2:
|
||||
severity_val = st.selectbox("Severidad", options=["Alta", "Media", "Baja"], index=1)
|
||||
is_active_val = st.checkbox("Activa", value=True)
|
||||
|
||||
apply_history_val = st.checkbox(
|
||||
"Aplicar al histórico",
|
||||
value=False,
|
||||
help="Buscará coincidencias en todos los mensajes ya cargados y generará alertas."
|
||||
)
|
||||
st.caption("Necesitas ayuda con expresiones regulares? Visita: https://regex101.com/")
|
||||
submitted = st.form_submit_button("Crear regla", use_container_width=True, type="primary")
|
||||
|
||||
if submitted:
|
||||
if not description_val or not regex_val:
|
||||
st.warning("Completá la descripción y la regex para continuar.")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"description": description_val,
|
||||
"regex": regex_val,
|
||||
"severity": severity_val.lower(),
|
||||
"is_active": is_active_val,
|
||||
"apply_to_history": apply_history_val,
|
||||
}
|
||||
with st.spinner("Creando regla..."):
|
||||
res = api.create_rule(payload)
|
||||
|
||||
if res is not None:
|
||||
st.success("Regla creada correctamente.")
|
||||
time.sleep(1)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al crear la regla.")
|
||||
|
||||
|
||||
def editar_reglas():
|
||||
"""
|
||||
Vista unificada: lista, busca y edita reglas en una sola pantalla.
|
||||
Reemplaza las antiguas listar_reglas() y editar_reglas() separadas.
|
||||
"""
|
||||
api = api_client
|
||||
|
||||
# --- Estado de paginación y edición ---
|
||||
if "rule_page" not in st.session_state: st.session_state.rule_page = 1
|
||||
if "rules_per_page" not in st.session_state: st.session_state.rules_per_page = 10
|
||||
if "rule_search" not in st.session_state: st.session_state.rule_search = ""
|
||||
if "editing_rule_id" not in st.session_state: st.session_state.editing_rule_id = None
|
||||
|
||||
# --- Controles superiores ---
|
||||
col_search, col_per_page = st.columns([3, 1])
|
||||
with col_search:
|
||||
q = st.text_input(
|
||||
"Buscar regla",
|
||||
value=st.session_state.rule_search,
|
||||
placeholder="Filtrá por descripción...",
|
||||
key="rule_search_input",
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
if q != st.session_state.rule_search:
|
||||
st.session_state.rule_search = q
|
||||
st.session_state.rule_page = 1
|
||||
st.rerun()
|
||||
|
||||
with col_per_page:
|
||||
per_page = st.selectbox(
|
||||
"Por página",
|
||||
options=[5, 10, 20, 50],
|
||||
index=[5, 10, 20, 50].index(st.session_state.rules_per_page)
|
||||
if st.session_state.rules_per_page in [5, 10, 20, 50] else 1,
|
||||
key="rule_per_page_sel"
|
||||
)
|
||||
if per_page != st.session_state.rules_per_page:
|
||||
st.session_state.rules_per_page = per_page
|
||||
st.session_state.rule_page = 1
|
||||
|
||||
skip = (st.session_state.rule_page - 1) * st.session_state.rules_per_page
|
||||
limit = st.session_state.rules_per_page
|
||||
|
||||
# --- Cargar reglas ---
|
||||
with st.spinner("Cargando reglas..."):
|
||||
try:
|
||||
if st.session_state.rule_search.strip():
|
||||
rules = api.search_rules(q=st.session_state.rule_search.strip(), limit=500) or []
|
||||
# Paginación local cuando se busca
|
||||
total = len(rules)
|
||||
rules_paged = rules[skip: skip + limit]
|
||||
else:
|
||||
rules_paged = api.get_rules(skip=skip, limit=limit) or []
|
||||
total = None # no sabemos el total sin buscar
|
||||
except Exception as e:
|
||||
st.error(f"Error cargando reglas: {e}")
|
||||
return
|
||||
|
||||
if not rules_paged:
|
||||
st.info("No se encontraron reglas.")
|
||||
if st.session_state.rule_page > 1:
|
||||
st.session_state.rule_page = 1
|
||||
st.rerun()
|
||||
return
|
||||
|
||||
hay_mas = len(rules_paged) == limit if total is None else (skip + limit) < total
|
||||
|
||||
if total is not None:
|
||||
st.caption(f"{total} regla{'s' if total != 1 else ''} encontrada{'s' if total != 1 else ''}")
|
||||
else:
|
||||
st.caption(f"Mostrando {skip + 1}–{skip + len(rules_paged)}")
|
||||
|
||||
SEV_COLOR = {"alta": "🔴", "media": "🟡", "baja": "🟢"}
|
||||
|
||||
# --- Lista de reglas ---
|
||||
for rule in rules_paged:
|
||||
rule_id = rule.get("id")
|
||||
desc = rule.get("description", "Sin descripción")
|
||||
regex = rule.get("regex", "")
|
||||
sev = (rule.get("severity") or "media").lower()
|
||||
is_active = rule.get("is_active", True)
|
||||
sev_icon = SEV_COLOR.get(sev, "⚪")
|
||||
active_txt = "Activa" if is_active else "Inactiva"
|
||||
|
||||
with st.expander(
|
||||
f"{sev_icon} **#{rule_id}** — {desc[:60]}{'...' if len(desc) > 60 else ''} · *{active_txt}*",
|
||||
expanded=(st.session_state.editing_rule_id == rule_id)
|
||||
):
|
||||
# Si está en modo edición para esta regla
|
||||
if st.session_state.editing_rule_id == rule_id:
|
||||
with st.form(f"form_edit_{rule_id}"):
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
new_desc = st.text_input("Descripción", value=desc)
|
||||
new_regex = st.text_input("Regex / Palabra clave", value=regex)
|
||||
with col2:
|
||||
sev_opts = ["Alta", "Media", "Baja"]
|
||||
sev_idx = sev_opts.index(sev.capitalize()) if sev.capitalize() in sev_opts else 1
|
||||
new_sev = st.selectbox("Severidad", options=sev_opts, index=sev_idx)
|
||||
new_active = st.checkbox("Activa", value=is_active)
|
||||
|
||||
col_save, col_cancel = st.columns(2)
|
||||
with col_save:
|
||||
save = st.form_submit_button("Guardar cambios", use_container_width=True, type="primary")
|
||||
with col_cancel:
|
||||
cancel = st.form_submit_button("Cancelar", use_container_width=True)
|
||||
|
||||
if save:
|
||||
if not new_desc or not new_regex:
|
||||
st.warning("Descripción y regex son obligatorias.")
|
||||
else:
|
||||
payload = {
|
||||
"description": new_desc,
|
||||
"regex": new_regex,
|
||||
"severity": new_sev.lower(),
|
||||
"is_active": new_active,
|
||||
}
|
||||
with st.spinner("Guardando..."):
|
||||
res = api.update_rule(rule_id, payload)
|
||||
if res is not None:
|
||||
st.success("Regla actualizada.")
|
||||
st.session_state.editing_rule_id = None
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Error al actualizar la regla.")
|
||||
|
||||
if cancel:
|
||||
st.session_state.editing_rule_id = None
|
||||
st.rerun()
|
||||
|
||||
else:
|
||||
# Modo lectura
|
||||
col_info, col_btn = st.columns([4, 1])
|
||||
with col_info:
|
||||
st.caption(f"**Regex:** `{regex}`")
|
||||
st.caption(f"**Severidad:** {sev.capitalize()} · **Estado:** {active_txt}")
|
||||
with col_btn:
|
||||
if st.button("Editar", key=f"edit_btn_{rule_id}", use_container_width=True):
|
||||
st.session_state.editing_rule_id = rule_id
|
||||
st.rerun()
|
||||
|
||||
# --- Paginación ---
|
||||
st.divider()
|
||||
col_prev, col_info, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.rule_page <= 1, use_container_width=True):
|
||||
st.session_state.rule_page -= 1
|
||||
st.rerun()
|
||||
with col_info:
|
||||
st.write(f"**Página {st.session_state.rule_page}**")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas, use_container_width=True):
|
||||
st.session_state.rule_page += 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,224 @@
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
from src.api import api_client
|
||||
from src.pages.views.components import render_adjuntos
|
||||
|
||||
|
||||
def _fmt_fecha(fecha_str: str) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(str(fecha_str)).strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return str(fecha_str) or "—"
|
||||
|
||||
|
||||
def _tipo_badge(tipo: str) -> str:
|
||||
return {"user": "👤 Usuario", "bot": "🤖 Bot", "channel": "📢 Canal"}.get(tipo, tipo)
|
||||
|
||||
|
||||
def mostrar_senders():
|
||||
"""
|
||||
Vista de remitentes (senders) cargados por el feeder.
|
||||
Permite buscar, ver info y ver mensajes de cada sender.
|
||||
"""
|
||||
api = api_client
|
||||
|
||||
# --- Estado ---
|
||||
if "sender_page" not in st.session_state: st.session_state.sender_page = 1
|
||||
if "senders_per_page" not in st.session_state: st.session_state.senders_per_page = 20
|
||||
if "sender_search" not in st.session_state: st.session_state.sender_search = ""
|
||||
if "selected_sender" not in st.session_state: st.session_state.selected_sender = None
|
||||
|
||||
# --- Si hay un sender seleccionado, mostrar su detalle ---
|
||||
if st.session_state.selected_sender is not None:
|
||||
_mostrar_detalle_sender(api)
|
||||
return
|
||||
|
||||
# --- Vista de listado ---
|
||||
st.title("Remitentes")
|
||||
st.caption("Usuarios de Telegram detectados por el feeder")
|
||||
|
||||
col_search, col_per_page = st.columns([3, 1])
|
||||
with col_search:
|
||||
q = st.text_input(
|
||||
"Buscar",
|
||||
value=st.session_state.sender_search,
|
||||
placeholder="Nombre, username o ID de Telegram...",
|
||||
label_visibility="collapsed",
|
||||
key="sender_search_input"
|
||||
)
|
||||
if q != st.session_state.sender_search:
|
||||
st.session_state.sender_search = q
|
||||
st.session_state.sender_page = 1
|
||||
st.rerun()
|
||||
|
||||
with col_per_page:
|
||||
per_page = st.selectbox(
|
||||
"Por página",
|
||||
options=[10, 20, 50, 100],
|
||||
index=[10, 20, 50, 100].index(st.session_state.senders_per_page)
|
||||
if st.session_state.senders_per_page in [10, 20, 50, 100] else 1,
|
||||
label_visibility="collapsed",
|
||||
key="sender_per_page_sel"
|
||||
)
|
||||
if per_page != st.session_state.senders_per_page:
|
||||
st.session_state.senders_per_page = per_page
|
||||
st.session_state.sender_page = 1
|
||||
|
||||
skip = (st.session_state.sender_page - 1) * st.session_state.senders_per_page
|
||||
limit = st.session_state.senders_per_page
|
||||
|
||||
with st.spinner("Cargando remitentes..."):
|
||||
senders = api.get_senders(skip=skip, limit=limit) or []
|
||||
|
||||
# Filtrado local por búsqueda
|
||||
if st.session_state.sender_search.strip():
|
||||
sq = st.session_state.sender_search.strip().lower()
|
||||
senders = [
|
||||
s for s in senders
|
||||
if sq in str(s.get("id_telegram", "")).lower()
|
||||
or sq in str(s.get("username") or "").lower()
|
||||
or sq in str(s.get("first_name") or "").lower()
|
||||
or sq in str(s.get("last_name") or "").lower()
|
||||
]
|
||||
|
||||
if not senders:
|
||||
st.info("No se encontraron remitentes.")
|
||||
if st.session_state.sender_page > 1:
|
||||
st.session_state.sender_page = 1
|
||||
st.rerun()
|
||||
return
|
||||
|
||||
hay_mas = len(senders) == limit
|
||||
st.caption(f"Mostrando {skip + 1}–{skip + len(senders)}")
|
||||
st.divider()
|
||||
|
||||
# --- Tabla de senders ---
|
||||
for sender in senders:
|
||||
sid = sender.get("id_telegram")
|
||||
username = sender.get("username") or "—"
|
||||
first_name = "N/A" if sender.get("first_name") == "None" else sender.get("first_name") or ""
|
||||
last_name = "" if sender.get("last_name") == "None" else sender.get("last_name") or ""
|
||||
nombre = f"{first_name} {last_name}".strip() or "Sin nombre"
|
||||
tipo = sender.get("type", "user")
|
||||
phone = "Sin teléfono" if sender.get("phone") == "None" else sender.get("phone") or "—"
|
||||
|
||||
col_info, col_btn = st.columns([5, 1])
|
||||
with col_info:
|
||||
st.markdown(
|
||||
f"**{nombre}** `@{username}` · {_tipo_badge(tipo)}"
|
||||
f"<br><span style='font-size:11px;opacity:0.5;'>ID: {sid} · Tel: {phone}</span>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
with col_btn:
|
||||
if st.button("Ver", key=f"ver_sender_{sid}", use_container_width=True):
|
||||
st.session_state.selected_sender = sender
|
||||
st.rerun()
|
||||
|
||||
st.markdown(
|
||||
"<hr style='margin:6px 0;border:none;border-top:0.5px solid rgba(128,128,128,0.15);'>",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
# --- Paginación ---
|
||||
st.divider()
|
||||
col_prev, col_info_pag, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.sender_page <= 1,
|
||||
use_container_width=True, key="sender_prev"):
|
||||
st.session_state.sender_page -= 1
|
||||
st.rerun()
|
||||
with col_info_pag:
|
||||
st.write(f"**Página {st.session_state.sender_page}**")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas,
|
||||
use_container_width=True, key="sender_next"):
|
||||
st.session_state.sender_page += 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _mostrar_detalle_sender(api):
|
||||
"""Detalle de un sender: info + mensajes enviados."""
|
||||
sender = st.session_state.selected_sender
|
||||
sid = sender.get("id_telegram")
|
||||
username = sender.get("username") or "—"
|
||||
first_name = "N/A" if sender.get("first_name") == "None" else sender.get("first_name") or ""
|
||||
last_name = "" if sender.get("last_name") == "None" else sender.get("last_name") or ""
|
||||
nombre = f"{first_name} {last_name}".strip() or "Sin nombre"
|
||||
tipo = sender.get("type", "user")
|
||||
phone = "Sin teléfono" if sender.get("phone") == "None" else sender.get("phone") or "—"
|
||||
|
||||
# Botón volver
|
||||
if st.button("← Volver a remitentes", key="back_to_senders"):
|
||||
st.session_state.selected_sender = None
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Encabezado del sender ---
|
||||
st.title(nombre)
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
st.metric("Username", f"@{username}")
|
||||
with col2:
|
||||
st.metric("Tipo", _tipo_badge(tipo))
|
||||
with col3:
|
||||
st.metric("ID Telegram", str(sid))
|
||||
|
||||
col4, col5 = st.columns(2)
|
||||
with col4:
|
||||
st.metric("Teléfono", phone)
|
||||
with col5:
|
||||
st.metric("Apellido", last_name or "—")
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Mensajes del sender ---
|
||||
st.subheader("Mensajes enviados")
|
||||
|
||||
if "sender_msg_page" not in st.session_state: st.session_state.sender_msg_page = 1
|
||||
if "sender_msg_per_page" not in st.session_state: st.session_state.sender_msg_per_page = 10
|
||||
|
||||
msg_skip = (st.session_state.sender_msg_page - 1) * st.session_state.sender_msg_per_page
|
||||
msg_limit = st.session_state.sender_msg_per_page
|
||||
|
||||
with st.spinner("Cargando mensajes..."):
|
||||
mensajes = api.get_messages_by_sender(sid, skip=msg_skip, limit=msg_limit) or []
|
||||
|
||||
if not mensajes:
|
||||
st.info("Este remitente no tiene mensajes registrados.")
|
||||
return
|
||||
|
||||
hay_mas_msgs = len(mensajes) == msg_limit
|
||||
st.caption(f"Mostrando {msg_skip + 1}–{msg_skip + len(mensajes)}")
|
||||
|
||||
for msg in mensajes:
|
||||
grupo = msg.get("group") or {}
|
||||
fecha = _fmt_fecha(msg.get("date", ""))
|
||||
contenido = "_Sin contenido_" if msg.get("content") == "None" else msg.get("content") or "_Sin contenido_"
|
||||
adjuntos = msg.get("attachments") or []
|
||||
grupo_nombre = grupo.get("name") or str(msg.get("group_id", ""))
|
||||
|
||||
with st.expander(
|
||||
f"📨 {fecha} — {grupo_nombre} {'📎' if adjuntos else ''}",
|
||||
expanded=False
|
||||
):
|
||||
st.write(contenido)
|
||||
if adjuntos:
|
||||
context_key = f"sender_{sid}_{msg.get('id_mess_g', '')}_{msg.get('group_id', '')}"
|
||||
render_adjuntos(adjuntos, api, context_key=context_key)
|
||||
|
||||
# Paginación mensajes
|
||||
st.divider()
|
||||
col_prev, col_info_m, col_next = st.columns([1, 2, 1])
|
||||
with col_prev:
|
||||
if st.button("Anterior", disabled=st.session_state.sender_msg_page <= 1,
|
||||
use_container_width=True, key="sender_msg_prev"):
|
||||
st.session_state.sender_msg_page -= 1
|
||||
st.rerun()
|
||||
with col_info_m:
|
||||
st.write(f"**Página {st.session_state.sender_msg_page}**")
|
||||
with col_next:
|
||||
if st.button("Siguiente", disabled=not hay_mas_msgs,
|
||||
use_container_width=True, key="sender_msg_next"):
|
||||
st.session_state.sender_msg_page += 1
|
||||
st.rerun()
|
||||
@@ -0,0 +1,251 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from src.api import api_client
|
||||
from src.pages.views.components import safe_html
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEV_COLOR = {"alta": "🔴", "media": "🟡", "baja": "🟢"}
|
||||
DIAS_ORDER = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]
|
||||
|
||||
|
||||
def _metric(label: str, value, delta: str = "", delta_up: bool = True, accent: bool = False):
|
||||
label = safe_html(label)
|
||||
delta = safe_html(delta)
|
||||
border = "border-top:2px solid #27500A;" if accent else ""
|
||||
delta_color = "#3B6D11" if delta_up else "#A32D2D"
|
||||
delta_html = f'<div style="font-size:11px;color:{delta_color};margin-top:4px;">{delta}</div>' if delta else ""
|
||||
st.markdown(f"""
|
||||
<div style="background:var(--background-color);border:0.5px solid rgba(128,128,128,0.2);
|
||||
border-radius:8px;padding:14px 16px;{border}">
|
||||
<div style="font-size:11px;opacity:0.55;margin-bottom:6px;">{label}</div>
|
||||
<div style="font-size:24px;font-weight:500;line-height:1;">{value}</div>
|
||||
{delta_html}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _section(title: str):
|
||||
title = safe_html(title)
|
||||
st.markdown(f"""
|
||||
<div style="font-size:15px;font-weight:500;margin:24px 0 12px;
|
||||
padding-bottom:8px;border-bottom:0.5px solid rgba(128,128,128,0.2);">
|
||||
{title}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vista principal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mostrar_estadisticas():
|
||||
api = api_client
|
||||
|
||||
st.title("Estadísticas")
|
||||
st.caption("Análisis de actividad del sistema en el período seleccionado")
|
||||
|
||||
# --- Filtro de período ---
|
||||
with st.expander("Filtrar período", expanded=True):
|
||||
col_f1, col_h1, col_f2, col_h2, col_btn = st.columns([2, 1, 2, 1, 1])
|
||||
with col_f1:
|
||||
fecha_desde = st.date_input(
|
||||
"Desde (fecha)",
|
||||
value=datetime.utcnow().date() - timedelta(days=30),
|
||||
key="stats_fecha_desde"
|
||||
)
|
||||
with col_h1:
|
||||
hora_desde = st.time_input("Hora", value=time(0, 0), key="stats_hora_desde")
|
||||
with col_f2:
|
||||
fecha_hasta = st.date_input(
|
||||
"Hasta (fecha)",
|
||||
value=datetime.utcnow().date(),
|
||||
key="stats_fecha_hasta"
|
||||
)
|
||||
with col_h2:
|
||||
hora_hasta = st.time_input("Hora", value=time(23, 59, 59), key="stats_hora_hasta")
|
||||
with col_btn:
|
||||
st.markdown("<div style='height:28px'></div>", unsafe_allow_html=True)
|
||||
aplicar = st.button("Aplicar", use_container_width=True, type="primary")
|
||||
|
||||
dt_desde = datetime.combine(fecha_desde, hora_desde)
|
||||
dt_hasta = datetime.combine(fecha_hasta, hora_hasta)
|
||||
|
||||
if dt_desde >= dt_hasta:
|
||||
st.warning("La fecha de inicio debe ser anterior a la fecha de fin.")
|
||||
return
|
||||
|
||||
# --- Cargar datos ---
|
||||
with st.spinner("Calculando estadísticas..."):
|
||||
data = api.get_stats(
|
||||
date_from=dt_desde.isoformat(),
|
||||
date_to=dt_hasta.isoformat()
|
||||
)
|
||||
|
||||
if data is None:
|
||||
st.error("No se pudieron cargar las estadísticas.")
|
||||
return
|
||||
|
||||
resumen = data.get("resumen", {})
|
||||
periodo = data.get("periodo", {})
|
||||
|
||||
# --- 1. Resumen ejecutivo ---
|
||||
_section("Resumen del período")
|
||||
|
||||
col1, col2, col3, col4, col5, col6, col7 = st.columns(7)
|
||||
with col1:
|
||||
_metric("Alertas totales", resumen.get("total_alertas", 0), accent=True)
|
||||
with col2:
|
||||
_metric("Abiertas", resumen.get("alertas_abiertas", 0), delta_up=False)
|
||||
with col3:
|
||||
_metric("En curso", resumen.get("alertas_en_progreso", 0), delta_up=False)
|
||||
with col4:
|
||||
_metric("Cerradas", resumen.get("alertas_cerradas", 0), delta_up=True)
|
||||
with col5:
|
||||
_metric("Mensajes", f"{resumen.get('total_mensajes', 0):,}")
|
||||
with col6:
|
||||
_metric("Grupos activos", resumen.get("grupos_activos", 0))
|
||||
with col7:
|
||||
_metric("Reglas activas", resumen.get("reglas_activas", 0))
|
||||
|
||||
# --- 2. Alertas en el tiempo ---
|
||||
_section("Actividad de alertas en el tiempo")
|
||||
|
||||
alertas_dia = data.get("alertas_por_dia", {})
|
||||
if alertas_dia:
|
||||
df_alertas = pd.DataFrame([
|
||||
{
|
||||
"Fecha": dia,
|
||||
"Abiertas": vals.get("abiertas", 0),
|
||||
"En curso": vals.get("en_progreso", 0),
|
||||
"Cerradas": vals.get("cerradas", 0),
|
||||
}
|
||||
for dia, vals in sorted(alertas_dia.items())
|
||||
]).set_index("Fecha")
|
||||
st.line_chart(df_alertas, color=["#E24B4A", "#F7931E", "#3B6D11"])
|
||||
else:
|
||||
st.info("Sin alertas en el período seleccionado.")
|
||||
|
||||
# --- 3. Distribución + Alertas por grupo ---
|
||||
col_sev, col_grupo = st.columns(2)
|
||||
|
||||
with col_sev:
|
||||
_section("Distribución por severidad")
|
||||
dist = data.get("distribucion_severidad", {})
|
||||
if dist:
|
||||
df_sev = pd.DataFrame([
|
||||
{"Severidad": sev.capitalize(), "Alertas": total}
|
||||
for sev, total in dist.items()
|
||||
]).set_index("Severidad")
|
||||
st.bar_chart(df_sev)
|
||||
# Tabla resumen
|
||||
for sev, total in sorted(dist.items(), key=lambda x: x[1], reverse=True):
|
||||
pct = round(total / sum(dist.values()) * 100) if dist else 0
|
||||
st.markdown(
|
||||
f"{SEV_COLOR.get(sev, '⚪')} **{sev.capitalize()}**: "
|
||||
f"{total} alertas ({pct}%)"
|
||||
)
|
||||
else:
|
||||
st.info("Sin datos de severidad.")
|
||||
|
||||
with col_grupo:
|
||||
_section("Alertas por grupo (top 10)")
|
||||
grupos = data.get("alertas_por_grupo", [])
|
||||
if grupos:
|
||||
df_grupos = pd.DataFrame(grupos).set_index("grupo")
|
||||
st.bar_chart(df_grupos["total"])
|
||||
else:
|
||||
st.info("Sin datos por grupo.")
|
||||
|
||||
# --- 4. Reglas más disparadas ---
|
||||
_section("Reglas más disparadas (top 10)")
|
||||
reglas = data.get("reglas_top", [])
|
||||
if reglas:
|
||||
df_reglas = pd.DataFrame(reglas)
|
||||
df_reglas["label"] = df_reglas.apply(
|
||||
lambda r: f"{SEV_COLOR.get(r['severity'], '⚪')} {r['description'][:40]}",
|
||||
axis=1
|
||||
)
|
||||
df_reglas = df_reglas.set_index("label")[["total"]]
|
||||
df_reglas.columns = ["Disparos"]
|
||||
st.bar_chart(df_reglas)
|
||||
else:
|
||||
st.info("Sin reglas disparadas en el período.")
|
||||
|
||||
# --- 5. Volumen de mensajes por día ---
|
||||
_section("Volumen de mensajes por día")
|
||||
msgs_dia = data.get("mensajes_por_dia", {})
|
||||
if msgs_dia:
|
||||
df_msgs = pd.DataFrame([
|
||||
{"Fecha": dia, "Mensajes": total}
|
||||
for dia, total in sorted(msgs_dia.items())
|
||||
]).set_index("Fecha")
|
||||
st.bar_chart(df_msgs)
|
||||
else:
|
||||
st.info("Sin mensajes en el período.")
|
||||
|
||||
# --- 6. Heatmap hora × día de semana ---
|
||||
_section("Heatmap de actividad — hora × día de semana")
|
||||
heatmap = data.get("heatmap", [])
|
||||
if heatmap:
|
||||
df_heat = pd.DataFrame(heatmap)
|
||||
# Pivot: filas = día, columnas = hora
|
||||
pivot = df_heat.pivot_table(
|
||||
index="dia",
|
||||
columns="hora",
|
||||
values="total",
|
||||
aggfunc="sum",
|
||||
fill_value=0
|
||||
)
|
||||
# Reordenar días
|
||||
dias_presentes = [d for d in DIAS_ORDER if d in pivot.index]
|
||||
pivot = pivot.reindex(dias_presentes)
|
||||
pivot.columns = [f"{h:02d}h" for h in pivot.columns]
|
||||
|
||||
# Normalizar para colorear
|
||||
max_val = pivot.values.max() if pivot.values.max() > 0 else 1
|
||||
|
||||
st.dataframe(
|
||||
pivot.style.background_gradient(
|
||||
cmap="Greens",
|
||||
vmin=0,
|
||||
vmax=max_val
|
||||
).format("{:.0f}"),
|
||||
use_container_width=True,
|
||||
height=280
|
||||
)
|
||||
st.caption("Valores = cantidad de mensajes. Color más oscuro = mayor actividad.")
|
||||
else:
|
||||
st.info("Sin datos de actividad horaria.")
|
||||
|
||||
# --- 7. Top senders ---
|
||||
_section("Top remitentes por actividad")
|
||||
senders = data.get("top_senders", [])
|
||||
if senders:
|
||||
df_send = pd.DataFrame(senders)
|
||||
df_send["Remitente"] = df_send.apply(
|
||||
lambda r: f"@{r['username']}" if r.get("username") else r["nombre"],
|
||||
axis=1
|
||||
)
|
||||
df_send["Con alertas"] = df_send["alertas"].apply(
|
||||
lambda x: "🔴 Sí" if x > 0 else "✅ No"
|
||||
)
|
||||
df_send = df_send.rename(columns={
|
||||
"mensajes": "Mensajes",
|
||||
"alertas": "Alertas generadas"
|
||||
})
|
||||
|
||||
col_chart, col_table = st.columns([2, 1])
|
||||
with col_chart:
|
||||
st.bar_chart(df_send.set_index("Remitente")["Mensajes"])
|
||||
with col_table:
|
||||
st.dataframe(
|
||||
df_send[["Remitente", "Mensajes", "Alertas generadas", "Con alertas"]],
|
||||
use_container_width=True,
|
||||
hide_index=True
|
||||
)
|
||||
else:
|
||||
st.info("Sin datos de remitentes.")
|
||||
@@ -0,0 +1,104 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Upstreams
|
||||
upstream frontend {
|
||||
server frontend:8501;
|
||||
}
|
||||
|
||||
upstream api {
|
||||
server api:8000;
|
||||
}
|
||||
|
||||
upstream api_users {
|
||||
server api_users:8090;
|
||||
}
|
||||
|
||||
# Redirección HTTP -> HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost; # Cambiar por tu dominio
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Servidor HTTPS
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name localhost; # Cambiar por tu dominio
|
||||
|
||||
# Certificados SSL
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
|
||||
# Configuración SSL robusta (recomendada)
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Headers de seguridad
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Frontend (Streamlit) - ruta raíz
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
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;
|
||||
|
||||
# Soporte WebSocket (Streamlit)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# API Feeder (FastAPI en puerto 8000)
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://api;
|
||||
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;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# API Usuarios (FastAPI en puerto 8090)
|
||||
location /users/ {
|
||||
rewrite ^/users/(.*)$ /$1 break;
|
||||
proxy_pass http://api_users;
|
||||
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;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Opcional: estado de Nginx
|
||||
location /nginx_status {
|
||||
stub_status;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
requests==2.32.5
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
pymysql==1.1.0
|
||||
sqlalchemy==2.0.23
|
||||
python-multipart==0.0.6
|
||||
python-dotenv==1.0.0
|
||||
telethon==1.28.5
|
||||
aiofiles==23.2.1
|
||||
pydantic==2.11.9
|
||||
alembic==1.11.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
httpx
|
||||
@@ -0,0 +1,5 @@
|
||||
streamlit>=1.28.0
|
||||
requests>=2.31.0
|
||||
pandas>=2.0.0
|
||||
reportlab>=3.6.0
|
||||
matplotlib>=3.7.0
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
pydantic==2.5.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.6
|
||||
pymysql==1.1.2
|
||||
pydantic[email]
|
||||
slowapi
|
||||
@@ -0,0 +1,264 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restaura un backup completo del sistema TIP desde un archivo ZIP.
|
||||
.DESCRIPTION
|
||||
Busca el backup más reciente (tip_backup_*.zip), extrae el contenido,
|
||||
lee el manifiesto y restaura bases de datos, configuración, SSL y sesiones.
|
||||
ADVERTENCIA: Sobrescribe datos existentes. Usar solo en recuperación.
|
||||
#>
|
||||
|
||||
Write-Host "Esta seguro que desea iniciar un proceso de restauración de backup? Esto sobrescribirá datos actuales. S/N" -ForegroundColor Red
|
||||
$confirmation = Read-Host
|
||||
if ($confirmation -notmatch "^[Ss]") {
|
||||
Write-Host "Restauración cancelada por el usuario." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Localizar el backup más reciente
|
||||
# ------------------------------------------------------------------
|
||||
$backup_zip = Get-ChildItem -Path "." -Filter "tip_backup_*.zip" |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $backup_zip) {
|
||||
Write-Host "ERROR: No se encontró ningún backup con el patrón 'tip_backup_*.zip' en el directorio actual." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Backup encontrado: $($backup_zip.Name)" -ForegroundColor Green
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Extraer en carpeta temporal
|
||||
# ------------------------------------------------------------------
|
||||
$tempDir = "./temp_restore"
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
}
|
||||
Expand-Archive -Path $backup_zip.FullName -DestinationPath $tempDir -Force
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Leer manifiesto
|
||||
# ------------------------------------------------------------------
|
||||
$jsonPath = Join-Path $tempDir "manifest.json"
|
||||
if (-not (Test-Path $jsonPath)) {
|
||||
Write-Host "ERROR: No se encontró manifest.json en el backup." -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
exit 1
|
||||
}
|
||||
|
||||
$jsonContent = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json
|
||||
$createdAt = $jsonContent.created_at
|
||||
$createdBy = $jsonContent.created_by
|
||||
$components = $jsonContent.components
|
||||
$warnings = $jsonContent.warnings
|
||||
|
||||
Write-Host "Backup generado el $createdAt por usuario ID: $createdBy" -ForegroundColor Cyan
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Obtener credenciales de bases de datos desde docker-compose.yml
|
||||
# ------------------------------------------------------------------
|
||||
# Restaurar docker-compose.yml desde el backup si existe
|
||||
$composeBackupPath = Join-Path $tempDir "config/docker-compose.yml"
|
||||
if (Test-Path $composeBackupPath) {
|
||||
Write-Host "Restaurando docker-compose.yml desde el backup..." -ForegroundColor Yellow
|
||||
Copy-Item $composeBackupPath -Destination "./docker-compose.yml" -Force
|
||||
}
|
||||
|
||||
if (-not (Test-Path "./docker-compose.yml")) {
|
||||
Write-Host "ERROR: No se encuentra docker-compose.yml en el directorio actual." -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
$composeJson = docker-compose -f docker-compose.yml config --format json | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-Host "ERROR: No se pudo obtener la configuración de docker-compose. Verifica que Docker Compose esté instalado." -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
exit 1
|
||||
}
|
||||
|
||||
$FEEDER_PASS = $composeJson.services.database.environment.MYSQL_ROOT_PASSWORD
|
||||
$USERS_PASS = $composeJson.services.database_users.environment.MYSQL_ROOT_PASSWORD
|
||||
|
||||
if (-not $FEEDER_PASS -or -not $USERS_PASS) {
|
||||
Write-Host "ERROR: No se pudieron obtener las contraseñas de las bases de datos." -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Credenciales obtenidas correctamente." -ForegroundColor Green
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Función auxiliar para buscar archivo SQL
|
||||
# ------------------------------------------------------------------
|
||||
function Get-BackupSqlFile {
|
||||
param(
|
||||
[string]$DatabaseName,
|
||||
[string]$SearchDir
|
||||
)
|
||||
$pattern = "*${DatabaseName}*.sql"
|
||||
$file = Get-ChildItem -Path $SearchDir -Filter $pattern -File -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
return $file
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Procesar componentes del manifiesto
|
||||
# ------------------------------------------------------------------
|
||||
foreach ($comp in $components) {
|
||||
Write-Host "`nProcesando componente: $comp" -ForegroundColor Yellow
|
||||
|
||||
# --- Bases de datos ---
|
||||
if ($comp -like "database:*") {
|
||||
$dbName = $comp.Split(':')[1]
|
||||
|
||||
switch ($dbName) {
|
||||
"feeder" {
|
||||
Write-Host " → Restaurando base de datos Feeder..."
|
||||
docker compose up -d --build database --wait
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " ERROR: No se pudo levantar el contenedor de feeder_db." -ForegroundColor Red
|
||||
continue
|
||||
}
|
||||
|
||||
$sqlFile = Get-BackupSqlFile -DatabaseName "feeder" -SearchDir (Join-Path $tempDir "databases")
|
||||
if ($sqlFile) {
|
||||
Write-Host " Archivo de dump: $($sqlFile.Name)"
|
||||
Get-Content $sqlFile.FullName | docker exec -i feeder_db mysql -uroot -p"$FEEDER_PASS" feeder
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host " Base de datos feeder restaurada exitosamente." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " ERROR al restaurar feeder." -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host " ADVERTENCIA: No se encontró archivo .sql para feeder." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
"users" {
|
||||
Write-Host " → Restaurando base de datos Users..."
|
||||
docker compose up -d --build database_users --wait
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " ERROR: No se pudo levantar el contenedor de users_db." -ForegroundColor Red
|
||||
continue
|
||||
}
|
||||
|
||||
$sqlFile = Get-BackupSqlFile -DatabaseName "users" -SearchDir (Join-Path $tempDir "databases")
|
||||
if ($sqlFile) {
|
||||
Write-Host " Archivo de dump: $($sqlFile.Name)"
|
||||
Get-Content $sqlFile.FullName | docker exec -i users_db mysql -uroot -p"$USERS_PASS" users
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host " Base de datos users restaurada exitosamente." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " ERROR al restaurar users." -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host " ADVERTENCIA: No se encontró archivo .sql para users." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
default {
|
||||
Write-Host " → Base de datos desconocida: $dbName" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Archivos de configuración ---
|
||||
elseif ($comp -like "config:*") {
|
||||
$files = ($comp.Split(':')[1]) -split ','
|
||||
Write-Host " → Archivos de configuración: $($files -join ', ')"
|
||||
foreach ($file in $files) {
|
||||
$source = Join-Path $tempDir "config/$file"
|
||||
if (Test-Path $source) {
|
||||
switch ($file) {
|
||||
"nginx.conf" { $dest = "./nginx/nginx.conf" }
|
||||
"docker-compose.yml" { $dest = "./docker-compose.yml" }
|
||||
default { $dest = "./$file" }
|
||||
}
|
||||
Write-Host " - Copiando $file a $dest"
|
||||
Copy-Item $source -Destination $dest -Force
|
||||
} else {
|
||||
Write-Host " - Archivo $file no encontrado en el backup, se omite." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Certificados SSL ---
|
||||
elseif ($comp -eq "ssl") {
|
||||
Write-Host " → Restaurando certificados SSL..."
|
||||
$sslSource = Join-Path $tempDir "ssl"
|
||||
if (Test-Path $sslSource) {
|
||||
$sslDest = "./nginx/ssl"
|
||||
if (-not (Test-Path $sslDest)) {
|
||||
New-Item -ItemType Directory -Path $sslDest -Force | Out-Null
|
||||
}
|
||||
# Copiar contenido del directorio
|
||||
Get-ChildItem -Path $sslSource -File -Recurse | ForEach-Object {
|
||||
$relativePath = $_.FullName.Substring($sslSource.Length + 1)
|
||||
$destFile = Join-Path $sslDest $relativePath
|
||||
$destDir = Split-Path $destFile -Parent
|
||||
if (-not (Test-Path $destDir)) {
|
||||
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
|
||||
}
|
||||
Copy-Item $_.FullName -Destination $destFile -Force
|
||||
}
|
||||
Write-Host " Certificados SSL copiados a $sslDest" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " ADVERTENCIA: No se encontraron archivos SSL en el backup." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# --- Sesiones de Telegram ---
|
||||
elseif ($comp -eq "telegram_sessions") {
|
||||
Write-Host " → Restaurando sesiones de Telegram..."
|
||||
$sessionsSource = Join-Path $tempDir "telegram_sessions"
|
||||
if (Test-Path $sessionsSource) {
|
||||
$sessionsDest = "./app/telegram_sessions"
|
||||
if (-not (Test-Path $sessionsDest)) {
|
||||
New-Item -ItemType Directory -Path $sessionsDest -Force | Out-Null
|
||||
}
|
||||
Copy-Item -Path "$sessionsSource/*" -Destination $sessionsDest -Recurse -Force
|
||||
Write-Host " Sesiones copiadas a $sessionsDest" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " ADVERTENCIA: No se encontraron sesiones en el backup." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
Write-Host " → Componente no reconocido: $comp" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Mostrar advertencias del manifiesto
|
||||
# ------------------------------------------------------------------
|
||||
if ($warnings -and $warnings.Count -gt 0) {
|
||||
Write-Host "`n⚠️ Advertencias durante la generación del backup:" -ForegroundColor Magenta
|
||||
$warnings | ForEach-Object { Write-Host " - $_" }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Limpieza
|
||||
# ------------------------------------------------------------------
|
||||
Write-Host "`nEliminando archivos temporales..." -ForegroundColor Gray
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
|
||||
Write-Host "`nRestauración completada, desea reiniciar los contenedores? S/N" -ForegroundColor Green
|
||||
$answer = Read-Host
|
||||
|
||||
if ($answer -match "^[Ss]") {
|
||||
Write-Host "Reiniciando contenedores..." -ForegroundColor Green
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Contenedores reiniciados exitosamente." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "ERROR al reiniciar los contenedores. Verifica los logs de Docker." -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host "Recuerda reiniciar los contenedores para aplicar los cambios." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "Proceso de restauración completado." -ForegroundColor Green
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/bin/bash
|
||||
# ------------------------------------------------------------------
|
||||
# Script de restauración de backup completo del sistema TIP
|
||||
# ADVERTENCIA: Sobrescribe datos existentes. Usar solo en recuperación.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Colores para salida
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
GRAY='\033[0;90m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Función para imprimir mensajes con color
|
||||
print_error() { echo -e "${RED}$1${NC}"; }
|
||||
print_success() { echo -e "${GREEN}$1${NC}"; }
|
||||
print_warning() { echo -e "${YELLOW}$1${NC}"; }
|
||||
print_info() { echo -e "${CYAN}$1${NC}"; }
|
||||
print_debug() { echo -e "${GRAY}$1${NC}"; }
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confirmación inicial
|
||||
# ------------------------------------------------------------------
|
||||
print_error "¿Está seguro que desea iniciar un proceso de restauración de backup? Esto sobrescribirá datos actuales. S/N"
|
||||
read -r confirmation
|
||||
if [[ ! "$confirmation" =~ ^[Ss] ]]; then
|
||||
print_warning "Restauración cancelada por el usuario."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Localizar el backup más reciente
|
||||
# ------------------------------------------------------------------
|
||||
backup_zip=$(ls -t tip_backup_*.zip 2>/dev/null | head -n1)
|
||||
|
||||
if [[ -z "$backup_zip" ]]; then
|
||||
print_error "ERROR: No se encontró ningún backup con el patrón 'tip_backup_*.zip' en el directorio actual."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "Backup encontrado: $backup_zip"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Extraer en carpeta temporal
|
||||
# ------------------------------------------------------------------
|
||||
tempDir="./temp_restore"
|
||||
if [[ -d "$tempDir" ]]; then
|
||||
rm -rf "$tempDir"
|
||||
fi
|
||||
mkdir -p "$tempDir"
|
||||
unzip -q "$backup_zip" -d "$tempDir"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Leer manifiesto
|
||||
# ------------------------------------------------------------------
|
||||
jsonPath="$tempDir/manifest.json"
|
||||
if [[ ! -f "$jsonPath" ]]; then
|
||||
print_error "ERROR: No se encontró manifest.json en el backup."
|
||||
rm -rf "$tempDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Requiere jq instalado
|
||||
if ! command -v jq &> /dev/null; then
|
||||
print_error "ERROR: jq no está instalado. Instálelo con 'sudo apt install jq' o equivalente."
|
||||
rm -rf "$tempDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
createdAt=$(jq -r '.created_at' "$jsonPath")
|
||||
createdBy=$(jq -r '.created_by' "$jsonPath")
|
||||
# Leer componentes como array
|
||||
mapfile -t components < <(jq -r '.components[]' "$jsonPath")
|
||||
# Leer warnings
|
||||
mapfile -t warnings < <(jq -r '.warnings[]' "$jsonPath" 2>/dev/null)
|
||||
|
||||
print_info "Backup generado el $createdAt por usuario ID: $createdBy"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Obtener credenciales de bases de datos desde docker-compose.yml
|
||||
# ------------------------------------------------------------------
|
||||
# Restaurar docker-compose.yml desde el backup si existe
|
||||
composeBackupPath="${tempDir}/config/docker-compose.yml"
|
||||
|
||||
if [[ -f "$composeBackupPath" ]]; then
|
||||
print_warning "Restaurando docker-compose.yml desde el backup..."
|
||||
cp "$composeBackupPath" "./docker-compose.yml"
|
||||
fi
|
||||
|
||||
if [[ ! -f "./docker-compose.yml" ]]; then
|
||||
print_error "ERROR: No se encuentra docker-compose.yml en el directorio actual."
|
||||
rm -rf "$tempDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Obtener configuración de docker-compose en JSON
|
||||
if ! compose_json=$(docker-compose -f docker-compose.yml config --format json 2>/dev/null); then
|
||||
print_error "ERROR: No se pudo obtener la configuración de docker-compose. Verifica que Docker Compose esté instalado."
|
||||
rm -rf "$tempDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extraer contraseñas
|
||||
FEEDER_PASS=$(echo "$compose_json" | jq -r '.services.database.environment.MYSQL_ROOT_PASSWORD')
|
||||
USERS_PASS=$(echo "$compose_json" | jq -r '.services.database_users.environment.MYSQL_ROOT_PASSWORD')
|
||||
|
||||
if [[ -z "$FEEDER_PASS" || -z "$USERS_PASS" ]]; then
|
||||
print_error "ERROR: No se pudieron obtener las contraseñas de las bases de datos."
|
||||
rm -rf "$tempDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "Credenciales obtenidas correctamente."
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Función auxiliar para buscar archivo SQL
|
||||
# ------------------------------------------------------------------
|
||||
get_backup_sql_file() {
|
||||
local db_name="$1"
|
||||
local search_dir="$2"
|
||||
# Buscar archivo que contenga el nombre de la BD
|
||||
find "$search_dir" -maxdepth 1 -type f -name "*${db_name}*.sql" 2>/dev/null | head -n1
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Procesar componentes del manifiesto
|
||||
# ------------------------------------------------------------------
|
||||
for comp in "${components[@]}"; do
|
||||
echo -e "\n${YELLOW}Procesando componente: $comp${NC}"
|
||||
|
||||
# --- Bases de datos ---
|
||||
if [[ "$comp" == database:* ]]; then
|
||||
dbName="${comp#database:}"
|
||||
case "$dbName" in
|
||||
feeder)
|
||||
echo " → Restaurando base de datos Feeder..."
|
||||
docker compose up -d --build database --wait
|
||||
if [[ $? -ne 0 ]]; then
|
||||
print_error " ERROR: No se pudo levantar el contenedor de feeder_db."
|
||||
continue
|
||||
fi
|
||||
|
||||
sqlFile=$(get_backup_sql_file "feeder" "$tempDir/databases")
|
||||
if [[ -n "$sqlFile" && -f "$sqlFile" ]]; then
|
||||
echo " Archivo de dump: $(basename "$sqlFile")"
|
||||
if docker exec -i feeder_db mysql -uroot -p"$FEEDER_PASS" feeder < "$sqlFile"; then
|
||||
print_success " Base de datos feeder restaurada exitosamente."
|
||||
else
|
||||
print_error " ERROR al restaurar feeder."
|
||||
fi
|
||||
else
|
||||
print_warning " ADVERTENCIA: No se encontró archivo .sql para feeder."
|
||||
fi
|
||||
;;
|
||||
users)
|
||||
echo " → Restaurando base de datos Users..."
|
||||
docker compose up -d --build database_users --wait
|
||||
if [[ $? -ne 0 ]]; then
|
||||
print_error " ERROR: No se pudo levantar el contenedor de users_db."
|
||||
continue
|
||||
fi
|
||||
|
||||
sqlFile=$(get_backup_sql_file "users" "$tempDir/databases")
|
||||
if [[ -n "$sqlFile" && -f "$sqlFile" ]]; then
|
||||
echo " Archivo de dump: $(basename "$sqlFile")"
|
||||
if docker exec -i users_db mysql -uroot -p"$USERS_PASS" users < "$sqlFile"; then
|
||||
print_success " Base de datos users restaurada exitosamente."
|
||||
else
|
||||
print_error " ERROR al restaurar users."
|
||||
fi
|
||||
else
|
||||
print_warning " ADVERTENCIA: No se encontró archivo .sql para users."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
print_warning " → Base de datos desconocida: $dbName"
|
||||
;;
|
||||
esac
|
||||
# --- Archivos de configuración ---
|
||||
elif [[ "$comp" == config:* ]]; then
|
||||
filesStr="${comp#config:}"
|
||||
IFS=',' read -ra files <<< "$filesStr"
|
||||
echo " → Archivos de configuración: ${files[*]}"
|
||||
for file in "${files[@]}"; do
|
||||
source="$tempDir/config/$file"
|
||||
if [[ -f "$source" ]]; then
|
||||
case "$file" in
|
||||
nginx.conf) dest="./nginx/nginx.conf" ;;
|
||||
docker-compose.yml) dest="./docker-compose.yml" ;;
|
||||
*) dest="./$file" ;;
|
||||
esac
|
||||
echo " - Copiando $file a $dest"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "$source" "$dest"
|
||||
else
|
||||
print_warning " - Archivo $file no encontrado en el backup, se omite."
|
||||
fi
|
||||
done
|
||||
# --- Certificados SSL ---
|
||||
elif [[ "$comp" == "ssl" ]]; then
|
||||
echo " → Restaurando certificados SSL..."
|
||||
sslSource="$tempDir/ssl"
|
||||
if [[ -d "$sslSource" ]]; then
|
||||
sslDest="./nginx/ssl"
|
||||
mkdir -p "$sslDest"
|
||||
# Copiar contenido recursivamente preservando estructura
|
||||
cp -r "$sslSource"/* "$sslDest"/ 2>/dev/null || true
|
||||
print_success " Certificados SSL copiados a $sslDest"
|
||||
else
|
||||
print_warning " ADVERTENCIA: No se encontraron archivos SSL en el backup."
|
||||
fi
|
||||
# --- Sesiones de Telegram ---
|
||||
elif [[ "$comp" == "telegram_sessions" ]]; then
|
||||
echo " → Restaurando sesiones de Telegram..."
|
||||
sessionsSource="$tempDir/telegram_sessions"
|
||||
if [[ -d "$sessionsSource" ]]; then
|
||||
sessionsDest="./app/telegram_sessions"
|
||||
mkdir -p "$sessionsDest"
|
||||
cp -r "$sessionsSource"/* "$sessionsDest"/ 2>/dev/null || true
|
||||
print_success " Sesiones copiadas a $sessionsDest"
|
||||
else
|
||||
print_warning " ADVERTENCIA: No se encontraron sesiones en el backup."
|
||||
fi
|
||||
else
|
||||
print_error " → Componente no reconocido: $comp"
|
||||
fi
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Mostrar advertencias del manifiesto
|
||||
# ------------------------------------------------------------------
|
||||
if [[ ${#warnings[@]} -gt 0 ]]; then
|
||||
echo -e "\n${MAGENTA}⚠️ Advertencias durante la generación del backup:${NC}"
|
||||
for warn in "${warnings[@]}"; do
|
||||
echo " - $warn"
|
||||
done
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Limpieza
|
||||
# ------------------------------------------------------------------
|
||||
print_debug "\nEliminando archivos temporales..."
|
||||
rm -rf "$tempDir"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Preguntar si reiniciar contenedores
|
||||
# ------------------------------------------------------------------
|
||||
print_success "\nRestauración completada, ¿desea reiniciar los contenedores? S/N"
|
||||
read -r answer
|
||||
|
||||
if [[ "$answer" =~ ^[Ss] ]]; then
|
||||
print_info "Reiniciando contenedores..."
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
if [[ $? -eq 0 ]]; then
|
||||
print_success "Contenedores reiniciados exitosamente."
|
||||
else
|
||||
print_error "ERROR al reiniciar los contenedores. Verifica los logs de Docker."
|
||||
fi
|
||||
else
|
||||
print_warning "Recuerde reiniciar los contenedores para aplicar los cambios."
|
||||
fi
|
||||
|
||||
print_success "Proceso de restauración completado."
|
||||
@@ -0,0 +1,6 @@
|
||||
# Exportar los módulos principales
|
||||
from .database import Base, get_db
|
||||
from . import models
|
||||
from . import schemas
|
||||
|
||||
__all__ = ["Base", "get_db", "models", "schemas"]
|
||||
@@ -0,0 +1,38 @@
|
||||
import sys
|
||||
|
||||
from database import SessionLocal
|
||||
from models import User
|
||||
from services import AuthService
|
||||
from os import getenv
|
||||
|
||||
def create_admin():
|
||||
email = getenv("ADMIN_EMAIL")
|
||||
password = getenv("ADMIN_PASSWORD")
|
||||
|
||||
if not email or not password:
|
||||
raise RuntimeError("ADMIN_EMAIL y ADMIN_PASSWORD is required.")
|
||||
if len(password) < 8:
|
||||
raise RuntimeError("ADMIN_PASSWORD need almost 8 caracters.")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
print(f"[INFO] Admin '{email}' already exists, skipping creation.")
|
||||
return
|
||||
admin = User(
|
||||
name='Admin',
|
||||
email=email,
|
||||
password=AuthService.hash_password(password),
|
||||
rol='admin',
|
||||
active=True
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print(f"[INFO] Admin '{email}' created successfully.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"[ERROR] Failed to create admin: {e}", file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,231 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
import re
|
||||
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import (
|
||||
UserCreate, UserUpdate, UserResponse, UserWithModifications,
|
||||
RoleChangeRequest, LoginRequest, Token, ModificationResponse
|
||||
)
|
||||
from services import UserService, AuthService
|
||||
from dependencies import get_current_user, require_admin, require_admin_or_owner, oauth2_scheme
|
||||
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Rutas de Autenticación
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
login_data: LoginRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = AuthService.authenticate_user(db, login_data.email, login_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
)
|
||||
|
||||
access_token = AuthService.create_access_token(data={"user_id": user.id})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
# Rutas de Usuarios
|
||||
@router.get("/users/", response_model=List[UserResponse])
|
||||
def get_all_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
return user_service.get_all_users(skip, limit)
|
||||
|
||||
@router.get("/users/pending", response_model=List[UserResponse])
|
||||
def get_pending_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
return user_service.get_pending_users(skip, limit)
|
||||
|
||||
@router.get("/users/me", response_model=UserResponse)
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserWithModifications)
|
||||
def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = user_service.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Verificar permisos (admin o el propio usuario)
|
||||
if current_user.rol != 'admin' and current_user.id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
return user
|
||||
|
||||
@router.post("/users/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
user_data: UserCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ip_address = request.client.host if request.client else None
|
||||
return user_service.create_user(user_data, current_user.id, ip_address)
|
||||
|
||||
@router.post("/users/common", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("5/minute")
|
||||
def create_common_user(
|
||||
user_data: UserCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Endpoint público para que cualquier usuario (no autenticado) pueda registrarse.
|
||||
|
||||
The created user will always have `rol='operator'` and `active=False`.
|
||||
For audit purposes, when no authenticated updater is available we record the
|
||||
created user itself as the `updater` (self-created).
|
||||
"""
|
||||
user_service = UserService(db)
|
||||
ip_address = request.client.host if request.client else None
|
||||
|
||||
# Ensure role and active defaults regardless of input
|
||||
user_data = user_data.copy(update={"rol": "operator", "active": False})
|
||||
|
||||
# Pass updater_id=None so the service will mark the new user as the updater
|
||||
return user_service.create_user(user_data, updater_id=None, ip_address=ip_address)
|
||||
|
||||
@router.get("/logs/users", response_model=List[ModificationResponse])
|
||||
def get_users_modifications(
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Verificar permisos
|
||||
if current_user.rol != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
user_service = UserService(db)
|
||||
return user_service.get_users_modifications(skip, limit)
|
||||
|
||||
@router.put("/users/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
|
||||
# Verificar permisos
|
||||
if current_user.rol != 'admin' and current_user.id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
edited_user = user_service.get_user_by_id(user_id)
|
||||
|
||||
"""Verify is the desactivated used is and admin, in this case check if there is at least another active admin before allowing the activation."""
|
||||
if user_data.active is not None and edited_user.rol == 'admin' and user_data.active == False:
|
||||
active_admins = user_service.count_active_admins()
|
||||
if active_admins <= 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot deactivate this user because it is the only active admin. Please activate another admin first."
|
||||
)
|
||||
|
||||
|
||||
ip_address = request.client.host if request.client else None
|
||||
return user_service.update_user(user_id, user_data, current_user.id, ip_address)
|
||||
|
||||
@router.patch("/users/{user_id}/role", response_model=UserResponse)
|
||||
def change_user_role(
|
||||
user_id: int,
|
||||
role_data: RoleChangeRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ip_address = request.client.host if request.client else None
|
||||
return user_service.change_user_role(user_id, role_data.new_role, current_user.id, ip_address)
|
||||
|
||||
@router.post("/users/{user_id}/deactivate", response_model=UserResponse)
|
||||
def deactivate_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ip_address = request.client.host if request.client else None
|
||||
"""Verify is the desactivated used is and admin, in this case check if there is at least another active admin before allowing the activation."""
|
||||
if current_user.rol == 'admin':
|
||||
desactivated_user = user_service.get_user_by_id(user_id)
|
||||
if desactivated_user and desactivated_user.rol == 'admin':
|
||||
active_admins = user_service.count_active_admins()
|
||||
if active_admins <= 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot activate this user because it is the only active admin. Please activate another admin first."
|
||||
)
|
||||
return user_service.deactivate_user(user_id, current_user.id, ip_address)
|
||||
|
||||
@router.post("/users/{user_id}/activate", response_model=UserResponse)
|
||||
def activate_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ip_address = request.client.host if request.client else None
|
||||
return user_service.activate_user(user_id, current_user.id, ip_address)
|
||||
|
||||
@router.get("/users/{user_id}/modifications", response_model=List[ModificationResponse])
|
||||
def get_user_modifications(
|
||||
user_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Verificar permisos
|
||||
if current_user.rol != 'admin' and current_user.id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
user_service = UserService(db)
|
||||
return user_service.get_user_modifications(user_id, skip, limit)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=UserResponse)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ip_address = request.client.host if request.client else None
|
||||
if current_user.rol != 'admin' and current_user.id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user_service.delete_user(user_id, current_user.id, ip_address)
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Configuración de la base de datos
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db")
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependencia para obtener la sesión de la base de datos
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from services import AuthService, UserService
|
||||
from models import User
|
||||
|
||||
# Configuración OAuth2
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login")
|
||||
|
||||
# Dependencias de seguridad
|
||||
def get_current_user(
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(oauth2_scheme)
|
||||
) -> User:
|
||||
user_id = AuthService.verify_token(token)
|
||||
user_service = UserService(db)
|
||||
user = user_service.get_user_by_id(user_id)
|
||||
if not user or not user.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token or inactive user",
|
||||
)
|
||||
return user
|
||||
|
||||
def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_active_user)):
|
||||
if current_user.rol != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
def require_admin_or_owner(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if current_user.rol != 'admin' and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import crud
|
||||
from database import engine
|
||||
from models import Base
|
||||
from create_admin import create_admin
|
||||
from os import getenv
|
||||
|
||||
# Crear tablas
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="User Management API", version="1.0.0")
|
||||
|
||||
# Configurar CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[getenv("FRONT_END_URL")],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Incluir rutas
|
||||
app.include_router(crud.router, prefix="/api/v1", tags=["api"])
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "User Management API"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
##Eliminar en producción
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Ejecutar scraper síncrono en thread separado"""
|
||||
# Usar asyncio.to_thread para ejecutar código síncrono
|
||||
create_admin()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), unique=True, nullable=False)
|
||||
password = Column(String(255), nullable=False)
|
||||
rol = Column(String(50), default='operator')
|
||||
active = Column(Boolean, default=True)
|
||||
creation_time = Column(DateTime, default=datetime.utcnow)
|
||||
update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
modifications = relationship('UserAudit', back_populates='user',
|
||||
foreign_keys='UserAudit.user_id')
|
||||
modifications_made = relationship('UserAudit', back_populates='updater',
|
||||
foreign_keys='UserAudit.updater_id')
|
||||
|
||||
class UserAudit(Base):
|
||||
__tablename__ = 'users_audit'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
updater_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
|
||||
action = Column(String(50), nullable=False)
|
||||
updated_attribute = Column(String(100))
|
||||
before_value = Column(Text)
|
||||
after_value = Column(Text)
|
||||
snapshot = Column(JSON)
|
||||
|
||||
modification_date = Column(DateTime, default=datetime.utcnow)
|
||||
ip_address = Column(String(45))
|
||||
|
||||
user = relationship('User', foreign_keys=[user_id], back_populates='modifications')
|
||||
updater = relationship('User', foreign_keys=[updater_id], back_populates='modifications_made')
|
||||
@@ -0,0 +1,67 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# User Schemas
|
||||
class UserBase(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
rol: str = "operator"
|
||||
active: bool = True
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = None
|
||||
rol: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
creation_time: datetime
|
||||
update_time: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserWithModifications(UserResponse):
|
||||
modifications: List["ModificationSimple"] = []
|
||||
|
||||
# Audit Schemas
|
||||
class ModificationSimple(BaseModel):
|
||||
id: int
|
||||
action: str
|
||||
updated_attribute: Optional[str] = None
|
||||
before_value: Optional[str] = None
|
||||
after_value: Optional[str] = None
|
||||
modification_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ModificationResponse(ModificationSimple):
|
||||
user: Optional[UserResponse] = None
|
||||
updater: Optional[UserResponse] = None
|
||||
|
||||
# Auth Schemas
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: UserResponse
|
||||
|
||||
class TokenData(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class RoleChangeRequest(BaseModel):
|
||||
new_role: str
|
||||
|
||||
# Resolver referencias circulares
|
||||
UserWithModifications.model_rebuild()
|
||||
ModificationResponse.model_rebuild()
|
||||
@@ -0,0 +1,333 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from models import User, UserAudit
|
||||
from schemas import UserCreate, UserUpdate
|
||||
|
||||
# Configuración de seguridad
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY no configurada. Abortando inicio.")
|
||||
if len(SECRET_KEY) < 20:
|
||||
raise RuntimeError("SECRET_KEY demasiado corta")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
class AuthService:
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
user = db.query(User).filter(User.email == email, User.active == True).first()
|
||||
if not user:
|
||||
return None
|
||||
if not AuthService.verify_password(password, user.password):
|
||||
return None
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
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, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: int = payload.get("user_id")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
return user_id
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def get_user_by_email(self, email: str) -> Optional[User]:
|
||||
return self.db.query(User).filter(User.email == email).first()
|
||||
|
||||
def get_all_users(self, skip: int = 0, limit: int = 100):
|
||||
return self.db.query(User).offset(skip).limit(limit).all()
|
||||
|
||||
def get_pending_users(self, skip: int = 0, limit: int = 100):
|
||||
return self.db.query(User).filter(User.active == False).offset(skip).limit(limit).all()
|
||||
|
||||
def count_active_admins(self) -> int:
|
||||
"""Helper function to count the number of active admin users."""
|
||||
return self.db.query(User).filter(User.rol == 'admin', User.active == True).count()
|
||||
|
||||
def create_user(self, user_data: UserCreate, updater_id: Optional[int] = None, ip_address: str = None) -> User:
|
||||
if self.get_user_by_email(user_data.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to create user"
|
||||
)
|
||||
|
||||
if user_data.rol not in ['admin', 'operator']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid role specified"
|
||||
)
|
||||
|
||||
if user_data.password and len(user_data.password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters long"
|
||||
)
|
||||
|
||||
hashed_password = AuthService.hash_password(user_data.password)
|
||||
|
||||
user = User(
|
||||
name=user_data.name,
|
||||
email=user_data.email,
|
||||
password=hashed_password,
|
||||
rol=user_data.rol,
|
||||
active=user_data.active
|
||||
)
|
||||
|
||||
self.db.add(user)
|
||||
self.db.flush()
|
||||
|
||||
# If no updater_id provided (unauthenticated creation), mark the user as the updater (self-created)
|
||||
audit_updater = updater_id if updater_id is not None else user.id
|
||||
|
||||
audit = UserAudit(
|
||||
user_id=user.id,
|
||||
updater_id=audit_updater,
|
||||
action="create",
|
||||
after_value=f"User created with role: {user.rol}",
|
||||
snapshot={
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"rol": user.rol,
|
||||
"active": user.active
|
||||
},
|
||||
ip_address=ip_address
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: int, user_data: UserUpdate, updater_id: int, ip_address: str = None) -> User:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if user_data.rol not in ['admin', 'operator']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid role specified"
|
||||
)
|
||||
|
||||
if user_data.password and len(user_data.password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters long"
|
||||
)
|
||||
|
||||
changes = []
|
||||
snapshot = {
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"rol": user.rol,
|
||||
"active": user.active
|
||||
}
|
||||
|
||||
for field, value in user_data.dict(exclude_unset=True).items():
|
||||
if hasattr(user, field):
|
||||
old_value = getattr(user, field)
|
||||
|
||||
if field == "password":
|
||||
value = AuthService.hash_password(value)
|
||||
old_value = "***"
|
||||
|
||||
if old_value != value:
|
||||
setattr(user, field, value)
|
||||
|
||||
audit = UserAudit(
|
||||
user_id=user_id,
|
||||
updater_id=updater_id,
|
||||
action="update",
|
||||
updated_attribute=field,
|
||||
before_value=str(old_value),
|
||||
after_value=str(value) if field != "password" else "***",
|
||||
snapshot=snapshot,
|
||||
ip_address=ip_address
|
||||
)
|
||||
self.db.add(audit)
|
||||
changes.append(field)
|
||||
|
||||
if changes:
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def change_user_role(self, user_id: int, new_role: str, updater_id: int, ip_address: str = None) -> User:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
old_role = user.rol
|
||||
snapshot = {
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"rol": user.rol,
|
||||
"active": user.active
|
||||
}
|
||||
|
||||
user.rol = new_role
|
||||
|
||||
audit = UserAudit(
|
||||
user_id=user_id,
|
||||
updater_id=updater_id,
|
||||
action="role_change",
|
||||
updated_attribute="rol",
|
||||
before_value=old_role,
|
||||
after_value=new_role,
|
||||
snapshot=snapshot,
|
||||
ip_address=ip_address
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def deactivate_user(self, user_id: int, updater_id: int, ip_address: str = None) -> User:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.active = False
|
||||
|
||||
audit = UserAudit(
|
||||
user_id=user_id,
|
||||
updater_id=updater_id,
|
||||
action="deactivate",
|
||||
updated_attribute="active",
|
||||
before_value="True",
|
||||
after_value="False",
|
||||
snapshot={
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"rol": user.rol,
|
||||
"active": True
|
||||
},
|
||||
ip_address=ip_address
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def activate_user(self, user_id: int, updater_id: int, ip_address: str = None) -> User:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.active = True
|
||||
|
||||
audit = UserAudit(
|
||||
user_id=user_id,
|
||||
updater_id=updater_id,
|
||||
action="activate",
|
||||
updated_attribute="active",
|
||||
before_value="False",
|
||||
after_value="True",
|
||||
snapshot={
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"rol": user.rol,
|
||||
"active": False
|
||||
},
|
||||
ip_address=ip_address
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def get_user_modifications(self, user_id: int, skip: int = 0, limit: int = 50):
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return (self.db.query(UserAudit)
|
||||
.filter(UserAudit.user_id == user_id)
|
||||
.order_by(UserAudit.modification_date.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all())
|
||||
|
||||
def get_users_modifications(self, skip: int = 0, limit: int = 100):
|
||||
return (self.db.query(UserAudit)
|
||||
.order_by(UserAudit.modification_date.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all())
|
||||
|
||||
def delete_user(self, user_id: int, updater_id: int, ip_address: str = None):
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
snapshot = {
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"rol": user.rol,
|
||||
"active": user.active
|
||||
}
|
||||
|
||||
self.db.delete(user)
|
||||
|
||||
audit = UserAudit(
|
||||
user_id=user_id,
|
||||
updater_id=updater_id,
|
||||
action="delete",
|
||||
after_value="User deleted",
|
||||
snapshot=snapshot,
|
||||
ip_address=ip_address
|
||||
)
|
||||
self.db.add(audit)
|
||||
self.db.commit()
|
||||
Reference in New Issue
Block a user