First commit

This commit is contained in:
unknown
2026-06-09 21:18:13 -03:00
commit 5bff6b938b
66 changed files with 10922 additions and 0 deletions
+10
View File
@@ -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
View File
@@ -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"]
+29
View File
@@ -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"]
+23
View File
@@ -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"]
+766
View File
@@ -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á.*
+8
View File
@@ -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"]
+38
View File
@@ -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
+63
View File
@@ -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()
+25
View File
@@ -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"}
+64
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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()
+193
View File
@@ -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)}"
)
+833
View File
@@ -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
View File
+150
View File
@@ -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
View File
@@ -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)
View File
+220
View File
@@ -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
+157
View File
@@ -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}"}
)
+64
View File
@@ -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
+166
View File
@@ -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"}
+264
View File
@@ -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"}
+191
View File
@@ -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"}
+90
View File
@@ -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
+162
View File
@@ -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"}
+127
View File
@@ -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"}
+320
View File
@@ -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
View File
@@ -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()
+35
View File
@@ -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"]
+378
View File
@@ -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"}
+5
View File
@@ -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
+208
View File
@@ -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
+15
View File
@@ -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 "$@"
+15
View File
@@ -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"
+11
View File
@@ -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"
+28
View File
@@ -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()
+439
View File
@@ -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')))
+346
View File
@@ -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)
+484
View File
@@ -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()
+296
View File
@@ -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()
+547
View File
@@ -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)
+134
View File
@@ -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()
+91
View File
@@ -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."
)
+109
View File
@@ -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}** &nbsp; · &nbsp; {type}"
f"<br><span style='font-size:11px;opacity:0.5;'>ID: {group_id} &nbsp;·&nbsp; 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
)
+736
View File
@@ -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"])
+179
View File
@@ -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)
+193
View File
@@ -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()
+207
View File
@@ -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()
+224
View File
@@ -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}** &nbsp; `@{username}` &nbsp; · &nbsp; {_tipo_badge(tipo)}"
f"<br><span style='font-size:11px;opacity:0.5;'>ID: {sid} &nbsp;·&nbsp; 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} &nbsp; {'📎' 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()
+251
View File
@@ -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.")
+104
View File
@@ -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;
}
}
}
+13
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
streamlit>=1.28.0
requests>=2.31.0
pandas>=2.0.0
reportlab>=3.6.0
matplotlib>=3.7.0
+11
View File
@@ -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
+264
View File
@@ -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
+266
View File
@@ -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."
+6
View File
@@ -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"]
+38
View File
@@ -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()
+231
View File
@@ -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)
+24
View File
@@ -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()
+49
View File
@@ -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
+44
View File
@@ -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)
+40
View File
@@ -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')
+67
View File
@@ -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()
+333
View File
@@ -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()