primer commit
This commit is contained in:
commit
0855948192
11 files changed
+617
No files matched your search
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
venv/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
logs/
|
||||
adjuntos/
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hana": {
|
||||
"server": "192.168.9.13",
|
||||
"port": 30015,
|
||||
"user": "SYSTEM",
|
||||
"password": ""
|
||||
},
|
||||
"outlook": {
|
||||
"client_id": "671f6154-715f-4e52-aef9-94ad108dc1d4",
|
||||
"client_secret": "oG58Q~yufghPKmZuh0oVqX6rcLedLP1HTwIC-bq5",
|
||||
"tenant_id": "5ac66bf8-fbbd-4615-80c0-8e3d521dc5e6",
|
||||
"download_folder": "./adjuntos",
|
||||
"verify_ssl": false,
|
||||
"ca_bundle": null
|
||||
},
|
||||
"client": {
|
||||
"email": "contabilidad1@aberio.com.pe",
|
||||
"ruta_descarga": "./adjuntos",
|
||||
"ruta_final": "./resultado",
|
||||
"base_datos": "SBO_ABERIO_PRODUCCION",
|
||||
"usuario": "manager",
|
||||
"password": "s1st3m4s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from config_model import Config, HanaConfig, OutlookConfig, ClientConfig
|
||||
|
||||
########################################################
|
||||
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
|
||||
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Instantiate dataclasses
|
||||
hana = HanaConfig(**data.get('hana', {}))
|
||||
outlook = OutlookConfig(**data.get('outlook', {}))
|
||||
client = ClientConfig(**data.get('client', {}))
|
||||
|
||||
CONFIG = Config(
|
||||
hana=hana,
|
||||
outlook=outlook,
|
||||
client=client
|
||||
)
|
||||
|
||||
########################################################
|
||||
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class HanaConfig:
|
||||
server: str
|
||||
port: int
|
||||
user: str
|
||||
password: str
|
||||
|
||||
@dataclass
|
||||
class OutlookConfig:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
tenant_id: str
|
||||
download_folder: str
|
||||
verify_ssl: bool
|
||||
ca_bundle: Optional[str]
|
||||
|
||||
@dataclass
|
||||
class ClientConfig:
|
||||
email: str
|
||||
ruta_descarga: str
|
||||
ruta_final: str
|
||||
base_datos: str
|
||||
usuario: str
|
||||
password: str
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
hana: HanaConfig
|
||||
outlook: OutlookConfig
|
||||
client: ClientConfig
|
||||
@@ -0,0 +1,111 @@
|
||||
import sqlite3
|
||||
|
||||
DB_NAME = "emails.db"
|
||||
|
||||
########################################################
|
||||
def init_db():
|
||||
"""
|
||||
Inicializa la base de datos SQLite y crea la tabla de correos si no existe.
|
||||
"""
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Crear tabla con id como llave primaria para evitar duplicados
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS emails (
|
||||
id TEXT PRIMARY KEY,
|
||||
fecha TEXT,
|
||||
sender TEXT,
|
||||
subject TEXT,
|
||||
has_attach INTEGER,
|
||||
fecha_descarga TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
########################################################
|
||||
def save_emails(emails_list):
|
||||
"""
|
||||
Guarda una lista de correos en la base de datos.
|
||||
Retorna la cantidad de registros nuevos insertados.
|
||||
"""
|
||||
if not emails_list:
|
||||
return 0
|
||||
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
|
||||
from datetime import datetime
|
||||
fecha_ahora = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Mapear lista de diccionarios a tuplas para executemany
|
||||
# has_attach se guarda como 1 (True) o 0 (False)
|
||||
data = [
|
||||
(
|
||||
e['id'],
|
||||
e['fecha'],
|
||||
e['sender'],
|
||||
e['subject'],
|
||||
1 if e['has_attach'] else 0,
|
||||
fecha_ahora
|
||||
)
|
||||
for e in emails_list
|
||||
]
|
||||
|
||||
# INSERT OR IGNORE evita errores si el ID ya existe
|
||||
cursor.executemany('''
|
||||
INSERT OR IGNORE INTO emails (id, fecha, sender, subject, has_attach, fecha_descarga)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', data)
|
||||
|
||||
conn.commit()
|
||||
rows_affected = cursor.rowcount
|
||||
conn.close()
|
||||
return rows_affected
|
||||
|
||||
########################################################
|
||||
def get_existing_ids(ids_list):
|
||||
"""
|
||||
Recibe una lista de IDs y devuelve una lista con aquellos que ya existen en la BD.
|
||||
"""
|
||||
if not ids_list:
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
|
||||
existing_ids = []
|
||||
# Procesar por lotes para evitar límites de parámetros en SQLite
|
||||
for i in range(0, len(ids_list), 900):
|
||||
chunk = ids_list[i:i + 900]
|
||||
placeholders = ','.join(['?'] * len(chunk))
|
||||
query = f"SELECT id FROM emails WHERE id IN ({placeholders})"
|
||||
cursor.execute(query, chunk)
|
||||
existing_ids.extend([row[0] for row in cursor.fetchall()])
|
||||
|
||||
conn.close()
|
||||
return existing_ids
|
||||
|
||||
########################################################
|
||||
def delete_email_by_id(email_id):
|
||||
"""
|
||||
Elimina un correo de la base de datos por su ID.
|
||||
Retorna True si se eliminó el registro, False si no se encontró.
|
||||
"""
|
||||
if not email_id:
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM emails WHERE id = ?", (email_id,))
|
||||
|
||||
conn.commit()
|
||||
rows_affected = cursor.rowcount
|
||||
conn.close()
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
########################################################
|
||||
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
import zipfile
|
||||
import database_utilities
|
||||
|
||||
########################################################
|
||||
def quitar_correos_ya_registrados_en_base_de_datos(emails):
|
||||
"""
|
||||
Verifica cuáles correos son nuevos (no están en la BD), los guarda
|
||||
y retorna la lista filtrada de los que tienen adjuntos.
|
||||
"""
|
||||
if not emails:
|
||||
return []
|
||||
|
||||
# 1. Verificar cuáles ya existen en la BD
|
||||
ids = [e['id'] for e in emails]
|
||||
existentes = database_utilities.get_existing_ids(ids)
|
||||
|
||||
# Solo nos quedamos con los que NO están en la BD
|
||||
correos_no_registrados = [e for e in emails if e['id'] not in existentes]
|
||||
print(f"Correos nuevos detectados: {len(correos_no_registrados)}")
|
||||
|
||||
if correos_no_registrados:
|
||||
print("Guardando nuevos correos en la base de datos...")
|
||||
database_utilities.save_emails(correos_no_registrados)
|
||||
|
||||
return correos_no_registrados
|
||||
|
||||
########################################################
|
||||
def descomprimir_adjuntos(emails, ruta):
|
||||
"""
|
||||
Esta funcion recibe la lista de emails y por cada no recorre sus adjuntos
|
||||
si encuentra un adjunto .zip lo descomprime en la ruta especificada
|
||||
y actualiza la lista de archivos agregar estos nuevos documentos al lsitado de adjuntos del email
|
||||
"""
|
||||
archivos_finales = []
|
||||
for archivo in archivos:
|
||||
ruta_archivo = os.path.join(ruta, archivo)
|
||||
if archivo.lower().endswith('.zip') and os.path.exists(ruta_archivo):
|
||||
try:
|
||||
with zipfile.ZipFile(ruta_archivo, 'r') as zip_ref:
|
||||
nombres_extraidos = zip_ref.namelist()
|
||||
zip_ref.extractall(ruta)
|
||||
archivos_finales.extend(nombres_extraidos)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Error descomprimiendo {archivo}: {e}")
|
||||
archivos_finales.append(archivo)
|
||||
else:
|
||||
archivos_finales.append(archivo)
|
||||
return archivos_finales
|
||||
|
||||
########################################################
|
||||
def filtrar_archivos_validos(archivos):
|
||||
|
||||
"""
|
||||
Retorna una lista solo con archivos .pdf y .xml (ignora mayúsculas/minúsculas).
|
||||
"""
|
||||
extensiones_validas = ('.pdf', '.xml')
|
||||
return [a for a in archivos if a.lower().endswith(extensiones_validas)]
|
||||
|
||||
########################################################
|
||||
def procesar_adjuntos_email(e, archivos, ruta_descarga):
|
||||
"""
|
||||
Descomprime y filtra los archivos de un correo.
|
||||
Retorna la lista de archivos válidos si existen, de lo contrario lista vacía.
|
||||
"""
|
||||
# 1. Descomprimir archivos ZIP si existen
|
||||
archivos_procesados = descomprimir_adjuntos(archivos, ruta_descarga)
|
||||
|
||||
# 2. Filtrar solo PDF y XML
|
||||
archivos_validos = filtrar_archivos_validos(archivos_procesados)
|
||||
|
||||
if archivos_validos:
|
||||
e['archivos'] = archivos_validos
|
||||
return archivos_validos
|
||||
return []
|
||||
|
||||
########################################################
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
########################################################
|
||||
class MultiStream:
|
||||
"""
|
||||
Clase para redirigir la salida a múltiples flujos (consola y archivo).
|
||||
"""
|
||||
def __init__(self, *streams):
|
||||
self.streams = streams
|
||||
|
||||
def write(self, data):
|
||||
for stream in self.streams:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
|
||||
def flush(self):
|
||||
for stream in self.streams:
|
||||
stream.flush()
|
||||
|
||||
########################################################
|
||||
def setup_logger():
|
||||
"""
|
||||
Configura la redirección de stdout y stderr a un archivo de log y a la consola.
|
||||
Retorna la ruta del archivo de log creado.
|
||||
"""
|
||||
log_dir = "logs"
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
# Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
log_filename = f"{timestamp}.log"
|
||||
log_path = os.path.join(log_dir, log_filename)
|
||||
|
||||
# Abrir archivo en modo append con encoding utf-8 para soportar iconos
|
||||
log_file = open(log_path, "a", encoding="utf-8")
|
||||
|
||||
# Redirigir stdout y stderr
|
||||
sys.stdout = MultiStream(sys.stdout, log_file)
|
||||
sys.stderr = MultiStream(sys.stderr, log_file)
|
||||
|
||||
return log_path
|
||||
|
||||
########################################################
|
||||
def limpiar_logs_antiguos(dias=7):
|
||||
"""
|
||||
Elimina archivos de log con más de 'n' días de antigüedad.
|
||||
"""
|
||||
log_dir = "logs"
|
||||
if not os.path.exists(log_dir):
|
||||
return
|
||||
|
||||
limite = datetime.now() - timedelta(days=dias)
|
||||
count = 0
|
||||
|
||||
for archivo in os.listdir(log_dir):
|
||||
ruta_archivo = os.path.join(log_dir, archivo)
|
||||
if os.path.isfile(ruta_archivo) and archivo.endswith(".log"):
|
||||
fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo))
|
||||
if fecha_archivo < limite:
|
||||
try:
|
||||
os.remove(ruta_archivo)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al eliminar log {archivo}: {e}")
|
||||
|
||||
if count > 0:
|
||||
print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).")
|
||||
@@ -0,0 +1,127 @@
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import email_utilities
|
||||
import outlook_utilities
|
||||
import database_utilities
|
||||
import logger_utilities
|
||||
from datetime import datetime, timedelta
|
||||
from config_loader import CONFIG
|
||||
|
||||
########################################################
|
||||
def obtener_token():
|
||||
try:
|
||||
print("Intentando obtener token de acceso...")
|
||||
outlook_token = outlook_utilities.get_access_token()
|
||||
print("✅ Token obtenido exitosamente.")
|
||||
print(f"Token prefix: {outlook_token[:25]}...")
|
||||
return outlook_token
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
########################################################
|
||||
def descargar_correos(outlook_token, fecha_inicio, fecha_fin):
|
||||
|
||||
try:
|
||||
print("\n" + "="*60)
|
||||
print(f"[MAIL] {CONFIG.client.email}")
|
||||
|
||||
emails = outlook_utilities.get_emails(CONFIG.client.email, outlook_token, fecha_inicio, fecha_fin)
|
||||
print(f"Se encontraron {len(emails)} correos.")
|
||||
|
||||
return emails
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
return []
|
||||
|
||||
########################################################
|
||||
def descargar_adjuntos_de_correos(outlook_token, emails, ruta_descarga):
|
||||
try:
|
||||
|
||||
for email in emails:
|
||||
# Guardar lista original para el log
|
||||
adjuntos = outlook_utilities.download_attachments(
|
||||
CONFIG.client.email,
|
||||
email['id'],
|
||||
outlook_token,
|
||||
ruta_descarga)
|
||||
if adjuntos:
|
||||
email['adjuntos'] = adjuntos
|
||||
|
||||
return emails
|
||||
|
||||
except Exception as ex:
|
||||
print(f" ❌ Error procesando {email['id'][:10]}: {ex}")
|
||||
|
||||
return []
|
||||
|
||||
########################################################
|
||||
def main():
|
||||
|
||||
# Inicializar Base de Datos
|
||||
database_utilities.init_db()
|
||||
|
||||
# Calcular fechas
|
||||
hoy = datetime.now()
|
||||
ayer = hoy - timedelta(days=25)
|
||||
|
||||
fecha_inicio = ayer.strftime("%Y-%m-%d")
|
||||
fecha_fin = hoy.strftime("%Y-%m-%d")
|
||||
|
||||
print(f"Rango de búsqueda: {fecha_inicio} al {fecha_fin}")
|
||||
|
||||
outlook_token = obtener_token()
|
||||
# se descarga todos los correos en el rango de fecha ingresado
|
||||
# tengan o no adjuntos
|
||||
emails = descargar_correos(outlook_token, fecha_inicio, fecha_fin)
|
||||
|
||||
# se verifica si estos correos ya estan en la base de datos
|
||||
# si ya esta registrado, se quita del listado
|
||||
# si no esta se inserta en la base de datos
|
||||
|
||||
emails = email_utilities.quitar_correos_ya_registrados_en_base_de_datos(emails)
|
||||
|
||||
# descargar todos los adjuntos de cada mail
|
||||
|
||||
|
||||
|
||||
|
||||
# si el correo tiene archivos zip, se descomprimen y se agregan a
|
||||
# la lista de archivos adjuntos
|
||||
|
||||
|
||||
|
||||
# se evalua los adjuntos del correo:
|
||||
# 1. se quita de la lista el correo si no tiene adjunto
|
||||
# 2. se quita de la lista el correo si no tiene
|
||||
|
||||
for e in emails:
|
||||
print(f"\n📬 [{e['fecha']}] {e['subject'][:80]}...")
|
||||
archivos_adjuntos = descargar_adjunto_de_correo(outlook_token, e['id'], CONFIG.client.ruta_descarga)
|
||||
|
||||
########################################################
|
||||
if __name__ == "__main__":
|
||||
|
||||
# Configurar Logger (Redirige stdout a archivo y consola)
|
||||
log_path = logger_utilities.setup_logger()
|
||||
logger_utilities.limpiar_logs_antiguos(dias=30)
|
||||
|
||||
inicio_ejecucion = datetime.now()
|
||||
print("="*60)
|
||||
print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"📄 Log guardado en: {log_path}")
|
||||
print("="*60)
|
||||
|
||||
main()
|
||||
|
||||
fin_ejecucion = datetime.now()
|
||||
duracion = fin_ejecucion - inicio_ejecucion
|
||||
print("\n" + "="*60)
|
||||
print(f"🏁 FIN DE EJECUCIÓN: {fin_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"⏱️ Duración total: {str(duracion).split('.')[0]}") # Sin microsegundos
|
||||
print("="*60)
|
||||
@@ -0,0 +1,140 @@
|
||||
import msal
|
||||
import requests
|
||||
import warnings
|
||||
import base64
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from config_loader import CONFIG
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
########################################################
|
||||
def get_access_token():
|
||||
"""
|
||||
Obtiene un token de acceso de Microsoft Graph usando las credenciales
|
||||
configuradas en config.json (Client ID, Client Secret y Tenant ID).
|
||||
"""
|
||||
if not CONFIG or not CONFIG.outlook:
|
||||
raise ValueError("La configuración de Outlook no está cargada correctamente.")
|
||||
|
||||
authority = f"https://login.microsoftonline.com/{CONFIG.outlook.tenant_id}"
|
||||
|
||||
app = msal.ConfidentialClientApplication(
|
||||
CONFIG.outlook.client_id,
|
||||
authority=authority,
|
||||
client_credential=CONFIG.outlook.client_secret,
|
||||
verify=CONFIG.outlook.verify_ssl
|
||||
)
|
||||
|
||||
scopes = ["https://graph.microsoft.com/.default"]
|
||||
result = app.acquire_token_for_client(scopes=scopes)
|
||||
|
||||
if "access_token" in result:
|
||||
return result["access_token"]
|
||||
else:
|
||||
error = result.get("error")
|
||||
error_description = result.get("error_description")
|
||||
correlation_id = result.get("correlation_id")
|
||||
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
|
||||
|
||||
########################################################
|
||||
def get_emails(user_email, token, fecha_inicio, fecha_fin):
|
||||
"""
|
||||
Obtiene los correos de una cuenta específica en un rango de fechas.
|
||||
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
dt_inicio_utc = datetime.strptime(fecha_inicio, "%Y-%m-%d") + timedelta(hours=5)
|
||||
dt_fin_utc = datetime.strptime(fecha_fin, "%Y-%m-%d") + timedelta(days=1, hours=5)
|
||||
|
||||
filter_query = (
|
||||
f"receivedDateTime ge {dt_inicio_utc.strftime('%Y-%m-%dT%H:%M:%SZ')} and "
|
||||
f"receivedDateTime lt {dt_fin_utc.strftime('%Y-%m-%dT%H:%M:%SZ')}"
|
||||
)
|
||||
|
||||
params = {
|
||||
"$filter": filter_query,
|
||||
"$select": "id,receivedDateTime,hasAttachments,subject,from",
|
||||
"$top": 50
|
||||
}
|
||||
|
||||
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
|
||||
all_mails = []
|
||||
|
||||
while url:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
verify=CONFIG.outlook.verify_ssl
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error Graph API ({user_email}): {response.status_code} - {response.text}")
|
||||
|
||||
data = response.json()
|
||||
messages = data.get("value", [])
|
||||
|
||||
for msg in messages:
|
||||
utc_str = msg.get("receivedDateTime")
|
||||
dt_utc = datetime.fromisoformat(utc_str.replace("Z", "+00:00"))
|
||||
dt_local = dt_utc - timedelta(hours=5)
|
||||
|
||||
from_obj = msg.get("from", {})
|
||||
sender = from_obj.get("emailAddress", {}).get("address", "Desconocido")
|
||||
|
||||
all_mails.append({
|
||||
"id": msg.get("id"),
|
||||
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"sender": sender,
|
||||
"subject": msg.get("subject", ""),
|
||||
"has_attach": msg.get("hasAttachments")
|
||||
})
|
||||
|
||||
url = data.get("@odata.nextLink")
|
||||
params = None
|
||||
|
||||
return all_mails
|
||||
|
||||
########################################################
|
||||
def download_attachments(user_email, message_id, token, download_path):
|
||||
"""
|
||||
Descarga los adjuntos de un mensaje específico y los guarda en la ruta indicada.
|
||||
Retorna una lista con los nombres de los archivos descargados.
|
||||
"""
|
||||
if not os.path.exists(download_path):
|
||||
os.makedirs(download_path, exist_ok=True)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages/{message_id}/attachments"
|
||||
|
||||
response = requests.get(url, headers=headers, verify=CONFIG.outlook.verify_ssl)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error al obtener adjuntos ({message_id}): {response.status_code} - {response.text}")
|
||||
|
||||
attachments = response.json().get("value", [])
|
||||
downloaded_files = []
|
||||
|
||||
for attachment in attachments:
|
||||
if attachment.get("@odata.type") == "#microsoft.graph.fileAttachment":
|
||||
file_name = attachment.get("name")
|
||||
content_bytes = attachment.get("contentBytes")
|
||||
|
||||
if content_bytes:
|
||||
file_path = os.path.join(download_path, file_name)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(base64.b64decode(content_bytes))
|
||||
downloaded_files.append(file_name)
|
||||
|
||||
return downloaded_files
|
||||
|
||||
########################################################
|
||||
Reference in new issue
Block a user