From 5f7954e6e0e44fc64a5804c68ea19514813c6a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20Maureira-Fredes?= Date: Sat, 15 Aug 2026 18:58:46 +0200 Subject: [PATCH] Replace pandas-backed data_mod with a plain dict bot.data_mod was a DataFrame used purely as a keyed collection (mask by message_id, concat to append, iterrows to list) - no aggregation or numeric computation anywhere, evidenced by mod_row["x"].values[0] unwrapping scattered through moderacion.py. Replaced with a plain dict[message_id, dict], loaded via a new utils.read_csv_dicts() (csv.DictReader) instead of pd.read_csv. - bot.py: builds bot.data_mod as {message_id: row} directly, filtering out already-accepted/rejected ids via set operations instead of DataFrame masking. - moderacion.py: ValidatedPost.mod_row is now a plain dict (dropped the now-unneeded condition: pd.Series field); lookups are data_mod.get(post_id), removal is del data_mod[post_id], get_mod_pending iterates data.values(). - pandas dropped from requirements.txt and every import. --- bot.py | 20 +++++++++------ comandos/moderacion.py | 54 ++++++++++++++++++---------------------- requirements.txt | 1 - tests/conftest.py | 5 +--- tests/test_moderacion.py | 19 +++++++------- tests/test_utils.py | 30 +++++++++++++++++++++- utils.py | 8 ++++++ 7 files changed, 83 insertions(+), 54 deletions(-) diff --git a/bot.py b/bot.py index e9be85b..a992ed6 100644 --- a/bot.py +++ b/bot.py @@ -1,12 +1,12 @@ import asyncio import csv -import pandas as pd import discord import logging from discord.ext import commands from datetime import datetime from configuration import Config +from utils import read_csv_dicts # Add cogs from comandos.ping import Ping @@ -71,16 +71,20 @@ async def on_command_error(msg, error): async def main(): # Reading data - data_mod = pd.read_csv(str(config.log_mod_file), sep=";", dtype=str) - data_accepted = pd.read_csv(str(config.log_accepted_file), sep=";", dtype=str) - data_rejected = pd.read_csv(str(config.log_rejected_file), sep=";", dtype=str) + data_mod = read_csv_dicts(config.log_mod_file) + data_accepted = read_csv_dicts(config.log_accepted_file) + data_rejected = read_csv_dicts(config.log_rejected_file) # Pending moderation # Get 'message_id' from the 'accepted' and 'rejected' files - ready_ids = set(data_accepted["message_id"]).union(data_rejected["message_id"]) - - # keeping the data in the bot instance - bot.data_mod = data_mod[~data_mod["message_id"].isin(ready_ids)] # type: ignore[attr-defined] + ready_ids = {row["message_id"] for row in data_accepted} | { + row["message_id"] for row in data_rejected + } + + # keeping the data in the bot instance, keyed by message_id + bot.data_mod = { # type: ignore[attr-defined] + row["message_id"]: row for row in data_mod if row["message_id"] not in ready_ids + } for cog_cls in COGS: await bot.add_cog(cog_cls(bot)) diff --git a/comandos/moderacion.py b/comandos/moderacion.py index 2f67be6..ba0f32b 100644 --- a/comandos/moderacion.py +++ b/comandos/moderacion.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Optional -import pandas as pd import discord from discord.ext import commands @@ -43,13 +42,12 @@ def _decode_message(stored: str) -> str: @dataclass class ValidatedPost: post_id: str - mod_row: pd.DataFrame + mod_row: dict ch_main: discord.TextChannel ch_mod: discord.TextChannel ch_sub: discord.TextChannel message_dec: str author: discord.User - condition: pd.Series class RejectModal(discord.ui.Modal, title="Rechazar Mensaje"): @@ -154,20 +152,18 @@ async def _get_validated_post( if post_id is None: return None - if post_id not in set(self.bot.data_mod["message_id"]): + mod_row = self.bot.data_mod.get(post_id) + if mod_row is None: await channel_mod.send(f"El ID {post_id} no fue encontrado") return None - condition = self.bot.data_mod["message_id"] == post_id - mod_row = self.bot.data_mod[condition] - channel_id = config.CHANNELS[ - mod_row["channel"].values[0].replace("envio-", "") + mod_row["channel"].replace("envio-", "") ]["submission"] ch_main, ch_mod, ch_sub = self.get_channels_main_mod_sub(channel_id) - message_dec = _decode_message(mod_row["message"].values[0]) - author = self.bot.get_user(int(mod_row["author_id"].values[0])) + message_dec = _decode_message(mod_row["message"]) + author = self.bot.get_user(int(mod_row["author_id"])) return ValidatedPost( post_id=post_id, @@ -177,7 +173,6 @@ async def _get_validated_post( ch_sub=ch_sub, message_dec=message_dec, author=author, - condition=condition, ) def _log_action(self, action: str, row, post_id, moderator, reason: str = ""): @@ -187,9 +182,8 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""): Uses csv.writer (not hand-built quoting) so a channel/author name or message containing a literal '"' or newline doesn't silently corrupt - the row - these files are re-parsed with pd.read_csv on every bot - startup, so a malformed row there can break loading the pending - queue. + the row - these files are re-parsed on every bot startup, so a + malformed row there can break loading the pending queue. """ filename = ( config.log_accepted_file if action == "aceptar" else config.log_rejected_file @@ -198,10 +192,10 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""): fields = [ date_str, post_id, - row["channel"].values[0], - row["author_id"].values[0], - row["author"].values[0], - row["message"].values[0], + row["channel"], + row["author_id"], + row["author"], + row["message"], moderator, ] # log_rejected_file's header always has a "reason" column - include @@ -215,15 +209,16 @@ def _log_action(self, action: str, row, post_id, moderator, reason: str = ""): def log_on_message(self, channel_sub, author): date_str = f"{datetime.now()}" + message_id = f"{self._msg_id}" new_data = { "date": date_str, - "message_id": f"{self._msg_id}", + "message_id": message_id, "channel": f"{channel_sub}", "author_id": f"{author.id}", "author": f"{author}", "message": f"{self._msg_enc}", } - self.bot.data_mod = pd.concat([self.bot.data_mod, pd.DataFrame([new_data])]) + self.bot.data_mod[message_id] = new_data with open(str(config.log_mod_file), "a", newline="") as f: csv.writer(f, delimiter=";").writerow([ @@ -279,7 +274,7 @@ async def _aceptar_mensaje(self, ctx, message_id: Optional[int] = None): moderator = self._resolve_author(ctx) self._log_action("aceptar", vp.mod_row, vp.post_id, moderator) - self.bot.data_mod = self.bot.data_mod[~vp.condition] + del self.bot.data_mod[vp.post_id] # Send to the destination channel first so the confirmation below can # link to the message that was actually posted there, instead of @@ -311,7 +306,7 @@ async def _rechazar_mensaje( moderator = self._resolve_author(ctx) self._log_action("rechazar", vp.mod_row, vp.post_id, moderator, reason or "") - self.bot.data_mod = self.bot.data_mod[~vp.condition] + del self.bot.data_mod[vp.post_id] embed = discord.Embed( title="Mensaje rechazado", @@ -342,7 +337,7 @@ def get_mod_pending(self, data): title="Mensajes pendientes de moderación", colour=colors.BRAND, ) - for idx, mod_row in data.iterrows(): + for mod_row in data.values(): author = self.bot.get_user(int(mod_row["author_id"])) if not author: logger.warning("El author '%s' ya no existe en el server.", mod_row["author_id"]) @@ -376,20 +371,19 @@ async def mostrar_mensajes(self, ctx): if post_id is None: return - if post_id not in self.bot.data_mod["message_id"].to_list(): + mod_row = self.bot.data_mod.get(post_id) + if mod_row is None: await channel_mod.send(f"ID no encontrado: {post_id}") return - condition = self.bot.data_mod["message_id"] == post_id - mod_row = self.bot.data_mod[condition] - author = self.bot.get_user(int(mod_row["author_id"].values[0])) - m_message = _decode_message(mod_row["message"].values[0]) + author = self.bot.get_user(int(mod_row["author_id"])) + m_message = _decode_message(mod_row["message"]) embed = discord.Embed( title="Mensaje pendiente de moderación", description=( - f"Post de {author.mention} el {mod_row['date'].values[0]}\n" - f"**ID:** {mod_row['message_id'].values[0]}\n" + f"Post de {author.mention} el {mod_row['date']}\n" + f"**ID:** {mod_row['message_id']}\n" f"**Mensaje:**\n```\n{m_message}\n```\n" ), colour=colors.BRAND, diff --git a/requirements.txt b/requirements.txt index b49ebb5..0c1df6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ discord.py -pandas toml Pillow diff --git a/tests/conftest.py b/tests/conftest.py index 8b07dba..e1b645c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import pandas as pd import pytest from comandos.flood import FloodSpam @@ -51,9 +50,7 @@ async def moderacion_cog(isolated_logs, moderacion_channels): channels = {c.id: c for c in moderacion_channels.values()} bot = make_bot(channels=channels, guild=SimpleNamespace(id=333333333333333333)) - bot.data_mod = pd.DataFrame( - columns=["date", "message_id", "channel", "author_id", "author", "message"] - ) + bot.data_mod = {} # message_id -> row dict, same shape as read_csv_dicts() rows cog = Moderacion(bot) await cog.on_ready() return cog diff --git a/tests/test_moderacion.py b/tests/test_moderacion.py index 5ec9411..27e16e5 100644 --- a/tests/test_moderacion.py +++ b/tests/test_moderacion.py @@ -2,7 +2,6 @@ from types import SimpleNamespace from unittest.mock import AsyncMock -import pandas as pd import pytest from comandos.moderacion import _decode_message, _encode_message @@ -26,7 +25,7 @@ def add_pending_row(cog, post_id, *, channel="envio-eventos", author_id=42, auth "author": author_name, "message": encode(content), } - cog.bot.data_mod = pd.concat([cog.bot.data_mod, pd.DataFrame([new_row])], ignore_index=True) + cog.bot.data_mod[str(post_id)] = new_row return new_row @@ -185,7 +184,7 @@ async def test_bot_author_returns_none(self, moderacion_cog, moderacion_channels # --------------------------------------------------------------------------- class TestLogAction: def test_aceptar_writes_expected_line(self, moderacion_cog, isolated_logs): - row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=1)]) + row = add_pending_row(moderacion_cog, post_id=1) moderacion_cog._log_action("aceptar", row, "1", "moderador#0") @@ -195,7 +194,7 @@ def test_aceptar_writes_expected_line(self, moderacion_cog, isolated_logs): assert len(fields) == 7 # no "reason" column for aceptar def test_rechazar_includes_reason(self, moderacion_cog, isolated_logs): - row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=2)]) + row = add_pending_row(moderacion_cog, post_id=2) moderacion_cog._log_action("rechazar", row, "2", "moderador#0", "le falta info") @@ -207,10 +206,10 @@ def test_rechazar_without_reason_still_writes_the_reason_column( ): """Regression test: an empty reason used to skip the "reason" field entirely (``if reason: line += ...``), leaving that row one column - short of log_rejected_file's fixed 8-column header - which - pd.read_csv (run on every bot startup) can choke on. + short of log_rejected_file's fixed 8-column header - which the CSV + reader (run on every bot startup) can choke on. """ - row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=3)]) + row = add_pending_row(moderacion_cog, post_id=3) moderacion_cog._log_action("rechazar", row, "3", "moderador#0", "") @@ -224,7 +223,7 @@ def test_embedded_quotes_and_delimiters_round_trip(self, moderacion_cog, isolate csv.writer round-trips this correctly. """ tricky_name = 'mod "raro"; con punto y coma' - row = pd.DataFrame([add_pending_row(moderacion_cog, post_id=4, author_name=tricky_name)]) + row = add_pending_row(moderacion_cog, post_id=4, author_name=tricky_name) moderacion_cog._log_action("aceptar", row, "4", tricky_name) @@ -296,7 +295,7 @@ async def test_removes_pending_row_and_notifies_channels( await moderacion_cog._aceptar_mensaje(ctx) - assert moderacion_cog.bot.data_mod.empty + assert moderacion_cog.bot.data_mod == {} moderacion_channels["main"].send.assert_awaited_once() (msg,), _ = moderacion_channels["main"].send.call_args assert "contenido aprobado" in msg @@ -332,7 +331,7 @@ async def test_removes_pending_row_and_notifies_with_reason( await moderacion_cog._rechazar_mensaje(ctx) - assert moderacion_cog.bot.data_mod.empty + assert moderacion_cog.bot.data_mod == {} moderacion_channels["sub"].send.assert_awaited_once() _, kwargs = moderacion_channels["sub"].send.call_args assert "le falta info aqui" in kwargs["embed"].fields[0].value diff --git a/tests/test_utils.py b/tests/test_utils.py index dbd772d..d631a3e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ from tests.factories import make_message -from utils import get_message_to_moderate, strip_message +from utils import get_message_to_moderate, read_csv_dicts, strip_message class TestStripMessage: @@ -25,6 +25,34 @@ def test_empty_string(self): assert strip_message("") == "" +class TestReadCsvDicts: + def test_reads_rows_as_dicts(self, tmp_path): + path = tmp_path / "data.csv" + path.write_text("date;message_id;author\n2026-01-01;1;alice\n2026-01-02;2;bob\n") + + rows = read_csv_dicts(path) + + assert rows == [ + {"date": "2026-01-01", "message_id": "1", "author": "alice"}, + {"date": "2026-01-02", "message_id": "2", "author": "bob"}, + ] + + def test_handles_embedded_quotes_delimiters_and_newlines(self, tmp_path): + path = tmp_path / "data.csv" + path.write_text('date;author;message\n2026-01-01;"mod ""raro""; con coma";"linea uno\nlinea dos"\n') + + rows = read_csv_dicts(path) + + assert rows[0]["author"] == 'mod "raro"; con coma' + assert rows[0]["message"] == "linea uno\nlinea dos" + + def test_only_header_returns_empty_list(self, tmp_path): + path = tmp_path / "data.csv" + path.write_text("date;message_id;author\n") + + assert read_csv_dicts(path) == [] + + class TestGetMessageToModerate: def test_embed_contains_message_and_commands(self): message = make_message(content="hola, este es mi post") diff --git a/utils.py b/utils.py index 18cf793..a9c3a1a 100644 --- a/utils.py +++ b/utils.py @@ -1,3 +1,4 @@ +import csv import re import discord from datetime import datetime, timezone @@ -12,6 +13,13 @@ rechazar_emoji = "\N{CROSS MARK}" +def read_csv_dicts(path, delimiter=";"): + """Read a semicolon-delimited CSV file (as written by csv.writer + elsewhere in this project) into a list of {column: value} dicts.""" + with open(path, newline="") as f: + return list(csv.DictReader(f, delimiter=delimiter)) + + def get_message_to_moderate(message): msg = ( f"{datetime.now(timezone.utc).replace(tzinfo=None)} UTC\n"