From 8f0f0faa6be5bf5fdc2e440d7c7bccf9f53895f5 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:26:20 +0200 Subject: [PATCH 01/13] clean up the API <-> db helper --- src/api/core/db_helper.py | 604 +++++++++++++++++--------------------- 1 file changed, 276 insertions(+), 328 deletions(-) diff --git a/src/api/core/db_helper.py b/src/api/core/db_helper.py index 19b72a2..b91b23d 100644 --- a/src/api/core/db_helper.py +++ b/src/api/core/db_helper.py @@ -1,22 +1,72 @@ import logging import os +from contextlib import contextmanager import psycopg -from psycopg import OperationalError +from psycopg import sql +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool logger = logging.getLogger(__name__) +# serversettings, logging and roles are structurally identical +# (id SERIAL, name VARCHAR, value VARCHAR), so they share one set of helpers. +SETTINGS_TABLE = "serversettings" +LOGGING_TABLE = "logging" +ROLES_TABLE = "roles" +_KEY_VALUE_TABLES = frozenset({SETTINGS_TABLE, LOGGING_TABLE, ROLES_TABLE}) + +# Seconds to wait for a pooled connection before giving up. +POOL_TIMEOUT = 5.0 + + +def _build_pool() -> ConnectionPool: + """ + Build the connection pool. + Let's go swimming! + """ + pool = ConnectionPool( + kwargs={ + "dbname": os.getenv("POSTGRES_DB"), + "user": os.getenv("POSTGRES_USER"), + "password": os.getenv("POSTGRES_PASSWORD"), + "host": os.getenv("POSTGRES_HOST"), + "port": os.getenv("POSTGRES_PORT", "5432"), + "autocommit": True, + "row_factory": dict_row, + }, + min_size=1, + max_size=4, + check=ConnectionPool.check_connection, + name="eos-api", + open=False, + ) + pool.open() + return pool + class DB: - def __init__(self): - self.conn = psycopg.connect( - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - host=os.getenv("POSTGRES_HOST"), - ) - self.conn.autocommit = True - self.cursor = self.conn.cursor() + """ + All database access for the API. + """ + + def __init__(self, pool: ConnectionPool | None = None): + self.pool = pool if pool is not None else _build_pool() + + @contextmanager + def _cursor(self): + with self.pool.connection(timeout=POOL_TIMEOUT) as conn, conn.cursor() as cur: + yield cur + + @staticmethod + def _error(action: str, err: Exception) -> dict: + logger.error("Error %s: %s", action, err) + return {"status": "error", "message": f"Database error while {action}"} + + @staticmethod + def _check_table(table: str) -> None: + if table not in _KEY_VALUE_TABLES: + raise ValueError(f"Unknown table: {table}") ################## ## Healthchecks ## @@ -24,370 +74,268 @@ def __init__(self): def database_health_check(self): logger.debug("API attempting to contact DB for healthcheck...") try: - self.cursor.execute("SELECT 1") - result = self.cursor.fetchone() - if result: - return {"status": "ok"} - - except OperationalError as err: - logger.critical(f"DB Healthcheck - 500 - {err}") - self.conn.close() - return {"status": "unhealthy", "error": {err}} - - ################## - ## logging ## - ################## - def get_log_setting(self, setting_id): - logger.debug("API attempting to contact DB for get_log_setting...") - try: - self.cursor.execute("SELECT * FROM logging where id = %s", (setting_id,)) - result = self.cursor.fetchone() - return {"status": "ok", "logging": result} - except OperationalError as err: - logger.error(f"Error fetching logging: {err}") - return {"status": "error", "message": str(err)} - - def get_log_settings(self): - logger.debug("API attempting to contact DB for get_log_settings...") - try: - self.cursor.execute("SELECT * FROM logging") # case sensitive - result = self.cursor.fetchall() - return {"status": "ok", "logging": result} - except OperationalError as err: - logger.error(f"Error fetching logging: {err}") - return {"status": "error", "message": str(err)} - - def get_logging(self): - logger.debug("API attempting to contact DB for get_logging...") + with self._cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + return {"status": "ok"} + except (psycopg.Error, OSError) as err: + logger.critical("DB healthcheck failed: %s", err) + return {"status": "unhealthy", "message": "Database unreachable"} + + ############################ + ## Shared id/name/value ## + ############################ + def _get_row(self, table: str, row_id, key: str) -> dict: + self._check_table(table) try: - self.cursor.execute("SELECT * FROM logging") - result = self.cursor.fetchall() - return {"status": "ok", "logging": result} - except OperationalError as err: - logger.error(f"Error fetching logging: {err}") - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + sql.SQL("SELECT id, name, value FROM {} WHERE id = %s").format( + sql.Identifier(table) + ), + (row_id,), + ) + row = cur.fetchone() + except psycopg.Error as err: + return self._error(f"fetching {table} row {row_id}", err) + + if row is None: + return { + "status": "not_found", + "message": f"No {table} row with ID {row_id}", + } + return {"status": "ok", key: row} - def update_logging(self, log_id, value): - logger.debug( - f"API attempting to contact DB for update_logging with log ID:{log_id} - Value:{value}" - ) + def _get_rows(self, table: str, key: str) -> dict: + self._check_table(table) try: - self.cursor.execute( - "UPDATE logging SET value = %s WHERE id = %s", (value, log_id) - ) - return {"status": "ok", "message": "Log setting updated successfully"} - except OperationalError as err: - logger.error(f"Error updating log setting: {err}") - return {"status": "error", "message": str(err)} - - def add_log_setting(self, name, value): - logger.debug( - f"API attempting to contact DB for add_log with name:{name} - Value:{value}" - ) + with self._cursor() as cur: + cur.execute( + sql.SQL("SELECT id, name, value FROM {} ORDER BY id").format( + sql.Identifier(table) + ) + ) + rows = cur.fetchall() + except psycopg.Error as err: + return self._error(f"fetching {table}", err) + + return {"status": "ok", key: rows} + + def _update_row(self, table: str, row_id, value) -> dict: + self._check_table(table) + logger.debug("Updating %s row %s to %s", table, row_id, value) try: - self.cursor.execute( - "INSERT INTO logging (name, value) VALUES (%s, %s)", (name, value) - ) - return {"status": "ok", "message": "New log setting added successfully"} - except OperationalError as err: - logger.error(f"Error adding new log setting: {err}") - return {"status": "error", "message": str(err)} - - def delete_log_setting(self, log_id): - logger.debug( - f"API attempting to contact DB for delete_log with log_ID:{log_id}" - ) - try: - self.cursor.execute("DELETE FROM logging WHERE id = %s", (log_id,)) + with self._cursor() as cur: + cur.execute( + sql.SQL("UPDATE {} SET value = %s WHERE id = %s").format( + sql.Identifier(table) + ), + (value, row_id), + ) + updated = cur.rowcount + except psycopg.Error as err: + return self._error(f"updating {table} row {row_id}", err) + + if not updated: return { - "status": "ok", - "message": f"Log with ID {log_id} deleted successfully", + "status": "not_found", + "message": f"No {table} row with ID {row_id}", } - except OperationalError as err: - logger.error(f"Error deleting log setting: {err}") - return {"status": "error", "message": str(err)} + return {"status": "ok", "message": f"{table} row {row_id} updated successfully"} - ################## - ## Settings ## - ################## + ############### + ## Logging ## + ############### + def get_log_setting(self, log_id): + return self._get_row(LOGGING_TABLE, log_id, "log_setting") + + def get_log_settings(self): + return self._get_rows(LOGGING_TABLE, "log_settings") + + def update_logging(self, log_id, value): + return self._update_row(LOGGING_TABLE, log_id, value) + + ################ + ## Settings ## + ################ def get_setting(self, setting_id): - logger.debug("API attempting to contact DB for get_setting...") - try: - self.cursor.execute( - "SELECT * FROM serversettings where id = %s", (setting_id,) - ) - result = self.cursor.fetchone() - return {"status": "ok", "setting": result} - except OperationalError as err: - logger.error(f"Error fetching logging: {err}") - return {"status": "error", "message": str(err)} + return self._get_row(SETTINGS_TABLE, setting_id, "setting") def get_settings(self): - logger.debug("API attempting to contact DB for get_setting...") - try: - self.cursor.execute("SELECT * FROM serversettings") # case sensitive - result = self.cursor.fetchall() - return {"status": "ok", "setting": result} - except OperationalError as err: - logger.error(f"Error fetching setting: {err}") - return {"status": "error", "message": str(err)} + return self._get_rows(SETTINGS_TABLE, "settings") def update_setting(self, setting_id, value): - logger.debug( - f"API attempting to contact DB for update_setting with setting ID:{setting_id} - Value:{value}" - ) - try: - self.cursor.execute( - "UPDATE serversettings SET value = %s WHERE id = %s", - (value, setting_id), - ) - return {"status": "ok", "message": "Setting updated successfully"} - except OperationalError as err: - logger.error(f"Error updating setting: {err}") - return {"status": "error", "message": str(err)} - - def add_setting(self, name, value): - logger.debug( - f"API attempting to contact DB for add_setting with name:{name} - Value:{value}" - ) - try: - self.cursor.execute( - "INSERT INTO serversettings (name, value) VALUES (%s, %s)", - (name, value), - ) - return {"status": "ok", "message": "New setting added successfully"} - except OperationalError as err: - logger.error(f"Error adding new setting: {err}") - return {"status": "error", "message": str(err)} - - def delete_setting(self, log_id): - logger.debug( - f"API attempting to contact DB for delete_setting with setting_ID:{log_id}" - ) - try: - self.cursor.execute("DELETE FROM serversettings WHERE id = %s", (log_id,)) - return { - "status": "ok", - "message": f"Setting with ID {log_id} deleted successfully", - } - except OperationalError as err: - logger.error(f"Error deleting setting: {err}") - return {"status": "error", "message": str(err)} + return self._update_row(SETTINGS_TABLE, setting_id, value) - ################## - ## roles ## - ################## + ############# + ## Roles ## + ############# def get_role(self, role_id): - logger.debug("API attempting to contact DB for get_role...") - try: - self.cursor.execute("SELECT * FROM roles where id = %s", (role_id,)) - result = self.cursor.fetchone() - return {"status": "ok", "roles": result} - except OperationalError as err: - logger.error(f"Error fetching roles: {err}") - return {"status": "error", "message": str(err)} + return self._get_row(ROLES_TABLE, role_id, "role") def get_roles(self): - logger.debug("API attempting to contact DB for get_roles...") - try: - self.cursor.execute("SELECT * FROM roles") - result = self.cursor.fetchall() - return {"status": "ok", "roles": result} - except OperationalError as err: - logger.error(f"Error fetching roles: {err}") - return {"status": "error", "message": str(err)} + return self._get_rows(ROLES_TABLE, "roles") def update_role(self, role_id, value): - logger.debug( - f"API attempting to contact DB for update_role with role ID:{role_id} - Value:{value}" - ) - try: - self.cursor.execute( - "UPDATE roles SET value = %s WHERE id = %s", (value, role_id) - ) - return {"status": "ok", "message": "role updated successfully"} - except OperationalError as err: - logger.error(f"Error updating role: {err}") - return {"status": "error", "message": str(err)} - - def add_role(self, name, value): - logger.debug( - f"API attempting to contact DB for add_role with name:{name} - Value:{value}" - ) - try: - self.cursor.execute( - "INSERT INTO roles (name, value) VALUES (%s, %s)", (name, value) - ) - return {"status": "ok", "message": "New role added successfully"} - except OperationalError as err: - logger.error(f"Error adding new role: {err}") - return {"status": "error", "message": str(err)} - - def delete_role(self, role_id): - logger.debug( - f"API attempting to contact DB for delete_role with role_ID:{role_id}" - ) - try: - self.cursor.execute("DELETE FROM roles WHERE id = %s", (role_id,)) - return { - "status": "ok", - "message": f"role with ID {role_id} deleted successfully", - } - except OperationalError as err: - logger.error(f"Error deleting role: {err}") - return {"status": "error", "message": str(err)} + return self._update_row(ROLES_TABLE, role_id, value) - ################## - ## Points ## - ################## + ############## + ## Points ## + ############## def get_points_for_user(self, user_id): try: - self.cursor.execute( - "SELECT points FROM users where discord_id =%s", (user_id,) - ) - result = self.cursor.fetchone() - if result is not None: - return {"status": "ok", "points": result} - else: - return {"status": "error", "points": result} - except OperationalError as err: - logger.error(f"Error fetching points: {err}") - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "SELECT points FROM users WHERE discord_id = %s", (user_id,) + ) + row = cur.fetchone() + except psycopg.Error as err: + return self._error(f"fetching points for user {user_id}", err) + + if row is None: + return {"status": "not_found", "message": f"No user with ID {user_id}"} + return {"status": "ok", "points": row["points"]} def get_monthly_points_for_user(self, user_id): try: - self.cursor.execute( - "SELECT monthly_points FROM users WHERE discord_id = %s", (user_id,) - ) - result = self.cursor.fetchone() - if result is not None: - return {"status": "ok", "monthly_points": result} - else: - return {"status": "error", "monthly_points": result} - except OperationalError as err: - logger.error(f"Error fetching monthly_points: {err}") - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "SELECT monthly_points FROM users WHERE discord_id = %s", (user_id,) + ) + row = cur.fetchone() + except psycopg.Error as err: + return self._error(f"fetching monthly points for user {user_id}", err) + + if row is None: + return {"status": "not_found", "message": f"No user with ID {user_id}"} + return {"status": "ok", "monthly_points": row["monthly_points"]} def update_points(self, user_id, value): try: - self.cursor.execute( - "UPDATE users SET points = points + %s, monthly_points = monthly_points + %s WHERE discord_id = %s", - (value, value, user_id), - ) - self.conn.commit() - return {"status": "ok", "message": "points updated successfully"} - except OperationalError as err: - logger.error(f"Error updating points: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "UPDATE users SET points = points + %s, " + "monthly_points = monthly_points + %s WHERE discord_id = %s", + (value, value, user_id), + ) + updated = cur.rowcount + except psycopg.Error as err: + return self._error(f"updating points for user {user_id}", err) + + if not updated: + return {"status": "not_found", "message": f"No user with ID {user_id}"} + return {"status": "ok", "message": "Points updated successfully"} def add_user_to_points(self, user_id): try: - self.cursor.execute( - "INSERT INTO users (discord_id, points, monthly_points) VALUES (%s, 0, 0) ON CONFLICT (discord_id) DO NOTHING;", - (user_id,), - ) - # self.conn.commit() - return { - "status": "ok", - "message": "New user added to 'points' successfully", - } - except OperationalError as err: - logger.error(f"Error adding new user: {err}") - # self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "INSERT INTO users (discord_id, points, monthly_points) " + "VALUES (%s, 0, 0) ON CONFLICT (discord_id) DO NOTHING", + (user_id,), + ) + except psycopg.Error as err: + return self._error(f"adding user {user_id}", err) + + return {"status": "ok", "message": f"User {user_id} added successfully"} def remove_user_from_points(self, user_id): try: - self.cursor.execute("DELETE FROM users WHERE discord_id = %s", (user_id,)) - affected_rows = self.cursor.rowcount - if affected_rows > 0: - self.conn.commit() - return { - "status": "ok", - "message": f"User with ID: {user_id} deleted successfully", - } - else: - return { - "status": "not_found", - "message": f"No user found with ID: {user_id}", - } - except OperationalError as err: - logger.error(f"Error deleting user: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute("DELETE FROM users WHERE discord_id = %s", (user_id,)) + deleted = cur.rowcount + except psycopg.Error as err: + return self._error(f"deleting user {user_id}", err) + + if not deleted: + return {"status": "not_found", "message": f"No user with ID {user_id}"} + return {"status": "ok", "message": f"User {user_id} deleted successfully"} def get_top_10(self): try: - self.cursor.execute( - "SELECT discord_id, points FROM users ORDER BY points DESC LIMIT 10" - ) - result = self.cursor.fetchall() - return {"status": "ok", "message": result} - except OperationalError as err: - logger.error(f"Error getting top10: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "SELECT discord_id, points FROM users ORDER BY points DESC LIMIT 10" + ) + rows = cur.fetchall() + except psycopg.Error as err: + return self._error("fetching the points leaderboard", err) - def get_monthly_top_point_earner(self): - try: - self.cursor.execute( - "SELECT discord_id, monthly_points FROM users ORDER BY monthly_points DESC LIMIT 1" - ) - result = self.cursor.fetchone() - return {"status": "ok", "message": result} - except OperationalError as err: - logger.error(f"Error getting monthly top: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + return {"status": "ok", "leaderboard": rows} def get_monthly_top_10(self): try: - self.cursor.execute( - "SELECT discord_id, monthly_points FROM users ORDER BY monthly_points DESC LIMIT 10" - ) - result = self.cursor.fetchall() - return {"status": "ok", "message": result} - except OperationalError as err: - logger.error(f"Error getting monthly top: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "SELECT discord_id, monthly_points FROM users " + "ORDER BY monthly_points DESC LIMIT 10" + ) + rows = cur.fetchall() + except psycopg.Error as err: + return self._error("fetching the monthly points leaderboard", err) + + return {"status": "ok", "leaderboard": rows} + + def get_monthly_top_point_earner(self): + try: + with self._cursor() as cur: + cur.execute( + "SELECT discord_id, monthly_points FROM users " + "ORDER BY monthly_points DESC LIMIT 1" + ) + row = cur.fetchone() + except psycopg.Error as err: + return self._error("fetching the monthly top point earner", err) + + if row is None: + return {"status": "not_found", "message": "No users with points recorded"} + return {"status": "ok", "top_earner": row} def reset_monthly_points(self): try: - self.cursor.execute("UPDATE users SET monthly_points = 0") - self.conn.commit() - return {"status": "ok", "message": "monthly_points successfully set to 0"} - except OperationalError as err: - logger.error(f"Error resetting monthly_points: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute("UPDATE users SET monthly_points = 0") + except psycopg.Error as err: + return self._error("resetting monthly points", err) - ################ - ## parameters ## - ################ + return {"status": "ok", "message": "Monthly points reset successfully"} + + ################## + ## Parameters ## + ################## def get_parameter(self, parameter_name): try: - self.cursor.execute( - "SELECT parameter_value FROM parameters WHERE parameter_name = %s", - (parameter_name,), - ) - result = self.cursor.fetchone() - return {"status": "ok", "message": result} - except OperationalError as err: - logger.error(f"Error getting parameter: {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "SELECT parameter_value FROM parameters WHERE parameter_name = %s", + (parameter_name,), + ) + row = cur.fetchone() + except psycopg.Error as err: + return self._error(f"fetching parameter {parameter_name}", err) + + if row is None: + return { + "status": "not_found", + "message": f"No parameter named {parameter_name}", + } + return {"status": "ok", "parameter": row["parameter_value"]} def set_parameter(self, parameter_name, parameter_value): try: - self.cursor.execute( - "UPDATE parameters SET parameter_value = %s WHERE parameter_name = %s", - (parameter_value, parameter_name), - ) - self.conn.commit() - return {"status": "ok", "message": "parameter set successfully"} - except OperationalError as err: - logger.error(f"Error setting parameter : {err}") - self.conn.rollback() - return {"status": "error", "message": str(err)} + with self._cursor() as cur: + cur.execute( + "UPDATE parameters SET parameter_value = %s " + "WHERE parameter_name = %s", + (parameter_value, parameter_name), + ) + updated = cur.rowcount + except psycopg.Error as err: + return self._error(f"setting parameter {parameter_name}", err) + + if not updated: + return { + "status": "not_found", + "message": f"No parameter named {parameter_name}", + } + return {"status": "ok", "message": f"Parameter {parameter_name} set"} From a518c8076ba4f2887dd90c81e99267c4d4151b59 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:33:16 +0200 Subject: [PATCH 02/13] API modifications and improvements --- src/api/api.py | 56 ++++++++--------- src/api/pyproject.toml | 1 + src/api/routes/_responses.py | 21 +++++++ src/api/routes/healthchecks.py | 20 ++---- src/api/routes/logging.py | 68 +++++--------------- src/api/routes/parameters.py | 38 +++++------- src/api/routes/points.py | 110 ++++++++------------------------- src/api/routes/roles.py | 54 ++++------------ src/api/routes/settings.py | 58 +++++------------ 9 files changed, 140 insertions(+), 286 deletions(-) create mode 100644 src/api/routes/_responses.py diff --git a/src/api/api.py b/src/api/api.py index 9d13a05..2920b3f 100644 --- a/src/api/api.py +++ b/src/api/api.py @@ -13,40 +13,40 @@ from werkzeug.exceptions import HTTPException logger = logging.getLogger(__name__) -setup_logger( - level=int(os.getenv("API_LOG_LEVEL", "20")), - stream_logs=os.getenv("STREAM_LOGS", "true").lower(), -) +TROOPHY = {"1", "true", "yes", "on"} -app = Flask(__name__) -app.db = DB() -# API routes -app.register_blueprint(health_checks) -app.register_blueprint(logs) -app.register_blueprint(settings) -app.register_blueprint(points) -app.register_blueprint(role) -app.register_blueprint(parameters) +def create_app(db=None) -> Flask: + """ + Build the Flask app. + :param db: data layer to use. Defaults to a real :class:`DB`; + """ + setup_logger( + level=int(os.getenv("API_LOG_LEVEL", "20")), + stream_logs=os.getenv("STREAM_LOGS", "true").strip().lower() in TROOPHY, + ) -# Error handlers -@app.errorhandler(Exception) -def handle_exception(e): - # Generic Application Errors - app.logger.error(f"Unhandled exception: {str(e)}") + app = Flask(__name__) + app.db = db if db is not None else DB() - # Return a JSON response with a generic error message - return jsonify({"error": "An unexpected error occurred", "details": str(e)}), 500 + for blueprint in (health_checks, logs, settings, points, role, parameters): + app.register_blueprint(blueprint) + @app.errorhandler(HTTPException) + def handle_http_exception(e): + logger.warning("HTTP exception: %s", e) + return jsonify( + {"status": "error", "message": e.description, "status_code": e.code} + ), e.code -@app.errorhandler(HTTPException) -def handle_http_exception(e): - # HTTP Exception Errors - app.logger.error(f"HTTP exception: {str(e)}") + @app.errorhandler(Exception) + def handle_exception(e): + # Anything unhandled. log dat exception. + logger.exception("Unhandled exception: %s", e) + return jsonify( + {"status": "error", "message": "An unexpected error occurred"} + ), 500 - # Return a JSON response with details about the HTTP error - return jsonify( - {"error": str(e), "status_code": e.code, "description": e.description} - ), e.code + return app diff --git a/src/api/pyproject.toml b/src/api/pyproject.toml index 0a112eb..e8d6a7c 100644 --- a/src/api/pyproject.toml +++ b/src/api/pyproject.toml @@ -7,4 +7,5 @@ dependencies = [ "flask==3.1.3", "gunicorn==26.0.0", "psycopg[binary]==3.3.4", + "psycopg-pool==3.2.6", ] diff --git a/src/api/routes/_responses.py b/src/api/routes/_responses.py new file mode 100644 index 0000000..5647a5a --- /dev/null +++ b/src/api/routes/_responses.py @@ -0,0 +1,21 @@ +from flask import jsonify + +STATUS_CODES = { + "ok": 200, + "not_found": 404, + "error": 500, + "unhealthy": 503, +} + + +def respond(result, w0o0o: int = 200): + """ + if server gud -> 200. If server bad -> 500 + + :param result: dict from ``core.db_helper`` + :param w0o0o: status to use on success + """ + status = result.get("status", "error") + if status == "ok": + return jsonify(result), w0o0o + return jsonify(result), STATUS_CODES.get(status, 500) diff --git a/src/api/routes/healthchecks.py b/src/api/routes/healthchecks.py index cf4da3c..f4a9e82 100644 --- a/src/api/routes/healthchecks.py +++ b/src/api/routes/healthchecks.py @@ -1,10 +1,11 @@ import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify from flask import current_app as eos -logger = logging.getLogger(__name__) +from ._responses import respond +logger = logging.getLogger(__name__) # Define a Blueprint health_checks = Blueprint("health_checks", __name__) @@ -15,10 +16,7 @@ def api_health_check(): """ A simple healthcheck that returns an up status. """ - if request.method == "GET": - return jsonify({"status": "ok"}, 200) - - return jsonify({"message": "improper request method"}, 405) + return jsonify({"status": "ok"}), 200 @health_checks.route("/hc_db", methods=["GET"]) @@ -26,12 +24,4 @@ def database_health_check(): """ A simple healthcheck that returns an up status. """ - if request.method == "GET": - try: - hc = eos.db.database_health_check() - return jsonify(hc, 200) - - except TypeError: - return jsonify({"status": "unhealthy", "error": "DB unreachable"}, 404) - - return jsonify({"message": "improper request method"}, 404) + return respond(eos.db.database_health_check()) diff --git a/src/api/routes/logging.py b/src/api/routes/logging.py index 01aa4d6..d7bce60 100644 --- a/src/api/routes/logging.py +++ b/src/api/routes/logging.py @@ -1,8 +1,10 @@ import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from flask import current_app as eos +from ._responses import respond + logger = logging.getLogger(__name__) # Define a Blueprint @@ -13,66 +15,24 @@ @logs.route("/logging/", methods=["GET"]) def get_log_setting(log_id=None): """ - Retrieve logging settings from the database. + Grab logging settings from the database. - :param log_id: Optional integer ID of a specific setting - :return: JSON response with log setting + :param log_id: optional ID of a specific log setting; all when None, and one for all! + :return: JSON envelope with ``log_setting`` or ``log_settings`` """ if log_id is None: - # Retrieve all log settings - result = eos.db.get_log_settings() - else: - # Retrieve a single setting - result = eos.db.get_log_setting(log_id) - - return jsonify(result, 200) + return respond(eos.db.get_log_settings()) + return respond(eos.db.get_log_setting(log_id)) -# @settings.route('/log_settings', methods=['GET']) -# def get_log_settings(): -# """ -# Retrieve log settings from the database. -# -# :return: JSON response with settings -# """ -# result = eos.db.get_log_settings() -# -# return jsonify(result, 200) - -@logs.route("/logging/", methods=["PUT"]) +@logs.route("/logging/", methods=["PUT"]) def update_log_setting(log_id): """ - Update an existing setting in the database. - """ - if request.method == "PUT": - data = request.json - result = eos.db.update_logging(int(log_id), data["value"]) - return jsonify(result, 200) - - return jsonify({"message": "improper request method"}, 405) - - -@logs.route("/logging", methods=["POST"]) -def add_log_setting(): - """ - Add a new setting to the database. - """ - if request.method == "POST": - data = request.json - result = eos.db.add_log_setting(data["name"], data["value"]) - return jsonify(result, 201) - - return jsonify({"message": "improper request method"}, 405) - - -@logs.route("/logging/", methods=["DELETE"]) -def delete_log_setting(log_id): - """ - Delete a specific setting from the database. + Update an existing log setting in the database. """ - if request.method == "DELETE": - result = eos.db.delete_log_setting(log_id) - return jsonify(result, 200) + data = request.get_json(silent=True) or {} + if "value" not in data: + return {"status": "error", "message": "Missing required field: value"}, 400 - return jsonify({"message": "improper request method"}, 405) + return respond(eos.db.update_logging(log_id, data["value"])) diff --git a/src/api/routes/parameters.py b/src/api/routes/parameters.py index 3e2bc32..691ec86 100644 --- a/src/api/routes/parameters.py +++ b/src/api/routes/parameters.py @@ -1,8 +1,10 @@ import logging -from flask import Blueprint, jsonify +from flask import Blueprint, request from flask import current_app as eos +from ._responses import respond + logger = logging.getLogger(__name__) # Define a Blueprint @@ -12,26 +14,20 @@ @parameters.route("/parameters/", methods=["GET"]) def get_parameter(parameter_name): """ - Retrieve the value of a parameter from the DB. + Grab the value of a parameter from the DB. """ - try: - result = eos.db.get_parameter(parameter_name) - return jsonify(result), 200 - except Exception as err: - logger.error(f"Error getting parameter {parameter_name}: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 - - -@parameters.route( - "/parameters/set//", methods=["POST"] -) -def set_parameter(parameter_name, parameter_value): + return respond(eos.db.get_parameter(parameter_name)) + + +@parameters.route("/parameters/", methods=["PUT"]) +def set_parameter(parameter_name): """ - Set the value of a parameter in the DB + Set the value of a parameter in the DB. + + The value is taken from the JSON body rather than the URL path """ - try: - result = eos.db.set_parameter(parameter_name, parameter_value) - return jsonify(result), 200 - except Exception as err: - logger.error(f"Error setting parameter {parameter_name}: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + data = request.get_json(silent=True) or {} + if "value" not in data: + return {"status": "error", "message": "Missing required field: value"}, 400 + + return respond(eos.db.set_parameter(parameter_name, data["value"])) diff --git a/src/api/routes/points.py b/src/api/routes/points.py index 952c5ea..ec22cbd 100644 --- a/src/api/routes/points.py +++ b/src/api/routes/points.py @@ -1,8 +1,10 @@ import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from flask import current_app as eos +from ._responses import respond + logger = logging.getLogger(__name__) # Define a Blueprint @@ -12,57 +14,33 @@ @points.route("/points/", methods=["GET"]) def get_points(user_id): """ - Retrieve points from the database. + Grab a user's points. """ - try: - result = eos.db.get_points_for_user(user_id) - if result["status"] == "ok": - return jsonify(result), 200 - else: - logger.warning(f"Error getting points for user: {result}") - return jsonify(result), 400 - except Exception as err: - logger.error(f"Error fetching points: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.get_points_for_user(user_id)) @points.route("/points/monthly/", methods=["GET"]) def get_monthly_points(user_id): """ - Retrieve monthly points of the user. + Grab a user's points for the current month. """ - try: - result = eos.db.get_monthly_points_for_user(user_id) - if result["status"] == "ok": - return jsonify(result), 200 - else: - logger.warning(f"Error getting monthly points for the user: {result}") - return jsonify(result), 400 - except Exception as err: - logger.error(f"Error fetching monthly points: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.get_monthly_points_for_user(user_id)) @points.route("/points//update", methods=["POST"]) def update_points(user_id): """ - Update points for a user. + Adjust a user's points by a signed amount. """ - try: - data = request.json - if "value" not in data: - return jsonify( - {"status": "error", "message": "Missing required field: value"} - ), 400 + data = request.get_json(silent=True) or {} + if "value" not in data: + return {"status": "error", "message": "Missing required field: value"}, 400 + + value = data["value"] + if isinstance(value, bool) or not isinstance(value, int): + return {"status": "error", "message": "Field 'value' must be an integer"}, 400 - result = eos.db.update_points(user_id, data["value"]) - return jsonify(result), 201 - except ValueError as ve: - logger.error(f"Invalid input: {ve}") - return jsonify({"status": "error", "message": str(ve)}), 415 - except Exception as err: - logger.error(f"Error updating points: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.update_points(user_id, value)) @points.route("/points//add", methods=["POST"]) @@ -70,15 +48,7 @@ def add_user_to_points(user_id): """ Add a new user to the points table. """ - try: - result = eos.db.add_user_to_points(user_id) - return jsonify(result), 201 - except ValueError as ve: - logger.error(f"Invalid input: {ve}") - return jsonify({"status": "error", "message": str(ve)}), 400 - except Exception as err: - logger.error(f"Error adding user: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.add_user_to_points(user_id), ok_code=201) @points.route("/points/", methods=["DELETE"]) @@ -86,64 +56,36 @@ def remove_user_from_points(user_id): """ Remove a user from the points table. """ - try: - result = eos.db.remove_user_from_points(user_id) - return jsonify(result), 200 - except ValueError as ve: - logger.error(f"Invalid input: {ve}") - return jsonify({"status": "error", "message": str(ve)}), 400 - except Exception as err: - logger.error(f"Error removing user: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.remove_user_from_points(user_id)) @points.route("/points/top10", methods=["GET"]) def top10(): """ - Grabs the top 10 point earners from the DB + The top 10 all-time point earners. """ - try: - result = eos.db.get_top_10() - return jsonify(result), 200 - except Exception as err: - logger.error(f"Error getting top 10: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.get_top_10()) @points.route("/points/monthly/top", methods=["GET"]) def top_monthly(): """ - Grabs the top point earner of the month + The top point earner of the current month. """ - try: - result = eos.db.get_monthly_top_point_earner() - return jsonify(result), 200 - except Exception as err: - logger.error(f"Error getting monthly top point earner: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.get_monthly_top_point_earner()) @points.route("/points/monthly/top10", methods=["GET"]) def monthly_top10(): """ - Grabs the top 10 point earners of the month + The top 10 point earners of the current month. """ - try: - result = eos.db.get_monthly_top_10() - return jsonify(result), 200 - except Exception as err: - logger.error(f"Error getting monthly top 10: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.get_monthly_top_10()) @points.route("/points/monthly/reset", methods=["DELETE"]) def reset_monthly_points(): """ - Resets monthly points of all members + Reset every member's monthly points to zero. """ - try: - result = eos.db.reset_monthly_points() - return jsonify(result), 200 - except Exception as err: - logger.error(f"Error resetting monthly points: {err}") - return jsonify({"status": "error", "message": str(err)}), 400 + return respond(eos.db.reset_monthly_points()) diff --git a/src/api/routes/roles.py b/src/api/routes/roles.py index 0f09ffa..cf0163d 100644 --- a/src/api/routes/roles.py +++ b/src/api/routes/roles.py @@ -1,8 +1,10 @@ import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from flask import current_app as eos +from ._responses import respond + logger = logging.getLogger(__name__) # Define a Blueprint @@ -13,54 +15,24 @@ @role.route("/role/", methods=["GET"]) def get_role(role_id=None): """ - Retrieve role from the database. + Grab roles from the database. - :param role_id: Optional integer ID of a specific role - :return: JSON response with role + :param role_id: optional ID of a specific role; all when None, and None for all! + :return: JSON envelope with ``role`` or ``roles`` """ if role_id is None: - # Retrieve all role - result = eos.db.get_roles() - else: - # Retrieve a single role - result = eos.db.get_role(role_id) + return respond(eos.db.get_roles()) - return jsonify(result, 200) + return respond(eos.db.get_role(role_id)) -@role.route("/role/", methods=["PUT"]) +@role.route("/role/", methods=["PUT"]) def update_role(role_id): """ Update an existing role in the database. """ - if request.method == "PUT": - data = request.json - result = eos.db.update_role(int(role_id), data["value"]) - return jsonify(result, 200) - - return jsonify({"message": "improper request method"}, 405) - - -@role.route("/role", methods=["POST"]) -def add_role(): - """ - Add a new role to the database. - """ - if request.method == "POST": - data = request.json - result = eos.db.add_role(data["name"], data["value"]) - return jsonify(result, 201) - - return jsonify({"message": "improper request method"}, 405) - - -@role.route("/role/", methods=["DELETE"]) -def delete_role(role_id): - """ - Delete a specific role from the database. - """ - if request.method == "DELETE": - result = eos.db.delete_role(role_id) - return jsonify(result, 200) + data = request.get_json(silent=True) or {} + if "value" not in data: + return {"status": "error", "message": "Missing required field: value"}, 400 - return jsonify({"message": "improper request method"}, 405) + return respond(eos.db.update_role(role_id, data["value"])) diff --git a/src/api/routes/settings.py b/src/api/routes/settings.py index f2493b0..d70feba 100644 --- a/src/api/routes/settings.py +++ b/src/api/routes/settings.py @@ -1,8 +1,10 @@ import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from flask import current_app as eos +from ._responses import respond + logger = logging.getLogger(__name__) # Define a Blueprint @@ -11,56 +13,26 @@ @settings.route("/settings", methods=["GET"]) @settings.route("/settings/", methods=["GET"]) -def get_setting(setting_id): +def get_setting(setting_id=None): """ - Retrieve settings from the database. + Grab settings from the database. - :param setting_id: Optional integer ID of a specific setting - :return: JSON response with setting + :param setting_id: optional ID of a specific setting; all when omitted + :return: JSON envelope with ``setting`` or ``settings`` """ - if setting_id == 0: - # Retrieve all settings - result = eos.db.get_settings() - else: - # Retrieve a single setting - result = eos.db.get_setting(setting_id) + if setting_id is None: + return respond(eos.db.get_settings()) - return jsonify(result, 200) + return respond(eos.db.get_setting(setting_id)) -@settings.route("/settings/", methods=["PUT"]) +@settings.route("/settings/", methods=["PUT"]) def update_setting(setting_id): """ Update an existing setting in the database. """ - if request.method == "PUT": - data = request.json - result = eos.db.update_setting(int(setting_id), data["value"]) - return jsonify(result, 200) - - return jsonify({"message": "improper request method"}, 405) - - -@settings.route("/settings", methods=["POST"]) -def add_setting(): - """ - Add a new setting to the database. - """ - if request.method == "POST": - data = request.json - result = eos.db.add_setting(data["name"], data["value"]) - return jsonify(result, 201) - - return jsonify({"message": "improper request method"}, 405) - - -@settings.route("/settings/", methods=["DELETE"]) -def delete_setting(setting_id): - """ - Delete a specific setting from the database. - """ - if request.method == "DELETE": - result = eos.db.delete_setting(setting_id) - return jsonify(result, 200) + data = request.get_json(silent=True) or {} + if "value" not in data: + return {"status": "error", "message": "Missing required field: value"}, 400 - return jsonify({"message": "improper request method"}, 405) + return respond(eos.db.update_setting(setting_id, data["value"])) From 8032b586aa53a16193aee7a515c6f90aa2882a50 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:34:56 +0200 Subject: [PATCH 03/13] update dockerfile entrypoint --- src/Dockerfile-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Dockerfile-api b/src/Dockerfile-api index 3c106c0..5363634 100644 --- a/src/Dockerfile-api +++ b/src/Dockerfile-api @@ -30,4 +30,4 @@ ENV HOME="/api" \ PATH="/api/.venv/bin:$PATH" EXPOSE ${FLASK_RUN_PORT} -CMD ["sh", "-c", "exec gunicorn -w 2 --timeout 60 --graceful-timeout 30 --access-logfile - --error-logfile - -b 0.0.0.0:${FLASK_RUN_PORT} --chdir /api/src/api api:app"] +CMD ["sh", "-c", "exec gunicorn -w 2 --timeout 60 --graceful-timeout 30 --access-logfile - --error-logfile - -b 0.0.0.0:${FLASK_RUN_PORT} --chdir /api/src/api 'api:create_app()'"] From 499cde838aeb851f14473b2cc0975613b0939b7e Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:40:11 +0200 Subject: [PATCH 04/13] Update logging cogs --- src/bot/cogs/logging/logging_avatars.py | 6 +- src/bot/cogs/logging/logging_member_ban.py | 12 +- src/bot/cogs/logging/logging_member_kick.py | 12 +- src/bot/cogs/logging/logging_member_leaves.py | 12 +- .../cogs/logging/logging_message_delete.py | 12 +- src/bot/cogs/logging/logging_message_edit.py | 10 +- src/bot/cogs/logging/logging_name_changes.py | 6 +- src/bot/cogs/logging/logging_roles.py | 6 +- src/bot/core/api_helper.py | 203 +++++++----------- 9 files changed, 125 insertions(+), 154 deletions(-) diff --git a/src/bot/cogs/logging/logging_avatars.py b/src/bot/cogs/logging/logging_avatars.py index 0030e2c..1740c02 100644 --- a/src/bot/cogs/logging/logging_avatars.py +++ b/src/bot/cogs/logging/logging_avatars.py @@ -48,14 +48,14 @@ async def on_user_update(self, before, after): # return if before.avatar != after.avatar: - if self.user_log[0]["status"] == "ok": - if self.user_log[0]["logging"][2] == "0": + if self.user_log["status"] == "ok": + if self.user_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.user_log}" ) return logs_channel = await self.bot.fetch_channel( - self.user_log[0]["logging"][2] + self.user_log["log_setting"]["value"] ) embed = embed_avatar(before, after) diff --git a/src/bot/cogs/logging/logging_member_ban.py b/src/bot/cogs/logging/logging_member_ban.py index 9dfaaf3..eb649b9 100644 --- a/src/bot/cogs/logging/logging_member_ban.py +++ b/src/bot/cogs/logging/logging_member_ban.py @@ -33,8 +33,8 @@ class LoggingBans(commands.Cog): def __init__(self, bot): self.bot = bot - self.verification_role = self.bot.api.get_one_role("6")[0]["roles"][ - 2 + self.verification_role = self.bot.api.get_one_role("6")["role"][ + "value" ] # Verification role ID self.mod_log = self.bot.api.get_one_log_setting("5") # mod_log @@ -55,13 +55,15 @@ async def on_member_remove(self, member): audit_log = [entry async for entry in member.guild.audit_logs(limit=1)][0] - if self.mod_log[0]["status"] == "ok": - if self.mod_log[0]["logging"][2] == "0": + if self.mod_log["status"] == "ok": + if self.mod_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.join_log}" ) return - logs_channel = await self.bot.fetch_channel(self.mod_log[0]["logging"][2]) + logs_channel = await self.bot.fetch_channel( + self.mod_log["log_setting"]["value"] + ) if str(audit_log.action) == "AuditLogAction.ban": if audit_log.target == member: diff --git a/src/bot/cogs/logging/logging_member_kick.py b/src/bot/cogs/logging/logging_member_kick.py index 23bd971..44df945 100644 --- a/src/bot/cogs/logging/logging_member_kick.py +++ b/src/bot/cogs/logging/logging_member_kick.py @@ -34,8 +34,8 @@ class LoggingKicks(commands.Cog): def __init__(self, bot): self.bot = bot - self.verification_role = self.bot.api.get_one_role("6")[0]["roles"][ - 2 + self.verification_role = self.bot.api.get_one_role("6")["role"][ + "value" ] # Verification role ID self.mod_log = self.bot.api.get_one_log_setting("5") # mod_log @@ -56,13 +56,15 @@ async def on_member_remove(self, member): audit_log = [entry async for entry in member.guild.audit_logs(limit=1)][0] - if self.mod_log[0]["status"] == "ok": - if self.mod_log[0]["logging"][2] == "0": + if self.mod_log["status"] == "ok": + if self.mod_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.mod_log}" ) return - logs_channel = await self.bot.fetch_channel(self.mod_log[0]["logging"][2]) + logs_channel = await self.bot.fetch_channel( + self.mod_log["log_setting"]["value"] + ) if str(audit_log.action) == "AuditLogAction.kick": if audit_log.target == member: diff --git a/src/bot/cogs/logging/logging_member_leaves.py b/src/bot/cogs/logging/logging_member_leaves.py index 166f9d2..8570e49 100644 --- a/src/bot/cogs/logging/logging_member_leaves.py +++ b/src/bot/cogs/logging/logging_member_leaves.py @@ -32,8 +32,8 @@ class LoggingLeaves(commands.Cog): def __init__(self, bot): self.bot = bot - self.verification_role = self.bot.api.get_one_role("6")[0]["roles"][ - 2 + self.verification_role = self.bot.api.get_one_role("6")["role"][ + "value" ] # Verification role ID self.join_log = self.bot.api.get_one_log_setting("2") # Join_log @@ -52,13 +52,15 @@ async def on_member_remove(self, member): if self.verification_role in [role.id for role in member.roles]: return - if self.join_log[0]["status"] == "ok": - if self.join_log[0]["logging"][2] == "0": + if self.join_log["status"] == "ok": + if self.join_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.join_log}" ) return - logs_channel = await self.bot.fetch_channel(self.join_log[0]["logging"][2]) + logs_channel = await self.bot.fetch_channel( + self.join_log["log_setting"]["value"] + ) audit_log = [entry async for entry in member.guild.audit_logs(limit=1)][0] diff --git a/src/bot/cogs/logging/logging_message_delete.py b/src/bot/cogs/logging/logging_message_delete.py index 73b16d8..9e0056c 100644 --- a/src/bot/cogs/logging/logging_message_delete.py +++ b/src/bot/cogs/logging/logging_message_delete.py @@ -46,8 +46,8 @@ class LoggingMessageDelete(commands.Cog): def __init__(self, bot): self.bot = bot - self.staff_channel = self.bot.api.get_one_setting("3")[0]["setting"][ - 2 + self.staff_channel = self.bot.api.get_one_setting("3")["setting"][ + "value" ] # Staff Channel ID self.chat_log = self.bot.api.get_one_log_setting("3") # chat_log @@ -70,13 +70,15 @@ async def on_message_delete(self, message): return audit_log = [entry async for entry in message.guild.audit_logs(limit=1)][0] - if self.chat_log[0]["status"] == "ok": - if self.chat_log[0]["logging"][2] == "0": + if self.chat_log["status"] == "ok": + if self.chat_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.chat_log}" ) return - logs_channel = await self.bot.fetch_channel(self.chat_log[0]["logging"][2]) + logs_channel = await self.bot.fetch_channel( + self.chat_log["log_setting"]["value"] + ) if str(audit_log.action) == "AuditLogAction.message_delete": # Then a moderator deleted a message. diff --git a/src/bot/cogs/logging/logging_message_edit.py b/src/bot/cogs/logging/logging_message_edit.py index 2db38f3..75b6bfa 100644 --- a/src/bot/cogs/logging/logging_message_edit.py +++ b/src/bot/cogs/logging/logging_message_edit.py @@ -46,8 +46,8 @@ class LoggingMessageEdit(commands.Cog): def __init__(self, bot): self.bot = bot - self.staff_channel = self.bot.api.get_one_setting("3")[0]["setting"][ - 2 + self.staff_channel = self.bot.api.get_one_setting("3")["setting"][ + "value" ] # Staff Channel ID self.chat_log = self.bot.api.get_one_log_setting("3") # chat_log @@ -76,15 +76,15 @@ async def on_message_edit(self, message_before, message_after): return elif message_before.content != message_after.content: - if self.chat_log[0]["status"] == "ok": - if self.chat_log[0]["logging"][2] == "0": + if self.chat_log["status"] == "ok": + if self.chat_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.chat_log}" ) return logs_channel = await self.bot.fetch_channel( - self.chat_log[0]["logging"][2] + self.chat_log["log_setting"]["value"] ) # This guy here makes sure we use the displayed name inside the guild. diff --git a/src/bot/cogs/logging/logging_name_changes.py b/src/bot/cogs/logging/logging_name_changes.py index 54843d5..26e750f 100644 --- a/src/bot/cogs/logging/logging_name_changes.py +++ b/src/bot/cogs/logging/logging_name_changes.py @@ -54,14 +54,14 @@ async def on_member_update(self, before, after): return if username_before != username_after: - if self.user_log[0]["status"] == "ok": - if self.user_log[0]["logging"][2] == "0": + if self.user_log["status"] == "ok": + if self.user_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.user_log}" ) return logs_channel = await self.bot.fetch_channel( - self.user_log[0]["logging"][2] + self.user_log["log_setting"]["value"] ) embed = embed_name_change(username_before, username_after) diff --git a/src/bot/cogs/logging/logging_roles.py b/src/bot/cogs/logging/logging_roles.py index a52ce88..c897733 100644 --- a/src/bot/cogs/logging/logging_roles.py +++ b/src/bot/cogs/logging/logging_roles.py @@ -66,14 +66,14 @@ async def on_member_update(self, before, after): responsible_member = audit_log.user changed_roles = [] - if self.mod_log[0]["status"] == "ok": - if self.mod_log[0]["logging"][2] == "0": + if self.mod_log["status"] == "ok": + if self.mod_log["log_setting"]["value"] == "0": logger.debug( f"log was triggered, but logging is disabled. API: {self.mod_log}" ) return logs_channel = await self.bot.fetch_channel( - self.mod_log[0]["logging"][2] + self.mod_log["log_setting"]["value"] ) if len(before.roles) > len(after.roles): diff --git a/src/bot/core/api_helper.py b/src/bot/core/api_helper.py index 5f6f18d..104461c 100644 --- a/src/bot/core/api_helper.py +++ b/src/bot/core/api_helper.py @@ -9,8 +9,14 @@ # Default timeout (seconds) for all API requests so a hung API can't block the bot. REQUEST_TIMEOUT = 10 +_UNAVAILABLE = {"status": "error", "message": "API request failed"} + class API: + """ + Thin client over the Flask API. + """ + def __init__(self): logger.info("Initializing API...") self.api = os.getenv("FLASK_URL") @@ -18,18 +24,68 @@ def __init__(self): self.session = None async def setup(self): - timeout = aiohttp.ClientTimeout(total=10) + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT) self.session = aiohttp.ClientSession(headers=self.headers, timeout=timeout) logger.info("API initialized.") + async def close(self): + """Close the shared session. Called from the bot's shutdown hook.""" + if self.session is not None: + await self.session.close() + self.session = None + + ############################## + # Request plumbing # + ############################## + def _request(self, method, path, **kwargs) -> dict: + """Perform a blocking request and return the juice.""" + try: + response = requests.request( + method, f"{self.api}{path}", timeout=REQUEST_TIMEOUT, **kwargs + ) + payload = response.json() + except requests.RequestException as err: + logger.error("API %s %s failed: %s", method, path, err) + return dict(_UNAVAILABLE) + except ValueError: + logger.error("API %s %s returned a non-JSON body", method, path) + return dict(_UNAVAILABLE) + + if not isinstance(payload, dict): + logger.error( + "API %s %s returned an unexpected shape: %r", method, path, payload + ) + return dict(_UNAVAILABLE) + + return payload + + async def _arequest(self, method, path, **kwargs) -> dict: + """Perform a non-blocking request and return the juice.""" + try: + async with self.session.request( + method, f"{self.api}{path}", **kwargs + ) as response: + payload = await response.json(content_type=None) + except (aiohttp.ClientError, TimeoutError, ValueError) as err: + logger.error("API %s %s failed: %s", method, path, err) + return dict(_UNAVAILABLE) + + if not isinstance(payload, dict): + logger.error( + "API %s %s returned an unexpected shape: %r", method, path, payload + ) + return dict(_UNAVAILABLE) + + return payload + ############################## # Health checks # ############################## async def health_check(self): - """Returns the healtcheck status of API and DB""" + """Returns the healthcheck status of API and DB""" timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT) async with aiohttp.ClientSession(timeout=timeout) as session: results = {} @@ -45,44 +101,19 @@ async def health_check(self): def get_one_log_setting(self, flag_id): """Retrieves one log setting from the database""" logger.debug("Bot called get_one_log_setting endpoint.") - return requests.get( - f"{self.api}/logging/{flag_id}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", f"/logging/{flag_id}") def get_all_log_settings(self): """Retrieves all log settings from the database""" logger.debug("Bot called the get_all_log_settings endpoint.") - return requests.get(f"{self.api}/logging", timeout=REQUEST_TIMEOUT).json() - - def add_new_log_setting(self, name, value): - """Adds a new log setting to the database""" - logger.debug( - f"Bot called the add_new_log_setting endpoint. Log setting to add: {name} - Log setting value: {value}" - ) - data = {"name": name, "value": value} - return requests.post( - f"{self.api}/logging", json=data, timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", "/logging") def update_existing_log_setting(self, log_id, new_value): """Updates an existing log setting in the database""" logger.debug( f"Bot called the update_existing_log_setting endpoint. Log setting ID: {log_id} - New value: {new_value}" ) - - data = {"value": new_value} - return requests.put( - f"{self.api}/logging/{log_id}", json=data, timeout=REQUEST_TIMEOUT - ).json() - - def delete_log_setting(self, log_id): - """Deletes a log setting from the database""" - logger.debug( - f"Bot called the delete_log_setting endpoint. Log setting ID: {log_id}" - ) - return requests.delete( - f"{self.api}/logging/{log_id}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("PUT", f"/logging/{log_id}", json={"value": new_value}) ############################## # Settings # @@ -90,44 +121,21 @@ def delete_log_setting(self, log_id): def get_one_setting(self, flag_id): """Retrieves one setting from the database""" logger.debug("Bot called get_one_setting endpoint.") - return requests.get( - f"{self.api}/settings/{flag_id}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", f"/settings/{flag_id}") def get_all_settings(self): """Retrieves all settings from the database""" logger.debug("Bot called the get_all_settings endpoint.") - return requests.get(f"{self.api}/settings/0", timeout=REQUEST_TIMEOUT).json() - - def add_new_setting(self, name, value): - """Adds a new setting to the database""" - logger.debug( - f"Bot called the add_new_setting endpoint. Setting to add: {name} - Setting value: {value}" - ) - data = {"name": name, "value": value} - return requests.post( - f"{self.api}/settings", json=data, timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", "/settings") def update_existing_setting(self, setting_id, new_value): """Updates an existing setting in the database""" logger.debug( f"Bot called the update_existing_setting endpoint. Setting ID: {setting_id} - New value: {new_value}" ) - - data = {"value": new_value} - return requests.put( - f"{self.api}/settings/{setting_id}", json=data, timeout=REQUEST_TIMEOUT - ).json() - - def delete_setting(self, setting_id): - """Deletes a setting from the database""" - logger.debug( - f"Bot called the delete_setting endpoint. Setting ID: {setting_id}" + return self._request( + "PUT", f"/settings/{setting_id}", json={"value": new_value} ) - return requests.delete( - f"{self.api}/settings/{setting_id}", timeout=REQUEST_TIMEOUT - ).json() ############################## # Roles # @@ -135,42 +143,19 @@ def delete_setting(self, setting_id): def get_one_role(self, flag_id): """Retrieves one role from the database""" logger.debug("Bot called get_one_role endpoint.") - return requests.get( - f"{self.api}/role/{flag_id}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", f"/role/{flag_id}") def get_all_roles(self): """Retrieves all roles from the database""" logger.debug("Bot called the get_all_roles endpoint.") - return requests.get(f"{self.api}/role", timeout=REQUEST_TIMEOUT).json() - - def add_new_role(self, name, value): - """Adds a new role to the database""" - logger.debug( - f"Bot called the add_new_role endpoint. role to add: {name} - role value: {value}" - ) - data = {"name": name, "value": value} - return requests.post( - f"{self.api}/role", json=data, timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", "/role") def update_existing_role(self, role_id, new_value): """Updates an existing role in the database""" logger.debug( f"Bot called the update_existing_role endpoint. role ID: {role_id} - New value: {new_value}" ) - - data = {"value": new_value} - return requests.put( - f"{self.api}/role/{role_id}", json=data, timeout=REQUEST_TIMEOUT - ).json() - - def delete_role(self, role_id): - """Deletes a role from the database""" - logger.debug(f"Bot called the delete_role endpoint. role ID: {role_id}") - return requests.delete( - f"{self.api}/role/{role_id}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("PUT", f"/role/{role_id}", json={"value": new_value}) ############################## # Points # @@ -178,64 +163,45 @@ def delete_role(self, role_id): async def add_user_to_points(self, user_id): logger.debug(f"Bot called the add_user_to_points endpoint. User ID: {user_id}") - async with self.session.post(f"{self.api}/points/{user_id}/add") as response: - data = await response.json() - return data + return await self._arequest("POST", f"/points/{user_id}/add") async def delete_user_from_points(self, user_id): logger.debug( f"Bot called the delete_user_from_points endpoint. User ID: {user_id}" ) - async with self.session.delete(f"{self.api}/points/{user_id}") as response: - data = await response.json() - return data + return await self._arequest("DELETE", f"/points/{user_id}") async def get_points(self, user_id): logger.debug(f"Bot called the get_points endpoint. User ID: {user_id}") - async with self.session.get(f"{self.api}/points/{user_id}") as response: - data = await response.json() - return data + return await self._arequest("GET", f"/points/{user_id}") def get_monthly_points(self, user_id): logger.debug(f"Bot called the get_monthly_points endpoint. User Id: {user_id}") - return requests.get( - f"{self.api}/points/monthly/{user_id}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", f"/points/monthly/{user_id}") async def update_points(self, user_id, amount): logger.debug( f"Bot called the update_points endpoint. User ID: {user_id} - Points: {amount}" ) - data = {"value": amount} - async with self.session.post( - f"{self.api}/points/{user_id}/update", json=data - ) as response: - data = await response.json() - return data + return await self._arequest( + "POST", f"/points/{user_id}/update", json={"value": amount} + ) async def top_10(self): logger.debug("Bot called the top_10 endpoint.") - async with self.session.get(f"{self.api}/points/top10") as response: - data = await response.json() - return data + return await self._arequest("GET", "/points/top10") def monthly_top_point_earner(self): logger.debug("Bot called monthly top point earner.") - return requests.get( - f"{self.api}/points/monthly/top", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", "/points/monthly/top") def monthly_top_10(self): logger.debug("Bot called the monthly top_10 endpoint.") - return requests.get( - f"{self.api}/points/monthly/top10", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", "/points/monthly/top10") def reset_monthly_points(self): logger.debug("Bot called the reset monthly points endpoint.") - return requests.delete( - f"{self.api}/points/monthly/reset", timeout=REQUEST_TIMEOUT - ).json() + return self._request("DELETE", "/points/monthly/reset") ############################## # Parameters # @@ -245,15 +211,12 @@ def get_parameter(self, parameter_name): logger.debug( f"Bot called parameters endpoint to get parameter {parameter_name}" ) - return requests.get( - f"{self.api}/parameters/{parameter_name}", timeout=REQUEST_TIMEOUT - ).json() + return self._request("GET", f"/parameters/{parameter_name}") def set_parameter(self, parameter_name, parameter_value): logger.debug( f"Bot called parameters endpoint to set {parameter_name} to {parameter_value}" ) - return requests.post( - f"{self.api}/parameters/set/{parameter_name}/{parameter_value}", - timeout=REQUEST_TIMEOUT, - ).json() + return self._request( + "PUT", f"/parameters/{parameter_name}", json={"value": parameter_value} + ) From 618d16cc1dec59b84411d4828d944f1fbd4862cc Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:41:39 +0200 Subject: [PATCH 05/13] Update moderation cogs --- .../cogs/moderation/admin_automod_spam_messages.py | 6 +++--- src/bot/cogs/moderation/admin_purge.py | 8 ++++---- src/bot/cogs/moderation/admin_quarantine.py | 14 ++++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/bot/cogs/moderation/admin_automod_spam_messages.py b/src/bot/cogs/moderation/admin_automod_spam_messages.py index dd4130e..09e16d0 100644 --- a/src/bot/cogs/moderation/admin_automod_spam_messages.py +++ b/src/bot/cogs/moderation/admin_automod_spam_messages.py @@ -251,13 +251,13 @@ async def quarantine_user(self, message, record): author_id = message.author.id try: naughty_role = await message.guild.fetch_role( - self.bot.api.get_one_role("7")[0]["roles"][2] + self.bot.api.get_one_role("7")["role"]["value"] ) verified_role = await message.guild.fetch_role( - self.bot.api.get_one_role("6")[0]["roles"][2] + self.bot.api.get_one_role("6")["role"]["value"] ) - quarantine_channel = self.bot.api.get_one_setting("2")[0]["setting"][2] + quarantine_channel = self.bot.api.get_one_setting("2")["setting"]["value"] quarantine_channel = await self.bot.fetch_channel(quarantine_channel) thirty_seconds = datetime.datetime.now().astimezone() + datetime.timedelta( seconds=30 diff --git a/src/bot/cogs/moderation/admin_purge.py b/src/bot/cogs/moderation/admin_purge.py index 59b9959..0ee8495 100644 --- a/src/bot/cogs/moderation/admin_purge.py +++ b/src/bot/cogs/moderation/admin_purge.py @@ -28,13 +28,13 @@ def embed_info(message): def api_request_is_ok(request): - if request[0]["status"] == "ok": + if request["status"] == "ok": return True return False def logging_is_activated(request): - if request[0]["logging"][2] == "0": + if request["log_setting"]["value"] == "0": return False return True @@ -65,14 +65,14 @@ async def purge_messages(self, interaction: discord.Interaction, amount: int): if api_request_is_ok(self.log_channel_req): logger.info( f"{interaction.user.name} is purging {amount} messages from " - f"the {self.log_channel_req[0]['logging'][1]}" + f"the {self.log_channel_req['log_setting']['name']}" ) await interaction.response.defer() await interaction.channel.purge(limit=amount + 1) if logging_is_activated(self.log_channel_req): logging_channel = await self.bot.fetch_channel( - self.log_channel_req[0]["logging"][2] + self.log_channel_req["log_setting"]["value"] ) await logging_channel.send( diff --git a/src/bot/cogs/moderation/admin_quarantine.py b/src/bot/cogs/moderation/admin_quarantine.py index 033bd45..36b0718 100644 --- a/src/bot/cogs/moderation/admin_quarantine.py +++ b/src/bot/cogs/moderation/admin_quarantine.py @@ -69,11 +69,11 @@ class AdminQuarantine(commands.Cog): def __init__(self, bot): self.bot = bot - self.naughty_role = self.bot.api.get_one_role("7")[0]["roles"][ - 2 + self.naughty_role = self.bot.api.get_one_role("7")["role"][ + "value" ] # quarantine role ID - self.verified_role = self.bot.api.get_one_role("6")[0]["roles"][ - 2 + self.verified_role = self.bot.api.get_one_role("6")["role"][ + "value" ] # Verification role ID self.mod_log = self.bot.api.get_one_log_setting("5") # mod_log @@ -106,7 +106,9 @@ async def quarantine( if not target.guild_permissions.administrator: message_counter = 0 - mod_log = await self.bot.fetch_channel(self.mod_log[0]["logging"][2]) + mod_log = await self.bot.fetch_channel( + self.mod_log["log_setting"]["value"] + ) verified_role = get(interaction.guild.roles, id=int(self.verified_role)) naughty_role = get(interaction.guild.roles, id=int(self.naughty_role)) @@ -172,7 +174,7 @@ async def release(self, interaction: discord.Interaction, target: discord.Member f"{interaction.user.name} used the release command on {target.name}" ) if not target.bot: - mod_log = await self.bot.fetch_channel(self.mod_log[0]["logging"][2]) + mod_log = await self.bot.fetch_channel(self.mod_log["log_setting"]["value"]) verified_role = get(interaction.guild.roles, id=int(self.verified_role)) naughty_role = get(interaction.guild.roles, id=int(self.naughty_role)) From 3ceb28a310b0806d95fde6394082e7c31f3dfb04 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:42:28 +0200 Subject: [PATCH 06/13] Update verification cogs --- .../verification/verification_dropdown.py | 20 ++++++++++++------- .../cogs/verification/verification_on_join.py | 14 ++++++++----- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/bot/cogs/verification/verification_dropdown.py b/src/bot/cogs/verification/verification_dropdown.py index 7df09b7..051571b 100644 --- a/src/bot/cogs/verification/verification_dropdown.py +++ b/src/bot/cogs/verification/verification_dropdown.py @@ -33,10 +33,14 @@ def embed_verified_success(name, amount): class VerificationSelector(discord.ui.Select): def __init__(self, bot): self.bot = bot - self.verified_role = self.bot.api.get_one_role("6")[0]["roles"][2] - self.join_log = self.bot.api.get_one_log_setting("2")[0]["logging"][2] - self.verification_log = self.bot.api.get_one_log_setting("1")[0]["logging"][2] - self.verification_channel = self.bot.api.get_one_setting("1")[0]["setting"][2] + self.verified_role = self.bot.api.get_one_role("6")["role"]["value"] + self.join_log = self.bot.api.get_one_log_setting("2")["log_setting"]["value"] + self.verification_log = self.bot.api.get_one_log_setting("1")["log_setting"][ + "value" + ] + self.verification_channel = self.bot.api.get_one_setting("1")["setting"][ + "value" + ] self.robot = [ discord.SelectOption( @@ -114,9 +118,11 @@ class Verification(commands.Cog): def __init__(self, bot): self.bot = bot # Passed in from main.py - self.join_log = self.bot.api.get_one_log_setting("4")[0]["logging"][2] - self.verification_channel = self.bot.api.get_one_setting("1")[0]["setting"][2] - self.verified_role = self.bot.api.get_one_role("6")[0]["roles"][2] + self.join_log = self.bot.api.get_one_log_setting("4")["log_setting"]["value"] + self.verification_channel = self.bot.api.get_one_setting("1")["setting"][ + "value" + ] + self.verified_role = self.bot.api.get_one_role("6")["role"]["value"] @commands.command() async def verify(self, ctx): diff --git a/src/bot/cogs/verification/verification_on_join.py b/src/bot/cogs/verification/verification_on_join.py index e2d2c3a..f060361 100644 --- a/src/bot/cogs/verification/verification_on_join.py +++ b/src/bot/cogs/verification/verification_on_join.py @@ -21,11 +21,15 @@ class LoggingVerification(commands.Cog): def __init__(self, bot): self.bot = bot - self.verification_channel = self.bot.api.get_one_setting("1")[0]["setting"][2] - self.verification_log = self.bot.api.get_one_log_setting("1")[0]["logging"][2] - self.join_log = self.bot.api.get_one_log_setting("2")[0]["logging"][2] - self.verified_role = self.bot.api.get_one_role("6")[0]["roles"][2] - self.naughty_role = self.bot.api.get_one_role("7")[0]["roles"][2] + self.verification_channel = self.bot.api.get_one_setting("1")["setting"][ + "value" + ] + self.verification_log = self.bot.api.get_one_log_setting("1")["log_setting"][ + "value" + ] + self.join_log = self.bot.api.get_one_log_setting("2")["log_setting"]["value"] + self.verified_role = self.bot.api.get_one_role("6")["role"]["value"] + self.naughty_role = self.bot.api.get_one_role("7")["role"]["value"] async def log_unverified_join(self, member, logging_channel): await logging_channel.send(f"<@{member.id}> joined, but has not verified.") From 6e7ce749e98bc43c8d90179b129009c7850e0af3 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:43:48 +0200 Subject: [PATCH 07/13] Update admin cogs --- src/bot/cogs/admin/healthchecks.py | 12 ++++---- src/bot/cogs/admin/points.py | 16 +++++------ src/bot/cogs/admin/settings.py | 46 ++++++++++++++++-------------- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/bot/cogs/admin/healthchecks.py b/src/bot/cogs/admin/healthchecks.py index 428a462..46875a2 100644 --- a/src/bot/cogs/admin/healthchecks.py +++ b/src/bot/cogs/admin/healthchecks.py @@ -44,9 +44,9 @@ async def hc(self, ctx: commands.Context) -> None: hc_api = results["api_status"] logger.debug(hc_api) try: - status_api = hc_api[0]["status"] - status_code_api = hc_api[1] - except KeyError: + status_api = hc_api["status"] + status_code_api = "200" + except (KeyError, TypeError): status_api = "Unhealthy" status_code_api = "API Unreachable" @@ -61,9 +61,9 @@ async def hc(self, ctx: commands.Context) -> None: hc_db = results["db_status"] logger.debug(hc_db) try: - status_db = hc_db[0]["status"] - status_code_db = hc_db[1] - except KeyError: + status_db = hc_db["status"] + status_code_db = "200" if status_db == "ok" else "503" + except (KeyError, TypeError): status_db = "Unhealthy" status_code_db = "DB Unreachable" diff --git a/src/bot/cogs/admin/points.py b/src/bot/cogs/admin/points.py index a5a9f6d..f7a873c 100644 --- a/src/bot/cogs/admin/points.py +++ b/src/bot/cogs/admin/points.py @@ -73,7 +73,7 @@ async def get_points(self, ctx: commands.Context, user: discord.Member) -> None: await ctx.reply( embed=embed_info( "", - f"{user.display_name} has {points['points'][0]} points", + f"{user.display_name} has {points['points']} points", discord.Color.lighter_gray(), ) ) @@ -93,7 +93,7 @@ async def get_monthly_points( await ctx.reply( embed=embed_info( "", - f"{user.display_name} has {monthly_points['monthly_points'][0]} points", + f"{user.display_name} has {monthly_points['monthly_points']} points", discord.Color.lighter_gray(), ) ) @@ -139,12 +139,12 @@ async def top_10(self, ctx: commands.Context) -> None: top10 = await self.bot.api.top_10() if top10["status"] == "ok": data = [] - for user in top10["message"]: - user_obj = self.bot.get_user(int(user[0])) + for user in top10["leaderboard"]: + user_obj = self.bot.get_user(int(user["discord_id"])) if user_obj is None: continue - data.append((user_obj.display_name, user[1])) + data.append((user_obj.display_name, user["points"])) await ctx.reply( embed=embed_info( @@ -171,12 +171,12 @@ async def top_10_monthly(self, ctx: commands.Context) -> None: monthly_top10 = self.bot.api.monthly_top_10() if monthly_top10["status"] == "ok": data = [] - for user in monthly_top10["message"]: - user_obj = self.bot.get_user(int(user[0])) + for user in monthly_top10["leaderboard"]: + user_obj = self.bot.get_user(int(user["discord_id"])) if user_obj is None: continue - data.append((user_obj.display_name, user[1])) + data.append((user_obj.display_name, user["monthly_points"])) await ctx.reply( embed=embed_info( diff --git a/src/bot/cogs/admin/settings.py b/src/bot/cogs/admin/settings.py index b7a35bc..082ac26 100644 --- a/src/bot/cogs/admin/settings.py +++ b/src/bot/cogs/admin/settings.py @@ -37,10 +37,10 @@ async def settings(self, ctx: commands.Context): server_settings = self.bot.api.get_all_settings() log_settings = self.bot.api.get_all_log_settings() - if server_settings[0]["status"] != "ok": + if server_settings["status"] != "ok": await ctx.send(f"Failed to retrieve settings: {server_settings['message']}") return - if log_settings[0]["status"] != "ok": + if log_settings["status"] != "ok": await ctx.send(f"Failed to retrieve settings: {log_settings['message']}") return @@ -51,13 +51,17 @@ async def settings(self, ctx: commands.Context): timestamp=datetime.datetime.now(), ) - for setting in server_settings[0]["setting"]: - value = f"<#{setting[2]}>" if setting[2] != "0" else "Off" - embed.add_field(name="", value=f"**{setting[1]}**:{value}", inline=False) + for setting in server_settings["settings"]: + value = f"<#{setting['value']}>" if setting["value"] != "0" else "Off" + embed.add_field( + name="", value=f"**{setting['name']}**:{value}", inline=False + ) - for setting in log_settings[0]["logging"]: - value = f"<#{setting[2]}>" if setting[2] != "0" else "Off" - embed.add_field(name="", value=f"**{setting[1]}**:{value}", inline=False) + for setting in log_settings["log_settings"]: + value = f"<#{setting['value']}>" if setting["value"] != "0" else "Off" + embed.add_field( + name="", value=f"**{setting['name']}**:{value}", inline=False + ) embed.set_footer(text=ctx.guild.name, icon_url=ctx.guild.icon) @@ -76,9 +80,9 @@ async def update_settings(self, ctx: commands.Context): server_settings = self.bot.api.get_all_settings() # Pull the names out of the returned JSON - logging_types = [item for item in channel_settings[0]["logging"]] - role_types = [role for role in role_settings[0]["roles"]] - setting_types = [setting for setting in server_settings[0]["setting"]] + logging_types = [item for item in channel_settings["log_settings"]] + role_types = [role for role in role_settings["roles"]] + setting_types = [setting for setting in server_settings["settings"]] # Get the names of the available channels and roles to map channels = [ @@ -97,15 +101,15 @@ async def update_settings(self, ctx: commands.Context): menu = [ # Views take context, bot, values of dropdowns, names of dropdowns ( - (log[1], LoggingDropdownView(ctx, self.bot, channels, log)) + (log["name"], LoggingDropdownView(ctx, self.bot, channels, log)) for log in logging_types ), ( - (role[1], RoleDropdownView(ctx, self.bot, roles, role)) + (role["name"], RoleDropdownView(ctx, self.bot, roles, role)) for role in role_types ), ( - (setting[1], ServerDropdownView(ctx, self.bot, settings, setting)) + (setting["name"], ServerDropdownView(ctx, self.bot, settings, setting)) for setting in setting_types ), ] @@ -223,7 +227,7 @@ def __init__(self, ctx: commands.Context, bot, channels: list, purpose: str): # Special formatting that uses : as a delimiter. # Kind of abusing the "value" option with this. # See the callback - value=f"{purpose[0]}:{purpose[1]}:0", + value=f"{purpose['id']}:{purpose['name']}:0", ) ] @@ -241,7 +245,7 @@ def __init__(self, ctx: commands.Context, bot, channels: list, purpose: str): # Special formatting that uses : as a delimiter. # Kind of abusing the "value" option with this. # See the callback - value=f"{purpose[0]}:{purpose[1]}:{guild_channel.id}", + value=f"{purpose['id']}:{purpose['name']}:{guild_channel.id}", ) ) logger.debug(f"SelectOption value: {purpose}:{guild_channel.id}") @@ -277,12 +281,12 @@ def __init__(self, ctx: commands.Context, bot, roles: list, purpose: str): self.dropdown_options = [ discord.SelectOption( label="Disconnect role.", - description=f"Disconnect the role from the {purpose[1]} position", + description=f"Disconnect the role from the {purpose['name']} position", emoji="🔴", # Special formatting that uses : as a delimiter. # Kind of abusing the "value" option with this. # See the callback - value=f"{purpose[0]}:{purpose[1]}:0", + value=f"{purpose['id']}:{purpose['name']}:0", ) ] @@ -298,7 +302,7 @@ def __init__(self, ctx: commands.Context, bot, roles: list, purpose: str): # Special formatting that uses : as a delimiter. # Kind of abusing the "value" option with this. # See the callback - value=f"{purpose[0]}:{purpose[1]}:{role.id}", + value=f"{purpose['id']}:{purpose['name']}:{role.id}", ) ) logger.debug(f"SelectOption value: {purpose}:{role.id}") @@ -339,7 +343,7 @@ def __init__(self, ctx: commands.Context, bot, channels: list, purpose: str): # Special formatting that uses : as a delimiter. # Kind of abusing the "value" option with this. # See the callback - value=f"{purpose[0]}:{purpose[1]}:0", + value=f"{purpose['id']}:{purpose['name']}:0", ) ] @@ -357,7 +361,7 @@ def __init__(self, ctx: commands.Context, bot, channels: list, purpose: str): # Special formatting that uses : as a delimiter. # Kind of abusing the "value" option with this. # See the callback - value=f"{purpose[0]}:{purpose[1]}:{guild_channel.id}", + value=f"{purpose['id']}:{purpose['name']}:{guild_channel.id}", ) ) logger.debug(f"SelectOption value: {purpose}:{guild_channel.id}") From 71dd14fe3a9af28f934374f4b07b0189a4d7e522 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:44:44 +0200 Subject: [PATCH 08/13] Update feature cogs --- src/bot/cogs/features/monthly_yapathon.py | 17 ++++++++++++----- src/bot/cogs/features/ticket.py | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/bot/cogs/features/monthly_yapathon.py b/src/bot/cogs/features/monthly_yapathon.py index c01fb8d..2afcf3e 100644 --- a/src/bot/cogs/features/monthly_yapathon.py +++ b/src/bot/cogs/features/monthly_yapathon.py @@ -24,9 +24,9 @@ class MonthlyYapathon(commands.Cog): def __init__(self, bot): self.bot = bot - self.yapper_role_id = self.bot.api.get_one_role("8")[0]["roles"][2] - self.announcement_channel_id = self.bot.api.get_one_setting("5")[0]["setting"][ - 2 + self.yapper_role_id = self.bot.api.get_one_role("8")["role"]["value"] + self.announcement_channel_id = self.bot.api.get_one_setting("5")["setting"][ + "value" ] self.appoint_monthly_yapper.start() @@ -44,6 +44,12 @@ async def appoint_monthly_yapper(self): monthly_top_point_earner = self.bot.api.monthly_top_point_earner() current_monthly_yapper = self.bot.api.get_parameter("monthly_yapper") + if current_monthly_yapper["status"] != "ok": + logger.error( + f"Error reading the current monthly yapper: {current_monthly_yapper['message']}" + ) + return + if monthly_top_point_earner["status"] == "ok": try: guild = self.bot.get_guild(int(os.getenv("MASTER_GUILD"))) @@ -51,10 +57,11 @@ async def appoint_monthly_yapper(self): yapper_role = get(guild.roles, id=int(self.yapper_role_id)) new_yapper = get( - guild.members, id=int(monthly_top_point_earner["message"][0]) + guild.members, + id=int(monthly_top_point_earner["top_earner"]["discord_id"]), ) current_yapper = get( - guild.members, id=int(current_monthly_yapper["message"][0]) + guild.members, id=int(current_monthly_yapper["parameter"]) ) # This condition will save this from crash when the current yapper has left the guild if current_yapper is not None: diff --git a/src/bot/cogs/features/ticket.py b/src/bot/cogs/features/ticket.py index 749d932..947bab6 100644 --- a/src/bot/cogs/features/ticket.py +++ b/src/bot/cogs/features/ticket.py @@ -60,7 +60,7 @@ async def button_callback(self, interaction, button): interaction.channel ) # TODO: guild specific settings for a support channel staff = interaction.guild.get_role( - self.bot.api.get_one_role("3")[0]["roles"][2] + self.bot.api.get_one_role("3")["role"]["value"] ) # Staff ticket = await support.create_thread( From ccbaa2c41fa59034e47882feab30e553cb93dff3 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:52:53 +0200 Subject: [PATCH 09/13] tests! --- .github/workflows/ci.yml | 9 ++ Makefile | 5 +- pyproject.toml | 1 + tests/conftest.py | 82 +++++++++++ tests/test_api_routes.py | 306 +++++++++++++++++++++++++++++++++++++++ uv.lock | 60 +++++++- 6 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_api_routes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e824a38..011ffa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,3 +15,12 @@ jobs: with: python-version: '3.12' - uses: pre-commit/action@v3.0.1 + + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv run --dev python -m pytest tests/ -q diff --git a/Makefile b/Makefile index ab1c93e..c869211 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: start stop build pre-commit format +.PHONY: start stop build pre-commit format test all: start @@ -17,3 +17,6 @@ pre-commit: format: uv run --dev ruff format . uv run --dev ruff check . --fix + +test: + uv run --dev python -m pytest tests/ diff --git a/pyproject.toml b/pyproject.toml index 0c7be31..eb920c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,5 +4,6 @@ members = ["src/api", "src/bot"] [dependency-groups] dev = [ "pre-commit>=4.6.0", + "pytest>=9.1.1", "ruff>=0.15.15", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6e35c1a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,82 @@ +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +API_SRC = ROOT / "src" / "api" + +_CLASHING = ("core", "routes", "api", "__logger__") + + +def _is_clashing(name: str) -> bool: + return name.split(".")[0] in _CLASHING + + +def _purge() -> dict: + return { + name: sys.modules.pop(name) + for name in list(sys.modules) + if _is_clashing(name) + } + + +def _restore(saved: dict) -> None: + for name in list(sys.modules): + if _is_clashing(name): + del sys.modules[name] + sys.modules.update(saved) + + +@pytest.fixture(scope="session") +def create_app(): + """imported without breaking the bot's `core` app.""" + saved_path = list(sys.path) + saved_modules = _purge() + sys.path.insert(0, str(API_SRC)) + try: + from api import create_app as factory + finally: + sys.path[:] = saved_path + _restore(saved_modules) + return factory + + +class FakeDB: + """ + Stand-in for core.db_helper.DB. + + Every method returns whatever was queued for it under the same name, so a + test can slam any response through the routes without a database. + """ + + def __init__(self, **results): + self.results = results + self.calls = [] + + def __getattr__(self, name): + def method(*args, **kwargs): + self.calls.append((name, args, kwargs)) + try: + return self.results[name] + except KeyError: + raise AssertionError( + f"FakeDB got an unexpected call to {name}()" + ) from None + + return method + + +@pytest.fixture +def client(create_app): + """Build a test client against a FakeDB configured per-test.""" + + def _build(**results): + db = FakeDB(**results) + app = create_app(db=db) + app.config.update(TESTING=True) + test_client = app.test_client() + test_client.db = db + return test_client + + return _build diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py new file mode 100644 index 0000000..c3df553 --- /dev/null +++ b/tests/test_api_routes.py @@ -0,0 +1,306 @@ +""" +Route-level tests for the Flask API. + +These ensure the response contract. Every endpoint returns a JSON *object* +carrying `status`, and the HTTP status code reflects that status. +""" + +ROW = {"id": 1, "name": "Chat Log", "value": "123"} +ERROR = {"status": "error", "message": "Database error while doing a thing"} + + +def test_api_healthcheck_is_an_object(client): + response = client().get("/hc_api") + + assert response.status_code == 200 + assert response.get_json() == {"status": "ok"} + + +def test_db_healthcheck_ok(client): + response = client(database_health_check={"status": "ok"}).get("/hc_db") + + assert response.status_code == 200 + assert response.get_json()["status"] == "ok" + + +def test_db_healthcheck_unhealthy_is_503(client): + c = client( + database_health_check={"status": "unhealthy", "message": "Database unreachable"} + ) + response = c.get("/hc_db") + + assert response.status_code == 503 + assert response.get_json()["status"] == "unhealthy" + + +# -------------------------------------------------------------------------- +# The three id/name/value resources share a structure, so they share these tests. +# (path, all-key, one-key, db method names) +# -------------------------------------------------------------------------- +RESOURCES = [ + ("/logging", "log_settings", "log_setting", "get_log_settings", "get_log_setting", "update_logging"), + ("/settings", "settings", "setting", "get_settings", "get_setting", "update_setting"), + ("/role", "roles", "role", "get_roles", "get_role", "update_role"), +] + + +def test_get_all_returns_a_list_under_the_plural_key(client): + for path, all_key, _, all_method, _, _ in RESOURCES: + c = client(**{all_method: {"status": "ok", all_key: [ROW]}}) + response = c.get(path) + + assert response.status_code == 200, path + body = response.get_json() + assert isinstance(body, dict), f"{path} must return an object, not an array" + assert body[all_key] == [ROW], path + + +def test_get_one_returns_a_dict_row_under_the_singular_key(client): + for path, _, one_key, _, one_method, _ in RESOURCES: + c = client(**{one_method: {"status": "ok", one_key: ROW}}) + response = c.get(f"{path}/1") + + assert response.status_code == 200, path + assert response.get_json()[one_key] == ROW, path + + +def test_get_one_missing_row_is_404(client): + for path, _, _, _, one_method, _ in RESOURCES: + c = client(**{one_method: {"status": "not_found", "message": "nope"}}) + response = c.get(f"{path}/99") + + assert response.status_code == 404, path + + +def test_get_all_db_error_is_500(client): + for path, _, _, all_method, _, _ in RESOURCES: + response = client(**{all_method: ERROR}).get(path) + + assert response.status_code == 500, path + + +def test_update_passes_value_through(client): + for path, _, _, _, _, update_method in RESOURCES: + c = client(**{update_method: {"status": "ok", "message": "updated"}}) + response = c.put(f"{path}/1", json={"value": "999"}) + + assert response.status_code == 200, path + assert c.db.calls == [(update_method, (1, "999"), {})], path + + +def test_update_without_value_is_400(client): + for path, _, _, _, _, update_method in RESOURCES: + c = client(**{update_method: {"status": "ok"}}) + response = c.put(f"{path}/1", json={}) + + assert response.status_code == 400, path + assert c.db.calls == [], f"{path} must not reach the DB on a bad request" + + +def test_update_missing_row_is_404(client): + for path, _, _, _, _, update_method in RESOURCES: + c = client(**{update_method: {"status": "not_found", "message": "nope"}}) + response = c.put(f"{path}/99", json={"value": "1"}) + + assert response.status_code == 404, path + + +# -------------------------------------------------------------------------- +# Points +# -------------------------------------------------------------------------- +def test_get_points_returns_a_scalar(client): + c = client(get_points_for_user={"status": "ok", "points": 42}) + response = c.get("/points/123") + + assert response.status_code == 200 + assert response.get_json()["points"] == 42 + + +def test_get_points_unknown_user_is_404(client): + c = client(get_points_for_user={"status": "not_found", "message": "nope"}) + response = c.get("/points/123") + + assert response.status_code == 404 + + +def test_get_monthly_points_returns_a_scalar(client): + c = client(get_monthly_points_for_user={"status": "ok", "monthly_points": 7}) + response = c.get("/points/monthly/123") + + assert response.status_code == 200 + assert response.get_json()["monthly_points"] == 7 + + +def test_update_points_accepts_an_integer(client): + c = client(update_points={"status": "ok", "message": "Points updated successfully"}) + response = c.post("/points/123/update", json={"value": -5}) + + assert response.status_code == 200 + assert c.db.calls == [("update_points", ("123", -5), {})] + + +def test_update_points_rejects_a_missing_value(client): + c = client(update_points={"status": "ok"}) + response = c.post("/points/123/update", json={}) + + assert response.status_code == 400 + assert c.db.calls == [] + + +def test_update_points_rejects_a_non_integer_value(client): + for bad in ("10", 1.5, True, None, [1]): + c = client(update_points={"status": "ok"}) + response = c.post("/points/123/update", json={"value": bad}) + + assert response.status_code == 400, bad + assert c.db.calls == [], bad + + +def test_update_points_unknown_user_is_404(client): + c = client(update_points={"status": "not_found", "message": "nope"}) + response = c.post("/points/123/update", json={"value": 1}) + + assert response.status_code == 404 + + +def test_add_user_is_201(client): + c = client(add_user_to_points={"status": "ok", "message": "added"}) + response = c.post("/points/123/add") + + assert response.status_code == 201 + + +def test_delete_user_is_200(client): + c = client(remove_user_from_points={"status": "ok", "message": "deleted"}) + response = c.delete("/points/123") + + assert response.status_code == 200 + + +def test_delete_unknown_user_is_404(client): + c = client(remove_user_from_points={"status": "not_found", "message": "nope"}) + response = c.delete("/points/123") + + assert response.status_code == 404 + assert response.get_json()["status"] == "not_found" + + +def test_top10_returns_leaderboard_not_message(client): + rows = [{"discord_id": "1", "points": 10}] + c = client(get_top_10={"status": "ok", "leaderboard": rows}) + response = c.get("/points/top10") + + assert response.status_code == 200 + body = response.get_json() + assert body["leaderboard"] == rows + assert "message" not in body, "payloads must not travel under `message`" + + +def test_monthly_top10_returns_leaderboard(client): + rows = [{"discord_id": "1", "monthly_points": 3}] + c = client(get_monthly_top_10={"status": "ok", "leaderboard": rows}) + response = c.get("/points/monthly/top10") + + assert response.status_code == 200 + assert response.get_json()["leaderboard"] == rows + + +def test_monthly_top_earner(client): + row = {"discord_id": "1", "monthly_points": 3} + c = client(get_monthly_top_point_earner={"status": "ok", "top_earner": row}) + response = c.get("/points/monthly/top") + + assert response.status_code == 200 + assert response.get_json()["top_earner"] == row + + +def test_monthly_top_earner_with_no_users_is_404(client): + c = client(get_monthly_top_point_earner={"status": "not_found", "message": "nope"}) + response = c.get("/points/monthly/top") + + assert response.status_code == 404 + + +def test_reset_monthly_points(client): + c = client(reset_monthly_points={"status": "ok", "message": "reset"}) + response = c.delete("/points/monthly/reset") + + assert response.status_code == 200 + + +def test_static_point_routes_are_not_shadowed_by_the_user_id_route(client): + """`/points/top10` must not be read as a user id of "top10".""" + c = client(get_top_10={"status": "ok", "leaderboard": []}) + response = c.get("/points/top10") + + assert response.status_code == 200 + assert [call[0] for call in c.db.calls] == ["get_top_10"] + + +# -------------------------------------------------------------------------- +# Parameters +# -------------------------------------------------------------------------- +def test_get_parameter_returns_the_value(client): + c = client(get_parameter={"status": "ok", "parameter": "42"}) + response = c.get("/parameters/monthly_yapper") + + assert response.status_code == 200 + assert response.get_json()["parameter"] == "42" + + +def test_get_unknown_parameter_is_404(client): + c = client(get_parameter={"status": "not_found", "message": "nope"}) + response = c.get("/parameters/nope") + + assert response.status_code == 404 + + +def test_set_parameter_takes_the_value_from_the_body(client): + c = client(set_parameter={"status": "ok", "message": "set"}) + response = c.put("/parameters/monthly_yapper", json={"value": "a/b"}) + + assert response.status_code == 200 + assert c.db.calls == [("set_parameter", ("monthly_yapper", "a/b"), {})] + + +def test_set_parameter_without_value_is_400(client): + c = client(set_parameter={"status": "ok"}) + response = c.put("/parameters/monthly_yapper", json={}) + + assert response.status_code == 400 + assert c.db.calls == [] + + +# -------------------------------------------------------------------------- +# Error handling +# -------------------------------------------------------------------------- +def test_unknown_route_returns_the_envelope(client): + response = client().get("/xarlos-is-a-god") + + assert response.status_code == 404 + assert response.get_json()["status"] == "error" + + +def test_wrong_method_returns_405(client): + response = client().post("/hc_api") + + assert response.status_code == 405 + + +def test_unhandled_exception_does_not_leak_the_message(client): + class Boom: + def get_roles(self): + raise RuntimeError("password=d33znu7z") + + def __getattr__(self, name): + raise AttributeError(name) + + app = client().application + app.db = Boom() + app.config["TESTING"] = False + response = app.test_client().get("/role") + + assert response.status_code == 500 + body = response.get_json() + assert body["status"] == "error" + assert "d33znu7z" not in response.get_data(as_text=True) diff --git a/uv.lock b/uv.lock index e2e449e..0a9c23b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -15,6 +15,7 @@ members = [ [manifest.dependency-groups] dev = [ { name = "pre-commit", specifier = ">=4.6.0" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.15" }, ] @@ -372,6 +373,7 @@ dependencies = [ { name = "flask" }, { name = "gunicorn" }, { name = "psycopg", extra = ["binary"] }, + { name = "psycopg-pool" }, ] [package.metadata] @@ -379,6 +381,7 @@ requires-dist = [ { name = "flask", specifier = "==3.1.3" }, { name = "gunicorn", specifier = "==26.0.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, + { name = "psycopg-pool", specifier = "==3.2.6" }, ] [[package]] @@ -582,6 +585,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -792,6 +804,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.6.0" @@ -960,6 +981,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] +[[package]] +name = "psycopg-pool" +version = "3.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/13/1e7850bb2c69a63267c3dbf37387d3f71a00fd0e2fa55c5db14d64ba1af4/psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5", size = 29770, upload-time = "2025-02-26T12:03:47.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/fd/4feb52a55c1a4bd748f2acaed1903ab54a723c47f6d0242780f4d97104d4/psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7", size = 38252, upload-time = "2025-02-26T12:03:45.073Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1050,6 +1083,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-discovery" version = "1.4.0" From 80acb35273c9e27d615ba08a6d10c9fbc3f75fec Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:53:48 +0200 Subject: [PATCH 10/13] update postman collection --- .postman/eos.postman_collection.json | 946 ++++++++++++++------------- src/api/__init__ .py | 0 2 files changed, 505 insertions(+), 441 deletions(-) delete mode 100644 src/api/__init__ .py diff --git a/.postman/eos.postman_collection.json b/.postman/eos.postman_collection.json index 29bb5e2..5683632 100644 --- a/.postman/eos.postman_collection.json +++ b/.postman/eos.postman_collection.json @@ -1,443 +1,507 @@ { - "info": { - "_postman_id": "fa4768f2-d289-4f78-9ad4-e446b125a976", - "name": "eos", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "38158174" - }, - "item": [ - { - "name": "Healthchecks", - "item": [ - { - "name": "GET Database HC", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/hc_db", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "hc_db" - ] - } - }, - "response": [] - }, - { - "name": "GET API HC", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/hc_api", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "hc_api" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Settings", - "item": [ - { - "name": "GET one setting", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/settings/3", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "settings", - "3" - ] - } - }, - "response": [] - }, - { - "name": "GET all settings", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/settings", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "settings" - ] - } - }, - "response": [] - }, - { - "name": "GET logging settings", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/log_settings", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "log_settings" - ] - } - }, - "response": [] - }, - { - "name": "ADD setting", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\"name\" : \"flag5\", \"value\": 1}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/settings", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "settings" - ], - "query": [ - { - "key": "", - "value": "", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "UPDATE setting", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\"value\": 0}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/settings/4", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "settings", - "4" - ], - "query": [ - { - "key": "", - "value": null, - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "DELETE setting", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{BASE_URL}}/settings/4", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "settings", - "4" - ], - "query": [ - { - "key": "", - "value": null, - "disabled": true - } - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Roles", - "item": [ - { - "name": "GET one role", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/role/7", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "role", - "7" - ], - "query": [ - { - "key": null, - "value": "", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "GET all roles", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/role", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "role" - ] - } - }, - "response": [] - }, - { - "name": "ADD role", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\"name\" : \"NewRole-OMG!\", \"value\": 1}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/role", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "role" - ], - "query": [ - { - "key": "", - "value": "", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "UPDATE role", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\"value\": 0}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/role/4", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "role", - "4" - ], - "query": [ - { - "key": "", - "value": null, - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "DELETE role", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{BASE_URL}}/role/9", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "role", - "9" - ], - "query": [ - { - "key": "", - "value": null, - "disabled": true - } - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Points", - "item": [ - { - "name": "ADD User to points", - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}/add", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "points", - "{{XARLOS_USERID}}", - "add" - ] - } - }, - "response": [] - }, - { - "name": "DELETE user from points", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "points", - "{{XARLOS_USERID}}" - ] - } - }, - "response": [] - }, - { - "name": "GET user points", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "points", - "{{XARLOS_USERID}}" - ] - } - }, - "response": [] - }, - { - "name": "GET Top 10", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{BASE_URL}}/points/top10", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "points", - "top10" - ] - } - }, - "response": [] - }, - { - "name": "UPDATE points", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}/update", - "host": [ - "{{BASE_URL}}" - ], - "path": [ - "points", - "{{XARLOS_USERID}}", - "update" - ] - } - }, - "response": [] - } - ] - } - ] + "info": { + "_postman_id": "fa4768f2-d289-4f78-9ad4-e446b125a976", + "name": "eos", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "38158174" + }, + "item": [ + { + "name": "Healthchecks", + "item": [ + { + "name": "GET API HC", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/hc_api", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "hc_api" + ] + } + }, + "response": [] + }, + { + "name": "GET Database HC", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/hc_db", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "hc_db" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Settings", + "item": [ + { + "name": "GET all settings", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/settings", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "settings" + ] + } + }, + "response": [] + }, + { + "name": "GET one setting", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/settings/3", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "settings", + "3" + ] + } + }, + "response": [] + }, + { + "name": "UPDATE setting", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"value\": \"0\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/settings/4", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "settings", + "4" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Logging", + "item": [ + { + "name": "GET all log settings", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/logging", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "logging" + ] + } + }, + "response": [] + }, + { + "name": "GET one log setting", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/logging/3", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "logging", + "3" + ] + } + }, + "response": [] + }, + { + "name": "UPDATE log setting", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"value\": \"0\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/logging/3", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "logging", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Roles", + "item": [ + { + "name": "GET all roles", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/role", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "role" + ] + } + }, + "response": [] + }, + { + "name": "GET one role", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/role/7", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "role", + "7" + ] + } + }, + "response": [] + }, + { + "name": "UPDATE role", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"value\": \"0\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/role/4", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "role", + "4" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Points", + "item": [ + { + "name": "ADD user", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}/add", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "{{XARLOS_USERID}}", + "add" + ] + } + }, + "response": [] + }, + { + "name": "GET points", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "{{XARLOS_USERID}}" + ] + } + }, + "response": [] + }, + { + "name": "UPDATE points", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"value\": 10}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}/update", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "{{XARLOS_USERID}}", + "update" + ] + } + }, + "response": [] + }, + { + "name": "DELETE user", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/{{XARLOS_USERID}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "{{XARLOS_USERID}}" + ] + } + }, + "response": [] + }, + { + "name": "GET top 10", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/top10", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "top10" + ] + } + }, + "response": [] + }, + { + "name": "GET monthly points", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/monthly/{{XARLOS_USERID}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "monthly", + "{{XARLOS_USERID}}" + ] + } + }, + "response": [] + }, + { + "name": "GET monthly top earner", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/monthly/top", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "monthly", + "top" + ] + } + }, + "response": [] + }, + { + "name": "GET monthly top 10", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/monthly/top10", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "monthly", + "top10" + ] + } + }, + "response": [] + }, + { + "name": "RESET monthly points", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/points/monthly/reset", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "points", + "monthly", + "reset" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Parameters", + "item": [ + { + "name": "GET parameter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/parameters/monthly_yapper", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "parameters", + "monthly_yapper" + ] + } + }, + "response": [] + }, + { + "name": "SET parameter", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"value\": \"0\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/parameters/monthly_yapper", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "parameters", + "monthly_yapper" + ] + } + }, + "response": [] + } + ] + } + ] } diff --git a/src/api/__init__ .py b/src/api/__init__ .py deleted file mode 100644 index e69de29..0000000 From 6fdd12c03f78ae4ebbaa9c18770f819a74f2cf78 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:16:29 +0200 Subject: [PATCH 11/13] fixing tests so they work! --- src/api/routes/points.py | 2 +- src/bot/main.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/api/routes/points.py b/src/api/routes/points.py index ec22cbd..c22e4c6 100644 --- a/src/api/routes/points.py +++ b/src/api/routes/points.py @@ -48,7 +48,7 @@ def add_user_to_points(user_id): """ Add a new user to the points table. """ - return respond(eos.db.add_user_to_points(user_id), ok_code=201) + return respond(eos.db.add_user_to_points(user_id), w0o0o=201) @points.route("/points/", methods=["DELETE"]) diff --git a/src/bot/main.py b/src/bot/main.py index 86d7e45..d853eaf 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -16,7 +16,24 @@ ) intents = discord.Intents.all() -bot = commands.Bot(command_prefix=os.getenv("PREFIX"), intents=intents) + + +class Eos(commands.Bot): + """ + The bot + """ + + async def close(self) -> None: + """ + Closes the shared API session before disconnecting from Discord. + """ + logger.debug("Executing shutdown tasks...") + if getattr(self, "api", None) is not None: + await self.api.close() + await super().close() + + +bot = Eos(command_prefix=os.getenv("PREFIX"), intents=intents) bot.boot_time = datetime.datetime.now() From e5fc04470ce5eb15ca4f3de232e7b7ee03205f64 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:20:13 +0200 Subject: [PATCH 12/13] fix pre-commit --- tests/conftest.py | 4 +--- tests/test_api_routes.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6e35c1a..606db2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,9 +15,7 @@ def _is_clashing(name: str) -> bool: def _purge() -> dict: return { - name: sys.modules.pop(name) - for name in list(sys.modules) - if _is_clashing(name) + name: sys.modules.pop(name) for name in list(sys.modules) if _is_clashing(name) } diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index c3df553..b11364c 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -38,8 +38,22 @@ def test_db_healthcheck_unhealthy_is_503(client): # (path, all-key, one-key, db method names) # -------------------------------------------------------------------------- RESOURCES = [ - ("/logging", "log_settings", "log_setting", "get_log_settings", "get_log_setting", "update_logging"), - ("/settings", "settings", "setting", "get_settings", "get_setting", "update_setting"), + ( + "/logging", + "log_settings", + "log_setting", + "get_log_settings", + "get_log_setting", + "update_logging", + ), + ( + "/settings", + "settings", + "setting", + "get_settings", + "get_setting", + "update_setting", + ), ("/role", "roles", "role", "get_roles", "get_role", "update_role"), ] From 1bc09b7fafb57c0bcbb9becd83dd4223df1478e6 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:44:17 +0200 Subject: [PATCH 13/13] exclude tests from pre-commit in CI + .env.EXAMPLE updates --- .pre-commit-config.yaml | 4 +++- src/.env.EXAMPLE | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af25ab6..c984c78 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,9 @@ repos: rev: 1.8.6 hooks: - id: bandit - # args: [--skip=B101] # Skip "assert_used" warnings + # B101 (assert_used) is just how pytest asserts, so skip the test suite + # rather than disabling the check globally. + exclude: ^tests/ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.15 diff --git a/src/.env.EXAMPLE b/src/.env.EXAMPLE index eeff674..509753a 100644 --- a/src/.env.EXAMPLE +++ b/src/.env.EXAMPLE @@ -4,7 +4,6 @@ PREFIX=> TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE - ############################## ### ADVANCED SETTINGS ### ############################## @@ -18,8 +17,8 @@ STREAM_LOGS=True # Database — don't change unless you know what you're doing POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -POSTGRES_PORT_HOST=5432 +POSTGRES_PORT=5432 # Internal post for migrations container to access postgres +POSTGRES_PORT_HOST=5432 # external mapping outside the container, for API. PGUSER=postgres # POSTGRES_USER must match PGUSER — the postgres image uses this to create the superuser POSTGRES_USER=postgres @@ -36,3 +35,4 @@ FLASK_URL=http://${FLASK_RUN_HOST}:${FLASK_RUN_PORT} # The guild which controls the DB and moderation MASTER_GUILD= +PISTON_API_TOKEN=null # for running >run commands. Received from EngineerMan's Piston project.