From af8c37d7deb1c137316e7dfa58cf015ad1c862b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20Maureira-Fredes?= Date: Wed, 19 Aug 2026 15:49:32 +0200 Subject: [PATCH] Add GDPR right-of-access export command (%exportar) Complements the existing right-to-erasure command (%olvidar) with a read-only counterpart: %exportar collects every row belonging to a user across the personal-data logs into an in-memory zip (one CSV per log) and posts it to the moderation channel, with an audit-log entry (never the exported content itself). Also: - %terminos: surfaces a link to TERMS.md from Discord, backed by a new configurable TERMS_URL. - TERMS.md: adds a legal-basis section, documents channel-archive retention separately from the 30-day personal-data retention, and documents the new export right; renumbers sections accordingly. - README.md: short "Privacidad y datos" summary linking to TERMS.md. - archivar.py: delete the local temp CSV after it's uploaded to the mod channel, so it doesn't linger on disk outside the retention policy. Co-Authored-By: Claude Sonnet 5 --- README.md | 8 +++ TERMS.md | 127 ++++++++++++++++++++++++++++++---------- bot.py | 3 +- comandos/archivar.py | 8 +++ comandos/ayuda.py | 15 +++++ comandos/exportar.py | 92 +++++++++++++++++++++++++++++ comandos/retencion.py | 14 +++++ config.toml.example | 6 ++ configuration.py | 18 ++++++ conftest.py | 1 + tests/test_archivar.py | 4 ++ tests/test_ayuda.py | 13 ++++ tests/test_exportar.py | 87 +++++++++++++++++++++++++++ tests/test_retencion.py | 25 ++++++++ 14 files changed, 390 insertions(+), 31 deletions(-) create mode 100644 comandos/exportar.py create mode 100644 tests/test_exportar.py diff --git a/README.md b/README.md index db5283a..33f80a2 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ unas de las siguientes reglas: * **Menciones**: Personas que envían mensajes con 3 menciones o más a roles o personas. +## Privacidad y datos + +El bot almacena algunos datos personales (ver [TERMS.md](TERMS.md)) con +fines de moderación, con borrado automático a los 30 días y comandos para +ejercer tus derechos de acceso (`%exportar`, uso del equipo de moderación) +y de eliminación (`%olvidar`). El comando `%terminos` muestra el enlace a +la política completa en cualquier momento. + ## Tengo una idea para el bot Las futuras ideas del bot están definidas como 'Issues' en este repositorio. diff --git a/TERMS.md b/TERMS.md index 038ec01..30d1b85 100644 --- a/TERMS.md +++ b/TERMS.md @@ -1,12 +1,19 @@ # Términos de Uso y Política de Datos del Bot -Este documento describe qué datos recopila y almacena el bot de moderación -de este servidor de Discord, con qué propósito, durante cuánto tiempo, y -qué opciones tienes respecto a tus propios datos. +*Última actualización: 2026-08-17* + +Este documento describe qué datos recopila y almacena **LlamaBot**, bot +oficial de moderación del servidor de Discord **Python en Español**, con +qué propósito, durante cuánto tiempo, y qué opciones tienes respecto a tus +propios datos. LlamaBot no está en funcionamiento en ningún otro servidor; +este documento se refiere únicamente a su uso en Python en Español. + +Puedes consultar este documento en cualquier momento con el comando +`%terminos`. ## 1. Alcance -Este documento cubre únicamente los datos que **el bot** almacena por su +Este documento cubre únicamente los datos que **LlamaBot** almacena por su cuenta (en archivos de registro en el servidor donde corre). No cubre los datos que Discord, como plataforma, almacena sobre tu cuenta, tus mensajes o tu actividad - eso se rige por la [Política de Privacidad de @@ -23,6 +30,16 @@ controlar esos datos. | Decisión de moderación (aceptado/rechazado) y motivo del rechazo | Acción del equipo de moderación | Mantener trazabilidad de qué se decidió y por qué. | | Registro general de mensajes del servidor (autor, canal, contenido, fecha) | Toda la actividad del servidor | Auditoría e investigación de incidentes de moderación. | +**Base legal (RGPD/GDPR):** el bot procesa estos datos por **interés +legítimo** del servidor en poder moderar su propio contenido y prevenir +abuso/spam - es la funcionalidad esencial por la que el bot existe. No se +usan para ningún otro fin (marketing, perfilado, venta a terceros, etc.). + +LlamaBot solo solicita a Discord el permiso ("intent") de **contenido de +mensajes**, que es el mínimo necesario para las funciones descritas +arriba. No solicita acceso a la lista de miembros del servidor ni a datos +de presencia/actividad (en línea, jugando, etc.). + ## 3. Qué el bot **no** vincula a tu identidad Para detectar spam y contenido malicioso repetido, el bot guarda firmas del @@ -33,52 +50,102 @@ autor. Por eso no se consideran datos personales y se conservan indefinidamente: sirven para reconocer el mismo contenido si vuelve a aparecer, sin que eso implique guardar información sobre ninguna persona. -## 4. Cuánto tiempo se conservan los datos +## 4. Archivado de canales + +El comando `%archivar` (uso exclusivo del equipo de moderación) genera una +copia del historial completo de un canal - incluyendo autor, ID de autor y +contenido de cada mensaje - y la publica como archivo adjunto en el canal +privado de moderación. Es una función que se usa **rara vez, típicamente +solo al cerrar/archivar un canal de forma permanente**, no como parte de +la moderación cotidiana descrita en la sección 2. + +Por su distinto propósito (dejar un registro histórico del canal cerrado, +no auditar actividad reciente), este archivo se trata de forma separada: + +- El bot **no conserva una copia propia**: el archivo se sube al canal de + moderación y se borra inmediatamente del servidor donde corre el bot. + La única copia que persiste es ese mensaje en el canal de moderación, + sujeto a los mismos controles de acceso que el resto de esa sección + (solo Coordinación) y a la propia retención de datos de Discord. +- No está sujeto al borrado automático de 30 días de la sección 5, ya + que su propósito es servir como referencia histórica a largo plazo, no + como registro operativo reciente. +- Una solicitud de eliminación (sección 6) no puede editar automáticamente + un archivo ya publicado en Discord. Si nos pides eliminar tus datos de + un canal ya archivado, el equipo de Coordinación evaluará el pedido caso + por caso (por ejemplo, editando o eliminando manualmente ese archivo). + +## 5. Cuánto tiempo se conservan los datos - **Registros con datos personales** (tabla de la sección 2): se eliminan automáticamente pasados **30 días** desde su creación. Esto ocurre todos los días de forma automática; no requiere intervención manual. - **Firmas de contenido/imágenes de spam** (sección 3): se conservan sin fecha de expiración, ya que no son datos personales. -- **Registro de solicitudes de eliminación** (sección 6): se conserva - indefinidamente como constancia de que una solicitud fue atendida, pero - solo contiene el ID de la persona afectada y un conteo de registros - eliminados — nunca el contenido eliminado. +- **Archivos de canales cerrados** (sección 4): se conservan sin fecha de + expiración fija, como registro histórico, únicamente en el canal de + moderación. +- **Registro de solicitudes de eliminación y de exportación** (sección 6): + se conservan indefinidamente como constancia de que una solicitud fue + atendida, pero solo contienen el ID de la persona afectada y un conteo + de registros — nunca el contenido eliminado o exportado. -## 5. Quién tiene acceso +## 6. Quién tiene acceso Los registros descritos en la sección 2 son visibles para el equipo de moderación ("Coordinación") del servidor, a través de los canales y comandos del bot. No se comparten con terceros ni se usan con fines -distintos a la moderación del servidor. +distintos a la moderación del servidor. LlamaBot no utiliza ningún +servicio externo (analítica, IA, hosting de terceros con acceso a los +datos, etc.) para procesar estos datos: corre en un único servidor +administrado por el equipo de Python en Español, y los registros se +guardan como archivos de texto sin cifrar en ese mismo servidor. El +acceso al servidor está restringido a las personas del equipo de +Coordinación que lo administran. -## 6. Tus derechos: acceso, rectificación y eliminación +## 7. Tus derechos: acceso, rectificación y eliminación Puedes solicitar en cualquier momento: -- **Saber qué datos tuyos están almacenados.** +- **Saber qué datos tuyos están almacenados** ("derecho de acceso"). - **Que se corrijan datos incorrectos.** - **Que se eliminen todos tus datos** ("derecho al olvido"). Para ejercer cualquiera de estos derechos, contacta con -**contacto@hablemospython.dev**. Una vez validada la solicitud, un miembro del -equipo de Coordinación puede ejecutar la eliminación mediante el comando -`%olvidar`, que: - -1. Muestra un resumen de cuántos registros se encontraron para tu cuenta. -2. Requiere una confirmación explícita antes de eliminar nada. -3. Elimina esos registros de todos los archivos de datos personales - descritos en la sección 2, de forma permanente e irreversible. -4. Dado que el bot identifica tus datos por tu ID de Discord (no por tu - nombre de usuario, que puede cambiar), la eliminación cubre todos los - registros asociados a tu cuenta, incluso si tu nombre de usuario fue - distinto en el pasado. - -Ten en cuenta que esto **no elimina tu historial dentro de Discord como -plataforma** (mensajes, roles, sanciones aplicadas directamente por +**contacto@hablemospython.dev**. Una vez validada la solicitud, un miembro +del equipo de Coordinación puede: + +- **Exportar tus datos** mediante el comando `%exportar`, que genera un + archivo `.zip` con todas tus filas en los registros de datos personales + descritos en la sección 2, y lo entrega de forma privada a quien hizo la + solicitud. +- **Eliminar tus datos** mediante el comando `%olvidar`, que: + 1. Muestra un resumen de cuántos registros se encontraron para tu cuenta. + 2. Requiere una confirmación explícita antes de eliminar nada. + 3. Elimina esos registros de todos los archivos de datos personales + descritos en la sección 2, de forma permanente e irreversible. + 4. Dado que el bot identifica tus datos por tu ID de Discord (no por tu + nombre de usuario, que puede cambiar), la eliminación cubre todos los + registros asociados a tu cuenta, incluso si tu nombre de usuario fue + distinto en el pasado. + +Ten en cuenta que ninguno de los dos comandos alcanza a los archivos de +canales cerrados (sección 4) ni elimina tu historial dentro de Discord +como plataforma (mensajes, roles, sanciones aplicadas directamente por Discord, etc.) — solo lo que este bot almacena por su cuenta. -## 7. Cambios a este documento +## 8. Menores de edad + +LlamaBot no está dirigido a, ni recopila datos intencionalmente de, +personas menores de 13 años, en línea con los [Términos de Servicio de +Discord](https://discord.com/terms). Si tomamos conocimiento de que se +almacenaron datos de una persona menor de 13 años, los eliminaremos de +forma inmediata al ser notificados, usando el mismo mecanismo de la +sección 7. + +## 9. Cambios a este documento Este documento puede actualizarse si cambia la forma en que el bot maneja -los datos. Se recomienda revisarlo periódicamente. +los datos. La fecha de "Última actualización" al inicio refleja la +versión vigente; se recomienda revisarlo periódicamente. Cambios +relevantes serán anunciados en el servidor de Python en Español. diff --git a/bot.py b/bot.py index 5364b34..60fe759 100644 --- a/bot.py +++ b/bot.py @@ -17,11 +17,12 @@ from comandos.archivar import Archivar from comandos.enviar import Enviar from comandos.retencion import Retencion +from comandos.exportar import Exportar # Every cog to register on startup. Add a class here (and to the imports # above) to wire up a new command/listener group - nothing else needs to # change. -COGS = (Ping, Ayuda, Limpia, Archivar, Moderacion, FloodSpam, Enviar, Retencion) +COGS = (Ping, Ayuda, Limpia, Archivar, Moderacion, FloodSpam, Enviar, Retencion, Exportar) # Global instance of the server guild = None diff --git a/comandos/archivar.py b/comandos/archivar.py index 028f609..ee8a2e6 100644 --- a/comandos/archivar.py +++ b/comandos/archivar.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from pathlib import Path from typing import List, Optional import discord @@ -40,6 +41,13 @@ async def archivar(self, ctx, *, channel: discord.TextChannel) -> Optional[disco colour=colors.ARCHIVE, ) await self.mod_channel.send(embed=e, file=discord.File(archived_filename)) + # The message just sent to the mod channel is the durable copy + # (per TERMS.md, channel archives are preserved there as an + # institutional record, not by this bot) - delete the local + # temp file now instead of leaving a personal-data CSV sitting + # unmanaged on the host's filesystem outside the retention + # policy in comandos/retencion.py. + Path(archived_filename).unlink(missing_ok=True) else: await self.mod_channel.send(f"Error: Canal '{channel.name}' no fue archivado.") diff --git a/comandos/ayuda.py b/comandos/ayuda.py index 607ac6e..4339974 100644 --- a/comandos/ayuda.py +++ b/comandos/ayuda.py @@ -19,6 +19,16 @@ async def mensaje_ayuda(self, ctx): await ctx.channel.send(embed=self.get_mod_help()) + @commands.command( + name="terminos", + help="Muestra el enlace a los Términos de Uso y Política de Datos del bot", + ) + async def terminos(self, ctx): + await ctx.channel.send( + "\N{PAGE FACING UP} Términos de Uso y Política de Datos de LlamaBot: " + f"{config.TERMS_URL}" + ) + def get_mod_help(self): embed = discord.Embed( title="Comandos Disponibles", @@ -52,4 +62,9 @@ def get_mod_help(self): value="Limpia N mensajes del canal de moderación", inline=False, ) + embed.add_field( + name="`%terminos`", + value="Muestra el enlace a los Términos de Uso y Política de Datos del bot", + inline=False, + ) return embed diff --git a/comandos/exportar.py b/comandos/exportar.py new file mode 100644 index 0000000..57bef95 --- /dev/null +++ b/comandos/exportar.py @@ -0,0 +1,92 @@ +"""Right-of-access / data-export requests (GDPR), the read-only counterpart +to ``comandos/retencion.py``'s right-to-erasure (``%olvidar``). + +``%exportar`` collects every row belonging to a user across the same +``PERSONAL_DATA_LOGS`` covered by retention/erasure into an in-memory .zip +(one CSV per log) and posts it to the moderation channel, for a moderator +to hand over to whoever requested their data - it never messages the user +directly, since only the moderation team can confirm a request is +legitimate in the first place. +""" +import csv +import io +import logging +import zipfile +from datetime import datetime + +import discord +from discord.ext import commands + +from configuration import Config +from comandos.retencion import PERSONAL_DATA_LOGS, rows_for_author + +config = Config() +logger = logging.getLogger(__name__) + + +def build_export_zip(user_id) -> tuple: + """Collect every row belonging to ``user_id`` from the personal-data + logs into an in-memory zip, one CSV per log. + + Read-only w.r.t. the log files themselves, and nothing is written to + disk - the zip only ever exists in memory before being attached to a + Discord message. + """ + buffer = io.BytesIO() + counts = {} + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for attr in PERSONAL_DATA_LOGS: + path = getattr(config, attr) + rows = rows_for_author(path, user_id) + counts[attr] = len(rows) + if not rows: + continue + out = io.StringIO() + writer = csv.DictWriter(out, fieldnames=rows[0].keys(), delimiter=";") + writer.writeheader() + writer.writerows(rows) + zf.writestr(f"{attr}.csv", out.getvalue()) + buffer.seek(0) + return buffer, counts + + +class Exportar(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command( + name="exportar", + help="Genera un .zip con todos los datos almacenados de un usuario (derecho de acceso / GDPR)", + ) + @commands.has_role(config.MOD_ROLE) + async def exportar_usuario(self, ctx, user: discord.User): + buffer, counts = build_export_zip(user.id) + total = sum(counts.values()) + + if total == 0: + await ctx.send(f"No se encontraron datos almacenados para {user.mention} (`{user.id}`).") + return + + self._log_access(user.id, requested_by=ctx.author, total_exported=total) + + detail = "\n".join(f"- `{name}`: {n}" for name, n in counts.items()) + filename = f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}_export_{user.id}.zip" + await ctx.send( + content=( + f"\N{OPEN MAILBOX WITH RAISED FLAG} Exportación de datos de {user.mention} " + f"(`{user.id}`): **{total}** registro(s) en total.\n{detail}\n\n" + "Este archivo contiene datos personales: entrégalo únicamente a la persona " + "que lo solicitó, por un canal privado, y bórralo de tu equipo después. " + f"Más información en los términos del bot: {config.TERMS_URL}" + ), + file=discord.File(buffer, filename=filename), + ) + + def _log_access(self, user_id, requested_by, total_exported): + """Audit trail for access/export requests - mirrors + Retencion._log_erasure: only the id, requester and a count, never + the exported content itself, so it's safe to keep indefinitely.""" + with open(config.log_gdpr_access_file, "a", newline="") as f: + csv.writer(f, delimiter=";").writerow([ + f"{datetime.now()}", user_id, f"{requested_by}", requested_by.id, total_exported, + ]) diff --git a/comandos/retencion.py b/comandos/retencion.py index 8fb2794..a2e44ab 100644 --- a/comandos/retencion.py +++ b/comandos/retencion.py @@ -7,6 +7,10 @@ every stored row belonging to a specific user on request, with an explicit confirmation step first. +The read-only counterpart to erasure - the right of access - lives in +``comandos/exportar.py`` (``%exportar``), built on top of the +``rows_for_author()`` helper defined here. + ``log_spam_file``/``log_image_spam_file`` (the known-spam text/image-hash caches) are intentionally excluded from both: they store only message content or image hashes, never an author, so they aren't personal data to @@ -101,6 +105,16 @@ def count_rows_for_author(path, author_id) -> int: return sum(1 for row in reader if row.get("author_id") == author_id) +def rows_for_author(path, author_id) -> list: + """Read-only: every row belonging to ``author_id``, without modifying + anything. Used for right-of-access/export requests (comandos/exportar.py), + the read counterpart to remove_rows_for_author()'s right-to-erasure.""" + author_id = str(author_id) + with open(path, newline="") as f: + reader = csv.DictReader(f, delimiter=";") + return [row for row in reader if row.get("author_id") == author_id] + + class ConfirmErasureView(discord.ui.View): def __init__(self, cog: "Retencion", target: discord.abc.User): super().__init__(timeout=60) diff --git a/config.toml.example b/config.toml.example index 7a732e4..0c7c86d 100644 --- a/config.toml.example +++ b/config.toml.example @@ -12,6 +12,12 @@ muted_role="..." [server] guild= +[links] +# Public URL to the bot's terms of use / data policy (TERMS.md), shown by +# the %terminos command. Also set this same URL as the bot's privacy +# policy link in the Discord Developer Portal. +terms_url="https://raw.githubusercontent.com/python-discord-es/bot/main/TERMS.md" + [channels] [channels.eventos] main= diff --git a/configuration.py b/configuration.py index 5f73a0c..bdb8650 100644 --- a/configuration.py +++ b/configuration.py @@ -58,6 +58,15 @@ def __init__(self): ) sys.exit(-1) + # Public URL to TERMS.md (the bot's terms of use / data policy), + # surfaced via the %terminos command and (separately, outside this + # repo) the Discord Developer Portal's privacy-policy field. + # Optional/`.get()`-based on purpose: older configs without a + # [links] section shouldn't fail to start over this. + self.TERMS_URL = config.get("links", {}).get( + "terms_url", "https://raw.githubusercontent.com/python-discord-es/bot/main/TERMS.md" + ) + self.setup_log_files() def setup_log_files(self): @@ -81,6 +90,11 @@ def setup_log_files(self): # a request was honored. self.log_gdpr_file = Path("logs/gdpr_erasure_log.csv") + # Audit trail for right-of-access/export requests (comandos/exportar.py). + # Same shape/rationale as log_gdpr_file above: id, requester and a + # count only, never the exported content itself. + self.log_gdpr_access_file = Path("logs/gdpr_access_log.csv") + # Checking files self.check_create_file( self.log_file, "date;command;message_id;channel;author_id;author;message\n" @@ -106,6 +120,10 @@ def setup_log_files(self): self.log_gdpr_file, "date;user_id;requested_by;requested_by_id;total_removed\n", ) + self.check_create_file( + self.log_gdpr_access_file, + "date;user_id;requested_by;requested_by_id;total_exported\n", + ) def get_spam_messages(self): # Adding spam messages diff --git a/conftest.py b/conftest.py index 9cf0741..8b277a6 100644 --- a/conftest.py +++ b/conftest.py @@ -98,6 +98,7 @@ def isolated_logs(config, tmp_path, monkeypatch): ("log_main_file", "main_log.csv"), ("log_file", "bot_log.csv"), ("log_gdpr_file", "gdpr_erasure_log.csv"), + ("log_gdpr_access_file", "gdpr_access_log.csv"), ]: path = logs_dir / filename path.write_text("\n") diff --git a/tests/test_archivar.py b/tests/test_archivar.py index 8c7f31e..ccf3759 100644 --- a/tests/test_archivar.py +++ b/tests/test_archivar.py @@ -68,6 +68,10 @@ async def test_sends_success_embed_with_the_file(self, tmp_path, monkeypatch): mod_channel.send.assert_awaited_once() _, kwargs = mod_channel.send.call_args assert "2 mensajes" in kwargs["embed"].description + # The mod-channel message just sent is the durable copy (see + # TERMS.md section 4) - the local temp file shouldn't linger on + # disk outside the retention policy in comandos/retencion.py. + assert list(tmp_path.glob("*.csv")) == [] async def test_sends_error_embed_when_archiving_fails(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) diff --git a/tests/test_ayuda.py b/tests/test_ayuda.py index 624bfd4..95534e4 100644 --- a/tests/test_ayuda.py +++ b/tests/test_ayuda.py @@ -34,3 +34,16 @@ async def test_sends_mod_help(self): assert kwargs["embed"].title == "Comandos Disponibles" names = [f.name for f in kwargs["embed"].fields] assert "`%mod`" in names + assert "`%terminos`" in names + + +class TestTerminos: + async def test_sends_the_terms_url(self, config): + cog = bind_commands(Ayuda(make_bot())) + ctx = make_ctx() + + await cog.terminos(ctx) + + ctx.channel.send.assert_awaited_once() + (msg,), _ = ctx.channel.send.call_args + assert config.TERMS_URL in msg diff --git a/tests/test_exportar.py b/tests/test_exportar.py new file mode 100644 index 0000000..f8a7d7d --- /dev/null +++ b/tests/test_exportar.py @@ -0,0 +1,87 @@ +import csv +import zipfile +from datetime import datetime, timedelta + +from comandos.exportar import Exportar, build_export_zip +from tests.factories import bind_commands, make_bot, make_ctx, make_member, read_last_csv_row + +RECENT = str(datetime.now() - timedelta(days=1)) + + +def write_csv(path, header, rows): + with open(path, "w", newline="") as f: + writer = csv.writer(f, delimiter=";") + writer.writerow(header) + writer.writerows(rows) + + +# --------------------------------------------------------------------------- +# build_export_zip +# --------------------------------------------------------------------------- +class TestBuildExportZip: + def test_collects_matching_rows_from_every_personal_data_log(self, isolated_logs): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], [RECENT, "99", "2"], + ]) + write_csv(isolated_logs.log_mod_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "10"], + ]) + + buffer, counts = build_export_zip(42) + + assert counts["log_main_file"] == 1 + assert counts["log_mod_file"] == 1 + assert counts["log_accepted_file"] == 0 + assert counts["log_rejected_file"] == 0 + + with zipfile.ZipFile(buffer) as zf: + names = zf.namelist() + assert "log_main_file.csv" in names + assert "log_mod_file.csv" in names + assert "log_accepted_file.csv" not in names # nothing to write - skipped + rows = list(csv.DictReader(zf.read("log_main_file.csv").decode().splitlines(), delimiter=";")) + assert [r["message_id"] for r in rows] == ["1"] + + def test_no_data_returns_all_zero_counts(self, isolated_logs): + buffer, counts = build_export_zip(404) + + assert sum(counts.values()) == 0 + with zipfile.ZipFile(buffer) as zf: + assert zf.namelist() == [] + + +# --------------------------------------------------------------------------- +# %exportar command +# --------------------------------------------------------------------------- +class TestExportarUsuario: + async def test_no_data_found_sends_plain_message(self, isolated_logs): + cog = bind_commands(Exportar(make_bot())) + ctx = make_ctx() + target = make_member(name="nadie", id=404) + + await cog.exportar_usuario(ctx, target) + + ctx.send.assert_awaited_once() + (msg,), kwargs = ctx.send.call_args + assert "No se encontraron datos" in msg + assert "file" not in kwargs + + async def test_data_found_sends_zip_and_logs_the_access(self, isolated_logs): + write_csv(isolated_logs.log_main_file, ["date", "author_id", "message_id"], [ + [RECENT, "42", "1"], + ]) + cog = bind_commands(Exportar(make_bot())) + ctx = make_ctx(author=make_member(name="mod1", id=1)) + target = make_member(name="alguien", id=42) + + await cog.exportar_usuario(ctx, target) + + ctx.send.assert_awaited_once() + _, kwargs = ctx.send.call_args + assert "1" in kwargs["content"] + assert kwargs["file"].filename.endswith(".zip") + + access_row = read_last_csv_row(isolated_logs.log_gdpr_access_file) + assert access_row[1] == "42" # user_id + assert access_row[2] == "mod1" # requested_by + assert access_row[4] == "1" # total_exported diff --git a/tests/test_retencion.py b/tests/test_retencion.py index ec54a6e..1805251 100644 --- a/tests/test_retencion.py +++ b/tests/test_retencion.py @@ -8,6 +8,7 @@ count_rows_for_author, prune_old_rows, remove_rows_for_author, + rows_for_author, ) from tests.factories import ( bind_commands, @@ -110,6 +111,30 @@ def test_count_matches_without_mutating(self, tmp_path): assert len(read_csv(path)) == 2 # unchanged +# --------------------------------------------------------------------------- +# rows_for_author +# --------------------------------------------------------------------------- +class TestRowsForAuthor: + def test_returns_only_matching_rows_without_mutating(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "author_id", "message_id"], [ + [RECENT, "10", "1"], + [RECENT, "20", "2"], + [RECENT, "10", "3"], + ]) + + rows = rows_for_author(path, 10) + + assert [r["message_id"] for r in rows] == ["1", "3"] + assert len(read_csv(path)) == 3 # unchanged + + def test_no_match_returns_empty_list(self, tmp_path): + path = tmp_path / "data.csv" + write_csv(path, ["date", "author_id", "message_id"], [[RECENT, "10", "1"]]) + + assert rows_for_author(path, 999) == [] + + # --------------------------------------------------------------------------- # Retencion.run_prune_once # ---------------------------------------------------------------------------