diff --git a/.env.example b/.env.example index b3bbacb7..921ffd29 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ POSTGRESQL_PORT=5432 POSTGRESQL_USER=postgres POSTGRESQL_PASSWORD=postgres POSTGRESQL_DATABASE=discordbot -POSTGRESQL_SCHEMA=public +POSTGRESQL_SCHEMA=public,gw2 # Session settings POSTGRESQL_ECHO=false POSTGRESQL_AUTOFLUSH=false diff --git a/pyproject.toml b/pyproject.toml index 0c00ec5c..4b75d47c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,12 +33,13 @@ dependencies = [ "alembic>=1.18.4", "beautifulsoup4>=4.14.3", "better-profanity>=0.7.0", - "ddcdatabases[postgres]>=3.0.10", + "ddcdatabases[postgres]>=3.0.11", "discord-py>=2.6.4", "gTTS>=2.5.4", "openai>=2.24.0", "PyNaCl>=1.6.2", - "pythonLogs>=6.0.2", + "pythonLogs>=6.0.3", + "uuid-utils>=0.14.1", ] [dependency-groups] diff --git a/src/database/dal/bot/bot_configs_dal.py b/src/database/dal/bot/bot_configs_dal.py index a15b00d4..adeb9ca8 100644 --- a/src/database/dal/bot/bot_configs_dal.py +++ b/src/database/dal/bot/bot_configs_dal.py @@ -11,11 +11,11 @@ def __init__(self, db_session, log): self.log = log async def update_bot_prefix(self, prefix: str): - stmt = sa.update(BotConfigs).where(BotConfigs.id == 1).values(prefix=prefix) + stmt = sa.update(BotConfigs).values(prefix=prefix) await self.db_utils.execute(stmt) async def update_bot_description(self, description: str): - stmt = sa.update(BotConfigs).where(BotConfigs.id == 1).values(description=description) + stmt = sa.update(BotConfigs).values(description=description) await self.db_utils.execute(stmt) async def get_bot_configs(self): @@ -24,6 +24,6 @@ async def get_bot_configs(self): return results async def get_bot_prefix(self): - stmt = select(BotConfigs.prefix).where(BotConfigs.id == 1) + stmt = select(BotConfigs.prefix) results = await self.db_utils.fetchvalue(stmt) return results diff --git a/src/database/dal/gw2/gw2_session_chars_dal.py b/src/database/dal/gw2/gw2_session_chars_dal.py index b53bebc0..6f350f97 100644 --- a/src/database/dal/gw2/gw2_session_chars_dal.py +++ b/src/database/dal/gw2/gw2_session_chars_dal.py @@ -1,41 +1,40 @@ from ddcDatabases import DBUtilsAsync -from sqlalchemy import delete +from sqlalchemy import update from sqlalchemy.future import select -from src.database.models.gw2_models import Gw2SessionChars +from src.database.models.gw2_models import Gw2SessionCharDeaths -class Gw2SessionCharsDal: +class Gw2SessionCharDeathsDal: def __init__(self, db_session, log): - self.columns = list(Gw2SessionChars.__table__.columns.values()) + self.columns = list(Gw2SessionCharDeaths.__table__.columns.values()) self.db_utils = DBUtilsAsync(db_session) self.log = log - async def insert_session_char(self, characters_data: list[dict], insert_args: dict): + async def insert_start_char_deaths(self, session_id, user_id: int, characters_data: list[dict]): for char in characters_data: - stmt = Gw2SessionChars( - session_id=insert_args["session_id"], - user_id=insert_args["user_id"], + stmt = Gw2SessionCharDeaths( + session_id=session_id, + user_id=user_id, name=char["name"], profession=char["profession"], - deaths=char["deaths"], - start=insert_args["start"], - end=insert_args["end"], + start=char["deaths"], + end=None, ) await self.db_utils.insert(stmt) - async def delete_end_characters(self, session_id: int): - stmt = delete(Gw2SessionChars).where( - Gw2SessionChars.session_id == session_id, - Gw2SessionChars.end.is_(True), - ) - await self.db_utils.execute(stmt) - - async def get_all_start_characters(self, user_id: int): - stmt = select(*self.columns).where(Gw2SessionChars.user_id == user_id, Gw2SessionChars.start.is_(True)) - results = await self.db_utils.fetchall(stmt, True) - return results + async def update_end_char_deaths(self, session_id, user_id: int, characters_data: list[dict]): + for char in characters_data: + stmt = ( + update(Gw2SessionCharDeaths) + .where( + Gw2SessionCharDeaths.session_id == session_id, + Gw2SessionCharDeaths.name == char["name"], + ) + .values(end=char["deaths"]) + ) + await self.db_utils.execute(stmt) - async def get_all_end_characters(self, user_id: int): - stmt = select(*self.columns).where(Gw2SessionChars.user_id == user_id, Gw2SessionChars.end.is_(True)) + async def get_char_deaths(self, user_id: int): + stmt = select(*self.columns).where(Gw2SessionCharDeaths.user_id == user_id) results = await self.db_utils.fetchall(stmt, True) return results diff --git a/src/database/dal/gw2/gw2_sessions_dal.py b/src/database/dal/gw2/gw2_sessions_dal.py index f7880863..07fb6005 100644 --- a/src/database/dal/gw2/gw2_sessions_dal.py +++ b/src/database/dal/gw2/gw2_sessions_dal.py @@ -1,7 +1,7 @@ import sqlalchemy as sa from ddcDatabases import DBUtilsAsync from sqlalchemy.future import select -from src.database.models.gw2_models import Gw2SessionChars, Gw2Sessions +from src.database.models.gw2_models import Gw2SessionCharDeaths, Gw2Sessions class Gw2SessionsDal: @@ -11,8 +11,8 @@ def __init__(self, db_session, log): self.log = log async def insert_start_session(self, session: dict): - stmt = sa.delete(Gw2SessionChars).where( - Gw2SessionChars.user_id == session["user_id"], + stmt = sa.delete(Gw2SessionCharDeaths).where( + Gw2SessionCharDeaths.user_id == session["user_id"], ) await self.db_utils.execute(stmt) diff --git a/src/database/migrations/env.py b/src/database/migrations/env.py index ab9f36aa..c6f5e812 100644 --- a/src/database/migrations/env.py +++ b/src/database/migrations/env.py @@ -29,6 +29,8 @@ ) config.set_main_option("sqlalchemy.url", _conn_url) +_schemas = {s.strip() for s in (_postgres_settings.schema or "public").split(",")} + def _include_object( obj: SchemaItem, @@ -38,10 +40,13 @@ def _include_object( _compare_to: SchemaItem | None, ) -> bool | None: """ - Filter to only include objects from our target schema. + Filter to only include objects from our target schemas. This prevents Alembic from trying to manage tables in other schemas. """ - if type_ == "table" and hasattr(obj, "schema") and obj.schema != _postgres_settings.schema: # type: ignore[attr-defined] + if type_ == "table" and hasattr(obj, "schema"): + obj_schema = obj.schema # type: ignore[attr-defined] + if obj_schema is None or obj_schema in _schemas: + return True return False return True @@ -57,6 +62,9 @@ def _process_revision_directives(ctx: Any, revision: Any, directives: Any) -> No migration_script.rev_id = f"{new_rev_id:04}" +_version_table_schema = "public" if len(_schemas) > 1 else next(iter(_schemas)) + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -77,15 +85,16 @@ def run_migrations_offline() -> None: literal_binds=True, dialect_opts={"paramstyle": "named"}, process_revision_directives=_process_revision_directives, - version_table_schema=_postgres_settings.schema, + version_table_schema=_version_table_schema, version_table=_project_settings.alembic_version_table_name, include_schemas=True, include_object=_include_object, ) with context.begin_transaction(): - # Ensure the schema exists before Alembic tries to create its version table - context.execute(f"CREATE SCHEMA IF NOT EXISTS {_postgres_settings.schema}") + for s in _schemas: + if s != "public": + context.execute(f"CREATE SCHEMA IF NOT EXISTS {s}") context.run_migrations() @@ -103,15 +112,16 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - # Ensure the schema exists before Alembic tries to create its version table - connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {_postgres_settings.schema}")) + for s in _schemas: + if s != "public": + connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {s}")) connection.commit() context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=_process_revision_directives, - version_table_schema=_postgres_settings.schema, + version_table_schema=_version_table_schema, version_table=_project_settings.alembic_version_table_name, include_schemas=True, include_object=_include_object, diff --git a/src/database/migrations/versions/0001_create_functions.py b/src/database/migrations/versions/0001_create_functions.py index 4f2b7dcb..6f5c75bd 100644 --- a/src/database/migrations/versions/0001_create_functions.py +++ b/src/database/migrations/versions/0001_create_functions.py @@ -15,6 +15,7 @@ branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None db_schema = get_postgresql_settings().schema +_schemas = [s.strip() for s in (db_schema or "public").split(",")] def upgrade() -> None: @@ -27,10 +28,14 @@ def upgrade() -> None: return NEW; END $$; """) - # This function is responsible for schema creation - op.execute(f"CREATE SCHEMA IF NOT EXISTS {db_schema}") + # Create each non-public schema + for s in _schemas: + if s != "public": + op.execute(f"CREATE SCHEMA IF NOT EXISTS {s}") def downgrade() -> None: op.execute("DROP FUNCTION IF EXISTS updated_at_column_func") - op.execute(f"DROP SCHEMA IF EXISTS {db_schema} CASCADE") + for s in _schemas: + if s != "public": + op.execute(f"DROP SCHEMA IF EXISTS {s} CASCADE") diff --git a/src/database/migrations/versions/0002_bot_configs.py b/src/database/migrations/versions/0002_bot_configs.py index 157ce2e4..bd0cd745 100644 --- a/src/database/migrations/versions/0002_bot_configs.py +++ b/src/database/migrations/versions/0002_bot_configs.py @@ -10,7 +10,6 @@ from alembic import op from collections.abc import Sequence from src.bot.constants import variables -from src.database.models.bot_models import BotConfigs # revision identifiers, used by Alembic. revision: str = "0002" @@ -20,10 +19,9 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "bot_configs", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("prefix", sa.CHAR(length=1), server_default=variables.PREFIX, nullable=False), sa.Column("author_id", sa.BigInteger(), server_default=variables.AUTHOR_ID, nullable=False), sa.Column("url", sa.String(), server_default=variables.BOT_WEBPAGE_URL, nullable=False), @@ -34,10 +32,12 @@ def upgrade() -> None: sa.UniqueConstraint("id"), ) op.execute( - sa.insert(BotConfigs).values( - id=1, + sa.text( + "INSERT INTO bot_configs (prefix, author_id, url, description) " + "VALUES (:prefix, :author_id, :url, :description)" + ).bindparams( prefix=variables.PREFIX, - author_id=variables.AUTHOR_ID, + author_id=int(variables.AUTHOR_ID), url=variables.BOT_WEBPAGE_URL, description=variables.DESCRIPTION, ) @@ -48,11 +48,8 @@ def upgrade() -> None: FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.execute("DROP TRIGGER IF EXISTS before_update_bot_configs_tr ON bot_configs") op.drop_table("bot_configs") - # ### end Alembic commands ### diff --git a/src/database/migrations/versions/0003_servers.py b/src/database/migrations/versions/0003_servers.py index d98855ec..f4879eaa 100644 --- a/src/database/migrations/versions/0003_servers.py +++ b/src/database/migrations/versions/0003_servers.py @@ -18,7 +18,6 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "servers", sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), @@ -42,12 +41,9 @@ def upgrade() -> None: FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.execute("DROP TRIGGER IF EXISTS before_update_servers_tr ON servers") op.drop_index(op.f("ix_servers_id"), table_name="servers") op.drop_table("servers") - # ### end Alembic commands ### diff --git a/src/database/migrations/versions/0004_custom_commands.py b/src/database/migrations/versions/0004_custom_commands.py index 11d59658..076a2581 100644 --- a/src/database/migrations/versions/0004_custom_commands.py +++ b/src/database/migrations/versions/0004_custom_commands.py @@ -18,10 +18,9 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "custom_commands", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("server_id", sa.BigInteger(), nullable=False), sa.Column("name", sa.String(), nullable=False), sa.Column("description", sa.String(), nullable=False), @@ -40,12 +39,9 @@ def upgrade() -> None: FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.execute("DROP TRIGGER IF EXISTS before_update_custom_commands_tr ON custom_commands") op.drop_index(op.f("ix_custom_commands_server_id"), table_name="custom_commands") op.drop_table("custom_commands") - # ### end Alembic commands ### diff --git a/src/database/migrations/versions/0005_profanity_filters.py b/src/database/migrations/versions/0005_profanity_filters.py index cf6ad0cd..8c691c29 100644 --- a/src/database/migrations/versions/0005_profanity_filters.py +++ b/src/database/migrations/versions/0005_profanity_filters.py @@ -18,10 +18,9 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "profanity_filters", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("server_id", sa.BigInteger(), nullable=False), sa.Column("channel_id", sa.BigInteger(), nullable=False), sa.Column("channel_name", sa.String(), nullable=False), @@ -39,12 +38,9 @@ def upgrade() -> None: FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.execute("DROP TRIGGER IF EXISTS before_update_profanity_filters_tr ON profanity_filters") op.drop_index(op.f("ix_profanity_filters_server_id"), table_name="profanity_filters") op.drop_table("profanity_filters") - # ### end Alembic commands ### diff --git a/src/database/migrations/versions/0006_dice_rolls.py b/src/database/migrations/versions/0006_dice_rolls.py index 96d69dc1..6fd75192 100644 --- a/src/database/migrations/versions/0006_dice_rolls.py +++ b/src/database/migrations/versions/0006_dice_rolls.py @@ -18,10 +18,9 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "dice_rolls", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("server_id", sa.BigInteger(), nullable=False), sa.Column("user_id", sa.BigInteger(), nullable=False), sa.Column("roll", sa.Integer(), nullable=False), @@ -40,13 +39,10 @@ def upgrade() -> None: FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.execute("DROP TRIGGER IF EXISTS before_update_dice_rolls_tr ON dice_rolls") op.drop_index(op.f("ix_dice_rolls_user_id"), table_name="dice_rolls") op.drop_index(op.f("ix_dice_rolls_server_id"), table_name="dice_rolls") op.drop_table("dice_rolls") - # ### end Alembic commands ### diff --git a/src/database/migrations/versions/0007_gw2_keys.py b/src/database/migrations/versions/0007_gw2_keys.py index 054d20bb..b9d77e96 100644 --- a/src/database/migrations/versions/0007_gw2_keys.py +++ b/src/database/migrations/versions/0007_gw2_keys.py @@ -18,10 +18,9 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "gw2_keys", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("user_id", sa.BigInteger(), nullable=False), sa.Column("name", sa.String(), nullable=True), sa.Column("gw2_acc_name", sa.String(), nullable=False), @@ -33,18 +32,16 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("id"), sa.UniqueConstraint("user_id"), + schema="gw2", ) op.execute(""" CREATE TRIGGER before_update_gw2_keys_tr - BEFORE UPDATE ON gw2_keys + BEFORE UPDATE ON gw2.gw2_keys FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.execute("DROP TRIGGER IF EXISTS before_update_gw2_keys_tr ON gw2_keys") - op.drop_table("gw2_keys") - # ### end Alembic commands ### + op.execute("DROP TRIGGER IF EXISTS before_update_gw2_keys_tr ON gw2.gw2_keys") + op.drop_table("gw2_keys", schema="gw2") diff --git a/src/database/migrations/versions/0008_gw2_configs.py b/src/database/migrations/versions/0008_gw2_configs.py index 770e0387..6b6f240a 100644 --- a/src/database/migrations/versions/0008_gw2_configs.py +++ b/src/database/migrations/versions/0008_gw2_configs.py @@ -18,31 +18,28 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "gw2_configs", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("server_id", sa.BigInteger(), nullable=False), sa.Column("session", sa.Boolean(), server_default="0", nullable=False), sa.Column("updated_by", sa.BigInteger(), nullable=True), sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), sa.Column("created_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), - sa.ForeignKeyConstraint(["server_id"], ["servers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["server_id"], ["public.servers.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("id"), sa.UniqueConstraint("server_id"), + schema="gw2", ) op.execute(""" CREATE TRIGGER before_update_gw2_configs_tr - BEFORE UPDATE ON gw2_configs + BEFORE UPDATE ON gw2.gw2_configs FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.execute("DROP TRIGGER IF EXISTS before_update_gw2_configs_tr ON gw2_configs") - op.drop_table("gw2_configs") - # ### end Alembic commands ### + op.execute("DROP TRIGGER IF EXISTS before_update_gw2_configs_tr ON gw2.gw2_configs") + op.drop_table("gw2_configs", schema="gw2") diff --git a/src/database/migrations/versions/0009_gw2_sessions.py b/src/database/migrations/versions/0009_gw2_sessions.py index 0536d941..c209d424 100644 --- a/src/database/migrations/versions/0009_gw2_sessions.py +++ b/src/database/migrations/versions/0009_gw2_sessions.py @@ -19,10 +19,9 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "gw2_sessions", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), sa.Column("user_id", sa.BigInteger(), nullable=False), sa.Column("acc_name", sa.String(), nullable=False), sa.Column("start", postgresql.JSONB(astext_type=sa.Text()), nullable=False), @@ -31,18 +30,16 @@ def upgrade() -> None: sa.Column("created_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("id"), + schema="gw2", ) op.execute(""" CREATE TRIGGER before_update_gw2_sessions_tr - BEFORE UPDATE ON gw2_sessions + BEFORE UPDATE ON gw2.gw2_sessions FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.execute("DROP TRIGGER IF EXISTS before_update_gw2_sessions_tr ON gw2_sessions") - op.drop_table("gw2_sessions") - # ### end Alembic commands ### + op.execute("DROP TRIGGER IF EXISTS before_update_gw2_sessions_tr ON gw2.gw2_sessions") + op.drop_table("gw2_sessions", schema="gw2") diff --git a/src/database/migrations/versions/0010_gw2_session_chars.py b/src/database/migrations/versions/0010_gw2_session_char_deaths.py similarity index 59% rename from src/database/migrations/versions/0010_gw2_session_chars.py rename to src/database/migrations/versions/0010_gw2_session_char_deaths.py index f8ce8d55..dc63adc1 100644 --- a/src/database/migrations/versions/0010_gw2_session_chars.py +++ b/src/database/migrations/versions/0010_gw2_session_char_deaths.py @@ -1,4 +1,4 @@ -"""gw2_session_chars +"""gw2_session_char_deaths Revision ID: 0010 Revises: 0009 @@ -18,38 +18,33 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "gw2_session_chars", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("session_id", sa.BigInteger(), nullable=False), + "gw2_session_char_deaths", + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("session_id", sa.Uuid(), nullable=False), sa.Column("user_id", sa.BigInteger(), nullable=False), sa.Column("name", sa.String(), nullable=False), sa.Column("profession", sa.String(), nullable=False), - sa.Column("deaths", sa.Integer(), nullable=False), - sa.Column("start", sa.Boolean(), nullable=False), - sa.Column("end", sa.Boolean(), nullable=True), + sa.Column("start", sa.Integer(), nullable=False), + sa.Column("end", sa.Integer(), nullable=True), sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), sa.Column("created_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), sa.ForeignKeyConstraint( ["session_id"], - ["gw2_sessions.id"], + ["gw2.gw2_sessions.id"], ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("id"), - sa.UniqueConstraint("name"), + schema="gw2", ) op.execute(""" - CREATE TRIGGER before_update_gw2_session_chars_tr - BEFORE UPDATE ON gw2_session_chars + CREATE TRIGGER before_update_gw2_session_char_deaths_tr + BEFORE UPDATE ON gw2.gw2_session_char_deaths FOR EACH ROW EXECUTE PROCEDURE updated_at_column_func(); """) - # ### end Alembic commands ### def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.execute("DROP TRIGGER IF EXISTS before_update_gw2_session_chars_tr ON gw2_session_chars") - op.drop_table("gw2_session_chars") - # ### end Alembic commands ### + op.execute("DROP TRIGGER IF EXISTS before_update_gw2_session_char_deaths_tr ON gw2.gw2_session_char_deaths") + op.drop_table("gw2_session_char_deaths", schema="gw2") diff --git a/src/database/migrations/versions/0011_drop_unique_session_chars_name.py b/src/database/migrations/versions/0011_drop_unique_session_chars_name.py deleted file mode 100644 index beebcd78..00000000 --- a/src/database/migrations/versions/0011_drop_unique_session_chars_name.py +++ /dev/null @@ -1,24 +0,0 @@ -"""drop unique constraint on gw2_session_chars.name - -Revision ID: 0011 -Revises: 0010 -Create Date: 2026-02-24 00:00:00.000000 - -""" - -from alembic import op -from collections.abc import Sequence - -# revision identifiers, used by Alembic. -revision: str = "0011" -down_revision: str | None = "0010" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.drop_constraint("gw2_session_chars_name_key", "gw2_session_chars", type_="unique") - - -def downgrade() -> None: - op.create_unique_constraint("gw2_session_chars_name_key", "gw2_session_chars", ["name"]) diff --git a/src/database/models/bot_models.py b/src/database/models/bot_models.py index 687eb621..3845323e 100644 --- a/src/database/models/bot_models.py +++ b/src/database/models/bot_models.py @@ -1,12 +1,14 @@ -from sqlalchemy import CHAR, BigInteger, Boolean, ForeignKey +from sqlalchemy import CHAR, BigInteger, Boolean, ForeignKey, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from src.bot.constants import variables from src.database.models import BotBase +from uuid import UUID +from uuid_utils import uuid7 class BotConfigs(BotBase): __tablename__ = "bot_configs" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) prefix: Mapped[CHAR] = mapped_column(CHAR(1), server_default=variables.PREFIX) author_id: Mapped[int] = mapped_column(BigInteger, server_default=variables.AUTHOR_ID) url: Mapped[str] = mapped_column(server_default=variables.BOT_WEBPAGE_URL) @@ -29,12 +31,16 @@ class Servers(BotBase): custom_commands = relationship("CustomCommands", back_populates="servers") profanity_filters = relationship("ProfanityFilters", back_populates="servers") dice_rolls = relationship("DiceRolls", back_populates="servers") - gw2_configs = relationship("Gw2Configs", back_populates="servers") + gw2_configs = relationship( + "Gw2Configs", + back_populates="servers", + primaryjoin="foreign(Gw2Configs.server_id) == Servers.id", + ) class CustomCommands(BotBase): __tablename__ = "custom_commands" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) server_id: Mapped[int] = mapped_column(BigInteger, ForeignKey(Servers.id, ondelete="CASCADE"), index=True) name: Mapped[str] = mapped_column() description: Mapped[str] = mapped_column() @@ -45,7 +51,7 @@ class CustomCommands(BotBase): class ProfanityFilters(BotBase): __tablename__ = "profanity_filters" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) server_id: Mapped[int] = mapped_column(BigInteger, ForeignKey(Servers.id, ondelete="CASCADE"), index=True) channel_id: Mapped[int] = mapped_column(BigInteger) channel_name: Mapped[str] = mapped_column() @@ -55,7 +61,7 @@ class ProfanityFilters(BotBase): class DiceRolls(BotBase): __tablename__ = "dice_rolls" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) server_id: Mapped[int] = mapped_column(BigInteger, ForeignKey(Servers.id, ondelete="CASCADE"), index=True) user_id: Mapped[int] = mapped_column(BigInteger, index=True) roll: Mapped[int] = mapped_column() diff --git a/src/database/models/gw2_models.py b/src/database/models/gw2_models.py index 498e7b50..55fcdf42 100644 --- a/src/database/models/gw2_models.py +++ b/src/database/models/gw2_models.py @@ -1,14 +1,16 @@ -from sqlalchemy import BigInteger, Boolean, ForeignKey +from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, Uuid from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from src.database.models import BotBase -from src.database.models.bot_models import Servers from typing import Any +from uuid import UUID +from uuid_utils import uuid7 class Gw2Keys(BotBase): __tablename__ = "gw2_keys" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + __table_args__ = {"schema": "gw2"} + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) user_id: Mapped[int] = mapped_column(BigInteger, unique=True) name: Mapped[str] = mapped_column(nullable=True) gw2_acc_name: Mapped[str] = mapped_column() @@ -19,31 +21,37 @@ class Gw2Keys(BotBase): class Gw2Configs(BotBase): __tablename__ = "gw2_configs" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - server_id: Mapped[int] = mapped_column(BigInteger, ForeignKey(Servers.id, ondelete="CASCADE"), unique=True) + __table_args__ = {"schema": "gw2"} + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + server_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("servers.id", ondelete="CASCADE"), unique=True) session: Mapped[Boolean] = mapped_column(Boolean, server_default="0") updated_by: Mapped[int] = mapped_column(BigInteger, nullable=True) - servers = relationship("Servers", back_populates="gw2_configs") + servers = relationship( + "Servers", + back_populates="gw2_configs", + primaryjoin="foreign(Gw2Configs.server_id) == Servers.id", + ) class Gw2Sessions(BotBase): __tablename__ = "gw2_sessions" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + __table_args__ = {"schema": "gw2"} + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) user_id: Mapped[int] = mapped_column(BigInteger) acc_name: Mapped[str] = mapped_column() start: Mapped[dict[str, Any]] = mapped_column(JSONB) end: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=True) - session_chars = relationship("Gw2SessionChars", back_populates="session") + session_char_deaths = relationship("Gw2SessionCharDeaths", back_populates="session") -class Gw2SessionChars(BotBase): - __tablename__ = "gw2_session_chars" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - session_id: Mapped[int] = mapped_column(BigInteger, ForeignKey(Gw2Sessions.id)) +class Gw2SessionCharDeaths(BotBase): + __tablename__ = "gw2_session_char_deaths" + __table_args__ = {"schema": "gw2"} + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + session_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("gw2.gw2_sessions.id")) user_id: Mapped[int] = mapped_column(BigInteger) name: Mapped[str] = mapped_column() profession: Mapped[str] = mapped_column() - deaths: Mapped[int] = mapped_column() - start: Mapped[Boolean] = mapped_column(Boolean) - end: Mapped[Boolean] = mapped_column(Boolean, nullable=True) - session = relationship("Gw2Sessions", back_populates="session_chars") + start: Mapped[int] = mapped_column(Integer) + end: Mapped[int | None] = mapped_column(Integer, nullable=True) + session = relationship("Gw2Sessions", back_populates="session_char_deaths") diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index 1578f796..7c37fd20 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -3,7 +3,7 @@ from src.bot.tools import bot_utils, chat_formatting from src.database.dal.gw2.gw2_configs_dal import Gw2ConfigsDal from src.database.dal.gw2.gw2_key_dal import Gw2KeyDal -from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal +from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharDeathsDal from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal from src.gw2.cogs.gw2 import GuildWars2 from src.gw2.constants import gw2_messages @@ -141,11 +141,10 @@ async def session(ctx): _add_gold_field(embed, rs_start, rs_end) # Deaths - gw2_session_chars_dal = Gw2SessionCharsDal(ctx.bot.db_session, ctx.bot.log) - rs_chars_start = await gw2_session_chars_dal.get_all_start_characters(user_id) - if rs_chars_start: - rs_chars_end = await gw2_session_chars_dal.get_all_end_characters(user_id) - _add_deaths_field(embed, rs_chars_start, rs_chars_end) + gw2_session_chars_dal = Gw2SessionCharDeathsDal(ctx.bot.db_session, ctx.bot.log) + char_deaths = await gw2_session_chars_dal.get_char_deaths(user_id) + if char_deaths: + _add_deaths_field(embed, char_deaths) # WvW achievement-based stats _add_wvw_stats(embed, rs_start, rs_end) @@ -200,33 +199,21 @@ def _add_gold_field(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: embed.add_field(name="Gold", value=chat_formatting.inline(str(final_result)), inline=False) -def _add_deaths_field(embed: discord.Embed, rs_chars_start: list[dict], rs_chars_end: list[dict]) -> None: +def _add_deaths_field(embed: discord.Embed, char_deaths: list[dict]) -> None: """Add deaths field to embed. - Uses a dict keyed on character name to deduplicate entries from - multiple guild event firings. + Each row has start and end deaths; skip chars where end is None. """ - if not rs_chars_end: - return - - # Build lookup from end chars, deduplicating by name (keep first occurrence) - end_lookup: dict[str, dict] = {} - for char_end in rs_chars_end: - name = char_end["name"] - if name not in end_lookup: - end_lookup[name] = char_end - death_lines: list[str] = [] total_deaths = 0 - for char_start in rs_chars_start: - name = char_start["name"] - char_end = end_lookup.get(name) - if char_end and char_start["deaths"] != char_end["deaths"]: - profession = char_start["profession"] - time_deaths = int(char_end["deaths"]) - int(char_start["deaths"]) + for char in char_deaths: + if char["end"] is None: + continue + if char["start"] != char["end"]: + time_deaths = int(char["end"]) - int(char["start"]) total_deaths += time_deaths - death_lines.append(f"{name} ({profession}): {time_deaths}") + death_lines.append(f"{char['name']} ({char['profession']}): {time_deaths}") if death_lines: death_lines.append(f"Total: {total_deaths}") diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 7be4375d..adb3b406 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -20,7 +20,7 @@ def __init__(self): from src.database.dal.gw2.gw2_configs_dal import Gw2ConfigsDal from src.database.dal.gw2.gw2_key_dal import Gw2KeyDal -from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal +from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharDeathsDal from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal from src.gw2.constants import gw2_messages from src.gw2.constants.gw2_currencies import ACHIEVEMENT_MAPPING, WALLET_MAPPING @@ -395,7 +395,7 @@ async def _do_start_session(bot: Bot, member: discord.Member, api_key: str, sess except Exception as e: bot.log.error(f"Failed to insert start session into DB for user {member.id}: {e}") return - await insert_session_char(bot, member, api_key, session_id, "start") + await insert_start_char_deaths(bot, member, api_key, session_id) async def end_session(bot: Bot, member: discord.Member, api_key: str) -> None: @@ -427,15 +427,7 @@ async def _do_end_session(bot: Bot, member: discord.Member, api_key: str, sessio bot.log.warning(f"No active session found for user {member.id}, skipping end session chars") return bot.log.debug(f"Successfully updated end session {session_id} for user {member.id}") - bot.log.debug(f"Deleting previous end characters for session {session_id}") - try: - gw2_session_chars_dal = Gw2SessionCharsDal(bot.db_session, bot.log) - await gw2_session_chars_dal.delete_end_characters(session_id) - bot.log.debug(f"Successfully deleted end characters for session {session_id}") - except Exception as e: - bot.log.error(f"Failed to delete end characters for session {session_id}: {e}") - return - await insert_session_char(bot, member, api_key, session_id, "end") + await update_end_char_deaths(bot, member, api_key, session_id) async def _retry_session_later(bot: Bot, member: discord.Member, api_key: str, session_type: str) -> None: @@ -534,30 +526,34 @@ def _update_achievement_stats(user_stats: dict, achievements_data: list[dict]) - user_stats[stat_name] = achievement.get("current", 0) -async def insert_session_char( - bot: Bot, member: discord.Member, api_key: str, session_id: int, session_type: str -) -> None: - """Insert session character data.""" - bot.log.debug(f"Attempting to insert {session_type} session chars for session {session_id}, user {member.id}") +async def insert_start_char_deaths(bot: Bot, member: discord.Member, api_key: str, session_id) -> None: + """Insert start session character death data.""" + bot.log.debug(f"Attempting to insert start char deaths for session {session_id}, user {member.id}") try: gw2_api = Gw2Client(bot) characters_data = await gw2_api.call_api("characters?ids=all", api_key) - insert_args = { - "session_id": session_id, - "user_id": member.id, - "start": session_type == "start", - "end": session_type == "end", - } + gw2_session_chars_dal = Gw2SessionCharDeathsDal(bot.db_session, bot.log) + await gw2_session_chars_dal.insert_start_char_deaths(session_id, member.id, characters_data) + bot.log.debug(f"Successfully inserted start char deaths for session {session_id}, user {member.id}") + + except Exception as e: + bot.log.error(f"Error inserting start session character data for session {session_id}, user {member.id}: {e}") + + +async def update_end_char_deaths(bot: Bot, member: discord.Member, api_key: str, session_id) -> None: + """Update end session character death data.""" + bot.log.debug(f"Attempting to update end char deaths for session {session_id}, user {member.id}") + try: + gw2_api = Gw2Client(bot) + characters_data = await gw2_api.call_api("characters?ids=all", api_key) - gw2_session_chars_dal = Gw2SessionCharsDal(bot.db_session, bot.log) - await gw2_session_chars_dal.insert_session_char(characters_data, insert_args) - bot.log.debug(f"Successfully inserted {session_type} session chars for session {session_id}, user {member.id}") + gw2_session_chars_dal = Gw2SessionCharDeathsDal(bot.db_session, bot.log) + await gw2_session_chars_dal.update_end_char_deaths(session_id, member.id, characters_data) + bot.log.debug(f"Successfully updated end char deaths for session {session_id}, user {member.id}") except Exception as e: - bot.log.error( - f"Error inserting {session_type} session character data for session {session_id}, user {member.id}: {e}" - ) + bot.log.error(f"Error updating end session character data for session {session_id}, user {member.id}: {e}") def get_wvw_rank_title(rank: int) -> str: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d2cd7657..23d78935 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -49,7 +49,7 @@ def setup_env_and_run_migrations(postgres_container): os.environ["POSTGRESQL_USER"] = user os.environ["POSTGRESQL_PASSWORD"] = password os.environ["POSTGRESQL_DATABASE"] = database - os.environ["POSTGRESQL_SCHEMA"] = "public" + os.environ["POSTGRESQL_SCHEMA"] = "gw2,public" os.environ["POSTGRESQL_SSL_MODE"] = "disable" from ddcDatabases import clear_postgresql_settings_cache @@ -74,8 +74,7 @@ def db_url(): _SEED_BOT_CONFIGS = sa.text( - "INSERT INTO bot_configs (id, prefix, author_id, url, description) " - "VALUES (1, :prefix, :author_id, :url, :description)" + "INSERT INTO bot_configs (prefix, author_id, url, description) VALUES (:prefix, :author_id, :url, :description)" ) _SEED_PARAMS = { @@ -86,7 +85,7 @@ def db_url(): } _TRUNCATE_ALL = sa.text( - "TRUNCATE gw2_session_chars, gw2_sessions, gw2_keys, gw2_configs, " + "TRUNCATE gw2.gw2_session_char_deaths, gw2.gw2_sessions, gw2.gw2_keys, gw2.gw2_configs, " "dice_rolls, profanity_filters, custom_commands, servers, bot_configs " "RESTART IDENTITY CASCADE" ) diff --git a/tests/integration/test_alembic_migrations.py b/tests/integration/test_alembic_migrations.py index e50cd15f..2794cc94 100644 --- a/tests/integration/test_alembic_migrations.py +++ b/tests/integration/test_alembic_migrations.py @@ -7,28 +7,34 @@ # Helpers # ────────────────────────────────────────────────────────────────────── -EXPECTED_TABLES = [ +EXPECTED_PUBLIC_TABLES = [ "bot_configs", "servers", "custom_commands", "profanity_filters", "dice_rolls", +] + +EXPECTED_GW2_TABLES = [ "gw2_keys", "gw2_configs", "gw2_sessions", - "gw2_session_chars", + "gw2_session_char_deaths", ] -EXPECTED_TRIGGERS = [ +EXPECTED_PUBLIC_TRIGGERS = [ "before_update_bot_configs_tr", "before_update_servers_tr", "before_update_custom_commands_tr", "before_update_profanity_filters_tr", "before_update_dice_rolls_tr", +] + +EXPECTED_GW2_TRIGGERS = [ "before_update_gw2_keys_tr", "before_update_gw2_configs_tr", "before_update_gw2_sessions_tr", - "before_update_gw2_session_chars_tr", + "before_update_gw2_session_char_deaths_tr", ] @@ -60,12 +66,20 @@ async def test_updated_at_function_exists(db_session): assert rows[0]["routine_name"] == "updated_at_column_func" +async def test_gw2_schema_exists(db_session): + rows = await _fetch_rows( + db_session, + text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'gw2'"), + ) + assert len(rows) == 1 + + # ────────────────────────────────────────────────────────────────────── # All tables created # ────────────────────────────────────────────────────────────────────── -async def test_all_tables_exist(db_session): +async def test_all_public_tables_exist(db_session): rows = await _fetch_rows( db_session, text( @@ -75,11 +89,25 @@ async def test_all_tables_exist(db_session): ), ) table_names = [r["table_name"] for r in rows] - for expected in EXPECTED_TABLES: - assert expected in table_names, f"Table '{expected}' not found" + for expected in EXPECTED_PUBLIC_TABLES: + assert expected in table_names, f"Table '{expected}' not found in public schema" + + +async def test_all_gw2_tables_exist(db_session): + rows = await _fetch_rows( + db_session, + text( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'gw2' AND table_type = 'BASE TABLE' " + "ORDER BY table_name" + ), + ) + table_names = [r["table_name"] for r in rows] + for expected in EXPECTED_GW2_TABLES: + assert expected in table_names, f"Table '{expected}' not found in gw2 schema" -async def test_all_triggers_exist(db_session): +async def test_all_public_triggers_exist(db_session): rows = await _fetch_rows( db_session, text( @@ -87,7 +115,17 @@ async def test_all_triggers_exist(db_session): ), ) trigger_names = [r["trigger_name"] for r in rows] - for expected in EXPECTED_TRIGGERS: + for expected in EXPECTED_PUBLIC_TRIGGERS: + assert expected in trigger_names, f"Trigger '{expected}' not found" + + +async def test_all_gw2_triggers_exist(db_session): + rows = await _fetch_rows( + db_session, + text("SELECT trigger_name FROM information_schema.triggers WHERE trigger_schema = 'gw2' ORDER BY trigger_name"), + ) + trigger_names = [r["trigger_name"] for r in rows] + for expected in EXPECTED_GW2_TRIGGERS: assert expected in trigger_names, f"Trigger '{expected}' not found" @@ -97,7 +135,7 @@ async def test_alembic_version_at_head(db_session): text("SELECT version_num FROM alembic_version"), ) assert len(rows) == 1 - assert rows[0]["version_num"] == "0011" + assert rows[0]["version_num"] == "0010" # ────────────────────────────────────────────────────────────────────── @@ -123,29 +161,29 @@ async def test_bot_configs_columns(db_session): assert "updated_at" in cols assert "created_at" in cols assert cols["prefix"]["data_type"] == "character" + assert cols["id"]["data_type"] == "uuid" assert cols["id"]["is_nullable"] == "NO" async def test_bot_configs_seed_row(db_session): rows = await _fetch_rows( db_session, - text("SELECT id, prefix FROM bot_configs WHERE id = 1"), + text("SELECT id, prefix FROM bot_configs"), ) assert len(rows) == 1 - assert rows[0]["id"] == 1 + assert rows[0]["id"] is not None async def test_bot_configs_insert_and_read(db_session): await _execute( db_session, text( - "INSERT INTO bot_configs (id, prefix, author_id, url, description) " - "VALUES (99, '?', 123, 'http://test', 'test bot')" + "INSERT INTO bot_configs (prefix, author_id, url, description) VALUES ('?', 123, 'http://test', 'test bot')" ), ) rows = await _fetch_rows( db_session, - text("SELECT * FROM bot_configs WHERE id = 99"), + text("SELECT * FROM bot_configs WHERE prefix = '?'"), ) assert len(rows) == 1 assert rows[0]["prefix"] == "?" @@ -372,7 +410,7 @@ async def test_dice_rolls_cascade_delete(db_session): # ────────────────────────────────────────────────────────────────────── -# 0007 — gw2_keys unique constraint +# 0007 — gw2_keys unique constraint (gw2 schema) # ────────────────────────────────────────────────────────────────────── @@ -381,7 +419,7 @@ async def test_gw2_keys_user_id_unique(db_session): db_session, text( "SELECT constraint_name FROM information_schema.table_constraints " - "WHERE table_name = 'gw2_keys' AND constraint_type = 'UNIQUE' " + "WHERE table_schema = 'gw2' AND table_name = 'gw2_keys' AND constraint_type = 'UNIQUE' " "AND constraint_name != 'gw2_keys_pkey'" ), ) @@ -392,13 +430,13 @@ async def test_gw2_keys_insert_and_read(db_session): await _execute( db_session, text( - "INSERT INTO gw2_keys (user_id, name, gw2_acc_name, server, permissions, key) " + "INSERT INTO gw2.gw2_keys (user_id, name, gw2_acc_name, server, permissions, key) " "VALUES (7001, 'Main', 'Acc.1234', 'Anvil Rock', 'account', 'KEY-ABC')" ), ) rows = await _fetch_rows( db_session, - text("SELECT * FROM gw2_keys WHERE user_id = 7001"), + text("SELECT * FROM gw2.gw2_keys WHERE user_id = 7001"), ) assert len(rows) == 1 assert rows[0]["gw2_acc_name"] == "Acc.1234" @@ -406,7 +444,7 @@ async def test_gw2_keys_insert_and_read(db_session): # ────────────────────────────────────────────────────────────────────── -# 0008 — gw2_configs FK + unique server_id +# 0008 — gw2_configs FK + unique server_id (gw2 schema) # ────────────────────────────────────────────────────────────────────── @@ -415,7 +453,7 @@ async def test_gw2_configs_fk_and_unique(db_session): db_session, text( "SELECT constraint_type FROM information_schema.table_constraints " - "WHERE table_name = 'gw2_configs' " + "WHERE table_schema = 'gw2' AND table_name = 'gw2_configs' " "AND constraint_type IN ('FOREIGN KEY', 'UNIQUE')" ), ) @@ -428,11 +466,11 @@ async def test_gw2_configs_insert_and_read(db_session): await _execute(db_session, text("INSERT INTO servers (id, name) VALUES (10008, 'GW2 Cfg Server')")) await _execute( db_session, - text("INSERT INTO gw2_configs (server_id) VALUES (10008)"), + text("INSERT INTO gw2.gw2_configs (server_id) VALUES (10008)"), ) rows = await _fetch_rows( db_session, - text("SELECT * FROM gw2_configs WHERE server_id = 10008"), + text("SELECT * FROM gw2.gw2_configs WHERE server_id = 10008"), ) assert len(rows) == 1 assert rows[0]["session"] is False @@ -441,17 +479,17 @@ async def test_gw2_configs_insert_and_read(db_session): async def test_gw2_configs_cascade_delete(db_session): await _execute(db_session, text("INSERT INTO servers (id, name) VALUES (10009, 'Cascade GW2')")) - await _execute(db_session, text("INSERT INTO gw2_configs (server_id) VALUES (10009)")) + await _execute(db_session, text("INSERT INTO gw2.gw2_configs (server_id) VALUES (10009)")) await _execute(db_session, text("DELETE FROM servers WHERE id = 10009")) rows = await _fetch_rows( db_session, - text("SELECT * FROM gw2_configs WHERE server_id = 10009"), + text("SELECT * FROM gw2.gw2_configs WHERE server_id = 10009"), ) assert len(rows) == 0 # ────────────────────────────────────────────────────────────────────── -# 0009 — gw2_sessions JSONB columns +# 0009 — gw2_sessions JSONB columns (gw2 schema) # ────────────────────────────────────────────────────────────────────── @@ -461,7 +499,8 @@ async def test_gw2_sessions_jsonb_columns(db_session): text( "SELECT column_name, data_type, is_nullable " "FROM information_schema.columns " - "WHERE table_name = 'gw2_sessions' AND column_name IN ('start', 'end')" + "WHERE table_schema = 'gw2' AND table_name = 'gw2_sessions' " + "AND column_name IN ('start', 'end')" ), ) cols = {r["column_name"]: r for r in rows} @@ -475,26 +514,27 @@ async def test_gw2_sessions_insert_and_read_jsonb(db_session): await _execute( db_session, text( - "INSERT INTO gw2_sessions (user_id, acc_name, start) " + "INSERT INTO gw2.gw2_sessions (user_id, acc_name, start) " """VALUES (8001, 'TestAcc.9999', '{"gold": 100, "karma": 5000}'::jsonb)""" ), ) rows = await _fetch_rows( db_session, - text("SELECT * FROM gw2_sessions WHERE user_id = 8001"), + text("SELECT * FROM gw2.gw2_sessions WHERE user_id = 8001"), ) assert len(rows) == 1 assert rows[0]["start"]["gold"] == 100 assert rows[0]["start"]["karma"] == 5000 assert rows[0]["end"] is None + assert rows[0]["id"] is not None # ────────────────────────────────────────────────────────────────────── -# 0010/0011 — gw2_session_chars FK (unique name dropped in 0011) +# 0010 — gw2_session_char_deaths FK & columns (gw2 schema) # ────────────────────────────────────────────────────────────────────── -async def test_gw2_session_chars_fk_to_sessions(db_session): +async def test_gw2_session_char_deaths_fk_to_sessions(db_session): rows = await _fetch_rows( db_session, text( @@ -502,50 +542,65 @@ async def test_gw2_session_chars_fk_to_sessions(db_session): "FROM information_schema.table_constraints tc " "JOIN information_schema.constraint_column_usage ccu " " ON tc.constraint_name = ccu.constraint_name " - "WHERE tc.table_name = 'gw2_session_chars' AND tc.constraint_type = 'FOREIGN KEY'" + "WHERE tc.table_schema = 'gw2' AND tc.table_name = 'gw2_session_char_deaths' " + "AND tc.constraint_type = 'FOREIGN KEY'" ), ) assert any(r["foreign_table"] == "gw2_sessions" for r in rows) -async def test_gw2_session_chars_no_unique_name(db_session): - """Migration 0011 dropped the unique constraint on name to allow start+end records.""" +async def test_gw2_session_char_deaths_columns(db_session): rows = await _fetch_rows( db_session, text( - "SELECT constraint_name FROM information_schema.table_constraints " - "WHERE table_name = 'gw2_session_chars' AND constraint_type = 'UNIQUE' " - "AND constraint_name = 'gw2_session_chars_name_key'" + "SELECT column_name, data_type, is_nullable " + "FROM information_schema.columns " + "WHERE table_schema = 'gw2' AND table_name = 'gw2_session_char_deaths' " + "ORDER BY ordinal_position" ), ) - assert len(rows) == 0 + cols = {r["column_name"]: r for r in rows} + assert cols["id"]["data_type"] == "uuid" + assert cols["session_id"]["data_type"] == "uuid" + assert "start" in cols + assert cols["start"]["data_type"] == "integer" + assert cols["start"]["is_nullable"] == "NO" + assert "end" in cols + assert cols["end"]["data_type"] == "integer" + assert cols["end"]["is_nullable"] == "YES" + # No deaths or boolean columns from old schema + assert "deaths" not in cols -async def test_gw2_session_chars_insert_and_read(db_session): +async def test_gw2_session_char_deaths_insert_and_read(db_session): await _execute( db_session, text( - "INSERT INTO gw2_sessions (id, user_id, acc_name, start) " - """VALUES (90001, 8002, 'Char.1111', '{}'::jsonb)""" + "INSERT INTO gw2.gw2_sessions (user_id, acc_name, start) " + """VALUES (8002, 'Char.1111', '{}'::jsonb)""" ), ) + session_rows = await _fetch_rows( + db_session, + text("SELECT id FROM gw2.gw2_sessions WHERE user_id = 8002"), + ) + session_id = session_rows[0]["id"] await _execute( db_session, text( - "INSERT INTO gw2_session_chars " - "(session_id, user_id, name, profession, deaths, start) " - "VALUES (90001, 8002, 'MyWarrior', 'Warrior', 5, true)" + "INSERT INTO gw2.gw2_session_char_deaths " + "(session_id, user_id, name, profession, start) " + f"VALUES ('{session_id}', 8002, 'MyWarrior', 'Warrior', 5)" ), ) rows = await _fetch_rows( db_session, - text("SELECT * FROM gw2_session_chars WHERE user_id = 8002"), + text("SELECT * FROM gw2.gw2_session_char_deaths WHERE user_id = 8002"), ) assert len(rows) == 1 assert rows[0]["name"] == "MyWarrior" assert rows[0]["profession"] == "Warrior" - assert rows[0]["deaths"] == 5 - assert rows[0]["start"] is True + assert rows[0]["start"] == 5 assert rows[0]["end"] is None diff --git a/tests/integration/test_bot_configs_dal.py b/tests/integration/test_bot_configs_dal.py index 997867aa..e0839a5f 100644 --- a/tests/integration/test_bot_configs_dal.py +++ b/tests/integration/test_bot_configs_dal.py @@ -1,6 +1,7 @@ import pytest from src.bot.constants import variables from src.database.dal.bot.bot_configs_dal import BotConfigsDal +from uuid import UUID pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -10,7 +11,7 @@ async def test_get_bot_configs_returns_seeded_row(db_session, log): results = await dal.get_bot_configs() assert len(results) == 1 row = results[0] - assert row["id"] == 1 + assert isinstance(row["id"], UUID) assert row["prefix"] == variables.PREFIX assert row["author_id"] == int(variables.AUTHOR_ID) assert row["url"] == variables.BOT_WEBPAGE_URL diff --git a/tests/integration/test_gw2_session_char_deaths_dal.py b/tests/integration/test_gw2_session_char_deaths_dal.py new file mode 100644 index 00000000..763501ee --- /dev/null +++ b/tests/integration/test_gw2_session_char_deaths_dal.py @@ -0,0 +1,136 @@ +import pytest +from ddcDatabases import DBUtilsAsync +from sqlalchemy.exc import IntegrityError +from sqlalchemy.future import select +from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharDeathsDal +from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal +from src.database.models.gw2_models import Gw2SessionCharDeaths +from uuid import uuid4 + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +USER_ID = 600 +API_KEY = "TEST-API-KEY-1234" + + +async def test_insert_start_char_deaths(db_session, log): + """Test inserting start char deaths via the DAL.""" + sessions_dal = Gw2SessionsDal(db_session, log) + chars_dal = Gw2SessionCharDeathsDal(db_session, log) + + session_id = await sessions_dal.insert_start_session( + { + "user_id": USER_ID, + "acc_name": "CharTest.1111", + } + ) + + characters_data = [ + { + "name": "TestChar", + "profession": "Warrior", + "deaths": 10, + } + ] + await chars_dal.insert_start_char_deaths(session_id, USER_ID, characters_data) + + results = await chars_dal.get_char_deaths(USER_ID) + assert isinstance(results, list) + assert len(results) >= 1 + assert results[0]["start"] == 10 + assert results[0]["end"] is None + + +async def test_update_end_char_deaths(db_session, log): + """Test updating end char deaths via the DAL.""" + sessions_dal = Gw2SessionsDal(db_session, log) + chars_dal = Gw2SessionCharDeathsDal(db_session, log) + + session_id = await sessions_dal.insert_start_session( + { + "user_id": USER_ID, + "acc_name": "EndTest.2222", + } + ) + + characters_data = [ + {"name": "EndChar A", "profession": "Thief", "deaths": 3}, + ] + await chars_dal.insert_start_char_deaths(session_id, USER_ID, characters_data) + + end_characters_data = [ + {"name": "EndChar A", "deaths": 7}, + ] + await chars_dal.update_end_char_deaths(session_id, USER_ID, end_characters_data) + + results = await chars_dal.get_char_deaths(USER_ID) + assert isinstance(results, list) + assert len(results) >= 1 + char = next(c for c in results if c["name"] == "EndChar A") + assert char["start"] == 3 + assert char["end"] == 7 + + +async def test_get_char_deaths(db_session, log): + sessions_dal = Gw2SessionsDal(db_session, log) + + session_id = await sessions_dal.insert_start_session( + { + "user_id": USER_ID, + "acc_name": "GetChars.3333", + } + ) + + chars_dal = Gw2SessionCharDeathsDal(db_session, log) + characters_data = [ + {"name": "GetChar A", "profession": "Warrior", "deaths": 5}, + ] + await chars_dal.insert_start_char_deaths(session_id, USER_ID, characters_data) + + results = await chars_dal.get_char_deaths(USER_ID) + assert isinstance(results, list) + assert len(results) >= 1 + + +async def test_insert_char_stores_correct_data(db_session, log): + sessions_dal = Gw2SessionsDal(db_session, log) + + session_id = await sessions_dal.insert_start_session( + { + "user_id": USER_ID, + "acc_name": "DataCheck.4444", + } + ) + + chars_dal = Gw2SessionCharDeathsDal(db_session, log) + characters_data = [ + {"name": "MyWarrior", "profession": "Guardian", "deaths": 42}, + ] + await chars_dal.insert_start_char_deaths(session_id, USER_ID, characters_data) + + stmt = select( + Gw2SessionCharDeaths.name, + Gw2SessionCharDeaths.profession, + Gw2SessionCharDeaths.start, + ).where(Gw2SessionCharDeaths.user_id == USER_ID) + db_utils = DBUtilsAsync(db_session) + results = await db_utils.fetchall(stmt, True) + assert len(results) == 1 + assert results[0]["name"] == "MyWarrior" + assert results[0]["profession"] == "Guardian" + assert results[0]["start"] == 42 + + +async def test_fk_constraint_requires_session(db_session, log): + """Inserting a char with a non-existent session_id should fail with FK violation.""" + db_utils = DBUtilsAsync(db_session) + with pytest.raises(IntegrityError): + stmt = Gw2SessionCharDeaths( + session_id=uuid4(), + user_id=USER_ID, + name="Orphan", + profession="Mesmer", + start=0, + end=None, + ) + await db_utils.insert(stmt) diff --git a/tests/integration/test_gw2_session_chars_dal.py b/tests/integration/test_gw2_session_chars_dal.py deleted file mode 100644 index 41949a42..00000000 --- a/tests/integration/test_gw2_session_chars_dal.py +++ /dev/null @@ -1,126 +0,0 @@ -import pytest -from ddcDatabases import DBUtilsAsync -from sqlalchemy.exc import IntegrityError -from sqlalchemy.future import select -from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal -from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal -from src.database.models.gw2_models import Gw2SessionChars - -pytestmark = [pytest.mark.integration, pytest.mark.asyncio] - -USER_ID = 600 -API_KEY = "TEST-API-KEY-1234" - - -async def _insert_char_directly(db_session, session_id, user_id, name, profession, deaths, start=True, end=None): - """Insert a session char directly via SQL to bypass DAL's missing `start` field.""" - db_utils = DBUtilsAsync(db_session) - stmt = Gw2SessionChars( - session_id=session_id, - user_id=user_id, - name=name, - profession=profession, - deaths=deaths, - start=start, - end=end, - ) - await db_utils.insert(stmt) - - -async def test_insert_session_char_with_start_field(db_session, log): - """The DAL's insert_session_char now correctly sets start/end booleans.""" - sessions_dal = Gw2SessionsDal(db_session, log) - chars_dal = Gw2SessionCharsDal(db_session, log) - - session_id = await sessions_dal.insert_start_session( - { - "user_id": USER_ID, - "acc_name": "CharTest.1111", - } - ) - - characters_data = [ - { - "name": "TestChar", - "profession": "Warrior", - "deaths": 10, - } - ] - insert_args = { - "session_id": session_id, - "user_id": USER_ID, - "start": True, - "end": False, - } - # Should no longer raise IntegrityError now that start/end are passed - await chars_dal.insert_session_char(characters_data, insert_args) - - # Verify the data was inserted correctly - results = await chars_dal.get_all_start_characters(USER_ID) - assert isinstance(results, list) - assert len(results) >= 1 - - -async def test_get_all_start_characters(db_session, log): - sessions_dal = Gw2SessionsDal(db_session, log) - - session_id = await sessions_dal.insert_start_session( - { - "user_id": USER_ID, - "acc_name": "StartChars.2222", - } - ) - - await _insert_char_directly(db_session, session_id, USER_ID, "StartChar A", "Warrior", 5, start=True) - - chars_dal = Gw2SessionCharsDal(db_session, log) - results = await chars_dal.get_all_start_characters(USER_ID) - assert isinstance(results, list) - - -async def test_get_all_end_characters(db_session, log): - sessions_dal = Gw2SessionsDal(db_session, log) - - session_id = await sessions_dal.insert_start_session( - { - "user_id": USER_ID, - "acc_name": "EndChars.3333", - } - ) - - await _insert_char_directly(db_session, session_id, USER_ID, "EndChar A", "Thief", 3, start=False, end=True) - - chars_dal = Gw2SessionCharsDal(db_session, log) - results = await chars_dal.get_all_end_characters(USER_ID) - assert isinstance(results, list) - - -async def test_insert_char_stores_correct_data(db_session, log): - sessions_dal = Gw2SessionsDal(db_session, log) - - session_id = await sessions_dal.insert_start_session( - { - "user_id": USER_ID, - "acc_name": "DataCheck.4444", - } - ) - - await _insert_char_directly(db_session, session_id, USER_ID, "MyWarrior", "Guardian", 42, start=True) - - stmt = select( - Gw2SessionChars.name, - Gw2SessionChars.profession, - Gw2SessionChars.deaths, - ).where(Gw2SessionChars.user_id == USER_ID) - db_utils = DBUtilsAsync(db_session) - results = await db_utils.fetchall(stmt, True) - assert len(results) == 1 - assert results[0]["name"] == "MyWarrior" - assert results[0]["profession"] == "Guardian" - assert results[0]["deaths"] == 42 - - -async def test_fk_constraint_requires_session(db_session, log): - """Inserting a char with a non-existent session_id should fail with FK violation.""" - with pytest.raises(IntegrityError): - await _insert_char_directly(db_session, 999999, USER_ID, "Orphan", "Mesmer", 0, start=True) diff --git a/tests/integration/test_gw2_sessions_dal.py b/tests/integration/test_gw2_sessions_dal.py index d037a035..c3c8f7f3 100644 --- a/tests/integration/test_gw2_sessions_dal.py +++ b/tests/integration/test_gw2_sessions_dal.py @@ -1,5 +1,6 @@ import pytest from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal +from uuid import UUID pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -19,7 +20,7 @@ async def test_insert_start_session_returns_id(db_session, log): dal = Gw2SessionsDal(db_session, log) session_id = await dal.insert_start_session(_make_session()) assert session_id is not None - assert isinstance(session_id, int) + assert isinstance(session_id, UUID) async def test_insert_start_session_stores_jsonb(db_session, log): diff --git a/tests/integration/test_session_flow.py b/tests/integration/test_session_flow.py index bc8d2a9e..0519f5d5 100644 --- a/tests/integration/test_session_flow.py +++ b/tests/integration/test_session_flow.py @@ -5,7 +5,7 @@ """ import pytest -from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal +from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharDeathsDal from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal from src.gw2.tools import gw2_utils from unittest.mock import AsyncMock, MagicMock, patch @@ -135,15 +135,16 @@ async def test_session_start_end_lifecycle(db_session, log): assert session["start"]["wvw_rank"] == 250 assert session["end"] is None # Not ended yet - # Verify start characters were inserted - chars_dal = Gw2SessionCharsDal(db_session, log) - start_chars = await chars_dal.get_all_start_characters(USER_ID) - assert len(start_chars) == 2 - char_names = {c["name"] for c in start_chars} + # Verify start character deaths were inserted + chars_dal = Gw2SessionCharDeathsDal(db_session, log) + char_deaths = await chars_dal.get_char_deaths(USER_ID) + assert len(char_deaths) == 2 + char_names = {c["name"] for c in char_deaths} assert char_names == {"Warrior Prime", "Thief Shadow"} - warrior = next(c for c in start_chars if c["name"] == "Warrior Prime") + warrior = next(c for c in char_deaths if c["name"] == "Warrior Prime") assert warrior["profession"] == "Warrior" - assert warrior["deaths"] == 42 + assert warrior["start"] == 42 + assert warrior["end"] is None # ---- END SESSION ---- mock_gw2_api_end = AsyncMock() @@ -160,14 +161,15 @@ async def test_session_start_end_lifecycle(db_session, log): assert session["end"]["gold"] == 120000 assert session["end"]["karma"] == 55000 - # Migration 0011 dropped the unique constraint on name, so end chars - # can now be inserted alongside start chars for the same character name. - end_chars = await chars_dal.get_all_end_characters(USER_ID) - assert len(end_chars) == 2 - end_char_names = {c["name"] for c in end_chars} - assert end_char_names == {"Warrior Prime", "Thief Shadow"} - end_warrior = next(c for c in end_chars if c["name"] == "Warrior Prime") - assert end_warrior["deaths"] == 45 + # Verify end deaths were updated on the same rows + char_deaths = await chars_dal.get_char_deaths(USER_ID) + assert len(char_deaths) == 2 + end_warrior = next(c for c in char_deaths if c["name"] == "Warrior Prime") + assert end_warrior["start"] == 42 + assert end_warrior["end"] == 45 + end_thief = next(c for c in char_deaths if c["name"] == "Thief Shadow") + assert end_thief["start"] == 10 + assert end_thief["end"] == 12 async def test_end_session_without_start_is_noop(db_session, log): diff --git a/tests/unit/database/test_dal.py b/tests/unit/database/test_dal.py index 3586346c..bfe7a9cb 100644 --- a/tests/unit/database/test_dal.py +++ b/tests/unit/database/test_dal.py @@ -13,7 +13,7 @@ from src.database.dal.bot.servers_dal import ServersDal from src.database.dal.gw2.gw2_configs_dal import Gw2ConfigsDal from src.database.dal.gw2.gw2_key_dal import Gw2KeyDal -from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal +from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharDeathsDal from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal # ============================================================================= @@ -902,12 +902,12 @@ async def test_get_api_key_by_user_not_found(self, mock_dal): # ============================================================================= -# Gw2SessionCharsDal Tests +# Gw2SessionCharDeathsDal Tests # ============================================================================= -class TestGw2SessionCharsDal: - """Test cases for Gw2SessionCharsDal.""" +class TestGw2SessionCharDeathsDal: + """Test cases for Gw2SessionCharDeathsDal.""" @pytest.fixture def mock_dal(self): @@ -916,116 +916,82 @@ def mock_dal(self): with patch("src.database.dal.gw2.gw2_session_chars_dal.DBUtilsAsync") as mock_db_utils_class: mock_db_utils = AsyncMock() mock_db_utils_class.return_value = mock_db_utils - dal = Gw2SessionCharsDal(db_session, log) + dal = Gw2SessionCharDeathsDal(db_session, log) dal.db_utils = mock_db_utils yield dal def test_init(self): - """Test Gw2SessionCharsDal initialization.""" + """Test Gw2SessionCharDeathsDal initialization.""" db_session = MagicMock() log = MagicMock() with patch("src.database.dal.gw2.gw2_session_chars_dal.DBUtilsAsync") as mock_db_utils_class: mock_db_utils = AsyncMock() mock_db_utils_class.return_value = mock_db_utils - dal = Gw2SessionCharsDal(db_session, log) + dal = Gw2SessionCharDeathsDal(db_session, log) mock_db_utils_class.assert_called_once_with(db_session) assert dal.log == log assert dal.db_utils == mock_db_utils @pytest.mark.asyncio - async def test_insert_session_char(self, mock_dal): - """Test insert_session_char calls insert for each character.""" + async def test_insert_start_char_deaths(self, mock_dal): + """Test insert_start_char_deaths calls insert for each character.""" characters_data = [ {"name": "CharOne", "profession": "Warrior", "deaths": 5}, {"name": "CharTwo", "profession": "Elementalist", "deaths": 10}, ] - insert_args = { - "session_id": 1, - "user_id": 67890, - "start": True, - "end": False, - } - await mock_dal.insert_session_char(characters_data, insert_args) + await mock_dal.insert_start_char_deaths(session_id=1, user_id=67890, characters_data=characters_data) assert mock_dal.db_utils.insert.call_count == 2 @pytest.mark.asyncio - async def test_insert_session_char_single_character(self, mock_dal): - """Test insert_session_char with a single character.""" + async def test_insert_start_char_deaths_single_character(self, mock_dal): + """Test insert_start_char_deaths with a single character.""" characters_data = [ {"name": "Solo", "profession": "Necromancer", "deaths": 0}, ] - insert_args = { - "session_id": 2, - "user_id": 11111, - "start": False, - "end": True, - } - await mock_dal.insert_session_char(characters_data, insert_args) + await mock_dal.insert_start_char_deaths(session_id=2, user_id=11111, characters_data=characters_data) mock_dal.db_utils.insert.assert_called_once() @pytest.mark.asyncio - async def test_insert_session_char_empty_characters(self, mock_dal): - """Test insert_session_char with an empty characters list.""" - characters_data = [] - insert_args = { - "session_id": 3, - "user_id": 22222, - } - - await mock_dal.insert_session_char(characters_data, insert_args) + async def test_insert_start_char_deaths_empty_characters(self, mock_dal): + """Test insert_start_char_deaths with an empty characters list.""" + await mock_dal.insert_start_char_deaths(session_id=3, user_id=22222, characters_data=[]) mock_dal.db_utils.insert.assert_not_called() @pytest.mark.asyncio - async def test_delete_end_characters(self, mock_dal): - """Test delete_end_characters executes a delete statement for end chars.""" + async def test_update_end_char_deaths(self, mock_dal): + """Test update_end_char_deaths executes an update for each character.""" mock_dal.db_utils.execute = AsyncMock() - await mock_dal.delete_end_characters(session_id=42) - mock_dal.db_utils.execute.assert_called_once() - - @pytest.mark.asyncio - async def test_get_all_start_characters(self, mock_dal): - """Test get_all_start_characters calls fetchall and returns results.""" - expected = [ - {"user_id": 67890, "name": "CharOne", "profession": "Warrior", "start": True}, - {"user_id": 67890, "name": "CharTwo", "profession": "Elementalist", "start": True}, + characters_data = [ + {"name": "CharOne", "deaths": 8}, + {"name": "CharTwo", "deaths": 12}, ] - mock_dal.db_utils.fetchall.return_value = expected - results = await mock_dal.get_all_start_characters(user_id=67890) - mock_dal.db_utils.fetchall.assert_called_once() - call_args = mock_dal.db_utils.fetchall.call_args - assert call_args[0][1] is True - assert results == expected - - @pytest.mark.asyncio - async def test_get_all_start_characters_empty(self, mock_dal): - """Test get_all_start_characters when no characters exist.""" - mock_dal.db_utils.fetchall.return_value = [] - results = await mock_dal.get_all_start_characters(user_id=99999) - assert results == [] + await mock_dal.update_end_char_deaths(session_id=42, user_id=67890, characters_data=characters_data) + assert mock_dal.db_utils.execute.call_count == 2 @pytest.mark.asyncio - async def test_get_all_end_characters(self, mock_dal): - """Test get_all_end_characters calls fetchall and returns results.""" + async def test_get_char_deaths(self, mock_dal): + """Test get_char_deaths calls fetchall and returns results.""" expected = [ - {"user_id": 67890, "name": "CharOne", "profession": "Warrior", "end": True}, + {"user_id": 67890, "name": "CharOne", "profession": "Warrior", "start": 5, "end": 8}, + {"user_id": 67890, "name": "CharTwo", "profession": "Elementalist", "start": 10, "end": 12}, ] mock_dal.db_utils.fetchall.return_value = expected - results = await mock_dal.get_all_end_characters(user_id=67890) + results = await mock_dal.get_char_deaths(user_id=67890) mock_dal.db_utils.fetchall.assert_called_once() call_args = mock_dal.db_utils.fetchall.call_args assert call_args[0][1] is True assert results == expected @pytest.mark.asyncio - async def test_get_all_end_characters_empty(self, mock_dal): - """Test get_all_end_characters when no characters exist.""" + async def test_get_char_deaths_empty(self, mock_dal): + """Test get_char_deaths when no characters exist.""" mock_dal.db_utils.fetchall.return_value = [] - results = await mock_dal.get_all_end_characters(user_id=99999) + results = await mock_dal.get_char_deaths(user_id=99999) assert results == [] diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index 42a760ba..3952362e 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -190,7 +190,7 @@ async def run(self_runner): patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal, patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short", side_effect=lambda x: x), patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed", return_value=sample_time_passed), - patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal_class, + patch("src.gw2.cogs.sessions.Gw2SessionCharDeathsDal") as mock_chars_dal_class, patch("src.gw2.cogs.sessions.bot_utils.send_paginated_embed") as mock_send, patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"), patch( @@ -202,8 +202,7 @@ async def run(self_runner): mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) - mock_chars_dal_class.return_value.get_all_start_characters = AsyncMock(return_value=None) - mock_chars_dal_class.return_value.get_all_end_characters = AsyncMock(return_value=None) + mock_chars_dal_class.return_value.get_char_deaths = AsyncMock(return_value=None) self_runner.mock_send = mock_send self_runner.mock_chars_dal = mock_chars_dal_class.return_value @@ -407,18 +406,13 @@ async def test_session_gold_lost_with_leading_dash(self, mock_ctx, sample_api_ke async def test_session_characters_with_deaths(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command with character deaths.""" session_data = _make_session_data() - chars_start = [ - {"name": "TestChar", "profession": "Warrior", "deaths": 10}, - {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, - ] - chars_end = [ - {"name": "TestChar", "profession": "Warrior", "deaths": 15}, - {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, + char_deaths = [ + {"name": "TestChar", "profession": "Warrior", "start": 10, "end": 15}, + {"name": "TestChar2", "profession": "Ranger", "start": 5, "end": 5}, ] runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) async with runner.run() as r: - r.mock_chars_dal.get_all_start_characters = AsyncMock(return_value=chars_start) - r.mock_chars_dal.get_all_end_characters = AsyncMock(return_value=chars_end) + r.mock_chars_dal.get_char_deaths = AsyncMock(return_value=char_deaths) await session(mock_ctx) embed = r.mock_send.call_args[0][1] deaths_field = next((f for f in embed.fields if f.name == gw2_messages.TIMES_YOU_DIED), None) @@ -431,12 +425,10 @@ async def test_session_characters_with_deaths(self, mock_ctx, sample_api_key_dat async def test_session_no_deaths_when_unchanged(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command shows no deaths field when deaths unchanged.""" session_data = _make_session_data() - chars_start = [{"name": "TestChar", "profession": "Warrior", "deaths": 10}] - chars_end = [{"name": "TestChar", "profession": "Warrior", "deaths": 10}] + char_deaths = [{"name": "TestChar", "profession": "Warrior", "start": 10, "end": 10}] runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) async with runner.run() as r: - r.mock_chars_dal.get_all_start_characters = AsyncMock(return_value=chars_start) - r.mock_chars_dal.get_all_end_characters = AsyncMock(return_value=chars_end) + r.mock_chars_dal.get_char_deaths = AsyncMock(return_value=char_deaths) await session(mock_ctx) embed = r.mock_send.call_args[0][1] deaths_field = next((f for f in embed.fields if f.name == gw2_messages.TIMES_YOU_DIED), None) @@ -446,18 +438,13 @@ async def test_session_no_deaths_when_unchanged(self, mock_ctx, sample_api_key_d async def test_session_multiple_character_deaths(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command with multiple characters dying.""" session_data = _make_session_data() - chars_start = [ - {"name": "Char1", "profession": "Warrior", "deaths": 10}, - {"name": "Char2", "profession": "Mesmer", "deaths": 5}, - ] - chars_end = [ - {"name": "Char1", "profession": "Warrior", "deaths": 13}, - {"name": "Char2", "profession": "Mesmer", "deaths": 8}, + char_deaths = [ + {"name": "Char1", "profession": "Warrior", "start": 10, "end": 13}, + {"name": "Char2", "profession": "Mesmer", "start": 5, "end": 8}, ] runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) async with runner.run() as r: - r.mock_chars_dal.get_all_start_characters = AsyncMock(return_value=chars_start) - r.mock_chars_dal.get_all_end_characters = AsyncMock(return_value=chars_end) + r.mock_chars_dal.get_char_deaths = AsyncMock(return_value=char_deaths) await session(mock_ctx) embed = r.mock_send.call_args[0][1] deaths_field = next((f for f in embed.fields if f.name == gw2_messages.TIMES_YOU_DIED), None) @@ -857,10 +844,9 @@ class TestAddDeathsField: def test_deaths_changed(self): embed = discord.Embed() - start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] - end = [{"name": "Char1", "profession": "Warrior", "deaths": 15}] + char_deaths = [{"name": "Char1", "profession": "Warrior", "start": 10, "end": 15}] with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - _add_deaths_field(embed, start, end) + _add_deaths_field(embed, char_deaths) assert len(embed.fields) == 1 assert embed.fields[0].name == gw2_messages.TIMES_YOU_DIED assert "Char1 (Warrior): 5" in embed.fields[0].value @@ -868,52 +854,25 @@ def test_deaths_changed(self): def test_no_deaths(self): embed = discord.Embed() - start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] - end = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] - _add_deaths_field(embed, start, end) - assert len(embed.fields) == 0 - - def test_empty_end_chars(self): - embed = discord.Embed() - start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] - _add_deaths_field(embed, start, []) + char_deaths = [{"name": "Char1", "profession": "Warrior", "start": 10, "end": 10}] + _add_deaths_field(embed, char_deaths) assert len(embed.fields) == 0 - def test_none_end_chars(self): + def test_end_deaths_none_skipped(self): embed = discord.Embed() - start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] - _add_deaths_field(embed, start, None) + char_deaths = [{"name": "Char1", "profession": "Warrior", "start": 10, "end": None}] + _add_deaths_field(embed, char_deaths) assert len(embed.fields) == 0 - def test_duplicate_end_chars_deduplicated(self): - """Test that duplicate end chars (from multiple guild events) are deduplicated.""" - embed = discord.Embed() - start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] - end = [ - {"name": "Char1", "profession": "Warrior", "deaths": 15}, - {"name": "Char1", "profession": "Warrior", "deaths": 15}, - {"name": "Char1", "profession": "Warrior", "deaths": 15}, - ] - with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - _add_deaths_field(embed, start, end) - assert len(embed.fields) == 1 - assert "Total: 5" in embed.fields[0].value - # Should only show the character once - assert embed.fields[0].value.count("Warrior") == 1 - def test_multiple_characters_per_line_format(self): """Test that each character appears on its own line.""" embed = discord.Embed() - start = [ - {"name": "I Hadesz I", "profession": "Necromancer", "deaths": 10}, - {"name": "Hàdész", "profession": "Mesmer", "deaths": 5}, - ] - end = [ - {"name": "I Hadesz I", "profession": "Necromancer", "deaths": 11}, - {"name": "Hàdész", "profession": "Mesmer", "deaths": 7}, + char_deaths = [ + {"name": "I Hadesz I", "profession": "Necromancer", "start": 10, "end": 11}, + {"name": "Hàdész", "profession": "Mesmer", "start": 5, "end": 7}, ] with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): - _add_deaths_field(embed, start, end) + _add_deaths_field(embed, char_deaths) assert len(embed.fields) == 1 assert embed.fields[0].name == gw2_messages.TIMES_YOU_DIED assert "I Hadesz I (Necromancer): 1" in embed.fields[0].value diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index 4684bb08..d047c8b8 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -34,11 +34,12 @@ get_worlds_ids, get_wvw_rank_title, insert_gw2_server_configs, - insert_session_char, + insert_start_char_deaths, is_private_message, max_ap, send_msg, start_session, + update_end_char_deaths, ) from unittest.mock import AsyncMock, MagicMock, patch @@ -829,7 +830,7 @@ async def test_successful_start_session(self, mock_bot, mock_member): mock_instance = mock_session_dal.return_value mock_instance.insert_start_session = AsyncMock(return_value=42) - with patch("src.gw2.tools.gw2_utils.insert_session_char") as mock_insert_char: + with patch("src.gw2.tools.gw2_utils.insert_start_char_deaths") as mock_insert_char: mock_insert_char.return_value = None await start_session(mock_bot, mock_member, "api-key") @@ -839,7 +840,7 @@ async def test_successful_start_session(self, mock_bot, mock_member): assert call_arg["user_id"] == 12345 assert call_arg["date"] == "2023-01-01" - mock_insert_char.assert_called_once_with(mock_bot, mock_member, "api-key", 42, "start") + mock_insert_char.assert_called_once_with(mock_bot, mock_member, "api-key", 42) class TestEndSession: @@ -894,26 +895,21 @@ async def test_successful_end_session(self, mock_bot, mock_member): mock_instance = mock_session_dal.return_value mock_instance.update_end_session = AsyncMock(return_value=42) - with patch("src.gw2.tools.gw2_utils.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.delete_end_characters = AsyncMock() + with patch("src.gw2.tools.gw2_utils.update_end_char_deaths") as mock_update_char: + mock_update_char.return_value = None - with patch("src.gw2.tools.gw2_utils.insert_session_char") as mock_insert_char: - mock_insert_char.return_value = None - - await end_session(mock_bot, mock_member, "api-key") + await end_session(mock_bot, mock_member, "api-key") - mock_instance.update_end_session.assert_called_once() - call_arg = mock_instance.update_end_session.call_args[0][0] - assert call_arg["user_id"] == 12345 - assert call_arg["date"] == "2023-01-01" + mock_instance.update_end_session.assert_called_once() + call_arg = mock_instance.update_end_session.call_args[0][0] + assert call_arg["user_id"] == 12345 + assert call_arg["date"] == "2023-01-01" - mock_chars_instance.delete_end_characters.assert_called_once_with(42) - mock_insert_char.assert_called_once_with(mock_bot, mock_member, "api-key", 42, "end") + mock_update_char.assert_called_once_with(mock_bot, mock_member, "api-key", 42) @pytest.mark.asyncio async def test_end_session_no_active_session(self, mock_bot, mock_member): - """Test end_session skips insert_session_char when no active session exists.""" + """Test end_session skips update_end_char_deaths when no active session exists.""" session_data = {"acc_name": "TestUser.1234", "wvw_rank": 50, "gold": 1000} with patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats: @@ -929,11 +925,11 @@ async def test_end_session_no_active_session(self, mock_bot, mock_member): mock_instance = mock_session_dal.return_value mock_instance.update_end_session = AsyncMock(return_value=None) - with patch("src.gw2.tools.gw2_utils.insert_session_char") as mock_insert_char: + with patch("src.gw2.tools.gw2_utils.update_end_char_deaths") as mock_update_char: await end_session(mock_bot, mock_member, "api-key") mock_instance.update_end_session.assert_called_once() - mock_insert_char.assert_not_called() + mock_update_char.assert_not_called() mock_bot.log.warning.assert_called_once() @@ -1200,8 +1196,8 @@ def test_empty_achievements_data(self): assert user_stats["players"] == 0 -class TestInsertSessionChar: - """Test cases for insert_session_char function.""" +class TestInsertStartCharDeaths: + """Test cases for insert_start_char_deaths function.""" @pytest.fixture def mock_bot(self): @@ -1220,58 +1216,80 @@ def mock_member(self): @pytest.mark.asyncio async def test_successful_insert(self, mock_bot, mock_member): - """Test successful session character insert.""" + """Test successful start char deaths insert.""" with patch("src.gw2.tools.gw2_utils.Gw2Client") as mock_client_class: mock_client = mock_client_class.return_value characters_data = [{"name": "CharName", "profession": "Warrior", "deaths": 5}] mock_client.call_api = AsyncMock(return_value=characters_data) - with patch("src.gw2.tools.gw2_utils.Gw2SessionCharsDal") as mock_dal: + with patch("src.gw2.tools.gw2_utils.Gw2SessionCharDeathsDal") as mock_dal: mock_instance = mock_dal.return_value - mock_instance.insert_session_char = AsyncMock() + mock_instance.insert_start_char_deaths = AsyncMock() - await insert_session_char(mock_bot, mock_member, "api-key", 42, "start") + await insert_start_char_deaths(mock_bot, mock_member, "api-key", 42) mock_client.call_api.assert_called_once_with("characters?ids=all", "api-key") - mock_instance.insert_session_char.assert_called_once() + mock_instance.insert_start_char_deaths.assert_called_once_with(42, 12345, characters_data) + + @pytest.mark.asyncio + async def test_exception_logs_error(self, mock_bot, mock_member): + """Test that exception is caught and logged.""" + with patch("src.gw2.tools.gw2_utils.Gw2Client") as mock_client_class: + mock_client = mock_client_class.return_value + mock_client.call_api = AsyncMock(side_effect=Exception("API Error")) + + await insert_start_char_deaths(mock_bot, mock_member, "api-key", 42) - call_args = mock_instance.insert_session_char.call_args[0] - assert call_args[0] == characters_data - insert_args = call_args[1] - assert insert_args["session_id"] == 42 - assert insert_args["user_id"] == 12345 - assert insert_args["start"] is True - assert insert_args["end"] is False + mock_bot.log.error.assert_called_once() + assert "Error inserting start session character data" in mock_bot.log.error.call_args[0][0] + + +class TestUpdateEndCharDeaths: + """Test cases for update_end_char_deaths function.""" + + @pytest.fixture + def mock_bot(self): + """Create a mock bot.""" + bot = MagicMock() + bot.db_session = MagicMock() + bot.log = MagicMock() + return bot + + @pytest.fixture + def mock_member(self): + """Create a mock member.""" + member = MagicMock() + member.id = 12345 + return member @pytest.mark.asyncio - async def test_insert_end_session_type(self, mock_bot, mock_member): - """Test insert with end session type.""" + async def test_successful_update(self, mock_bot, mock_member): + """Test successful end char deaths update.""" with patch("src.gw2.tools.gw2_utils.Gw2Client") as mock_client_class: mock_client = mock_client_class.return_value - mock_client.call_api = AsyncMock(return_value=[]) + characters_data = [{"name": "CharName", "profession": "Warrior", "deaths": 8}] + mock_client.call_api = AsyncMock(return_value=characters_data) - with patch("src.gw2.tools.gw2_utils.Gw2SessionCharsDal") as mock_dal: + with patch("src.gw2.tools.gw2_utils.Gw2SessionCharDeathsDal") as mock_dal: mock_instance = mock_dal.return_value - mock_instance.insert_session_char = AsyncMock() + mock_instance.update_end_char_deaths = AsyncMock() - await insert_session_char(mock_bot, mock_member, "api-key", 42, "end") + await update_end_char_deaths(mock_bot, mock_member, "api-key", 42) - call_args = mock_instance.insert_session_char.call_args[0] - insert_args = call_args[1] - assert insert_args["start"] is False - assert insert_args["end"] is True + mock_client.call_api.assert_called_once_with("characters?ids=all", "api-key") + mock_instance.update_end_char_deaths.assert_called_once_with(42, 12345, characters_data) @pytest.mark.asyncio async def test_exception_logs_error(self, mock_bot, mock_member): - """Test that exception is caught and logged (lines 430-431).""" + """Test that exception is caught and logged.""" with patch("src.gw2.tools.gw2_utils.Gw2Client") as mock_client_class: mock_client = mock_client_class.return_value mock_client.call_api = AsyncMock(side_effect=Exception("API Error")) - await insert_session_char(mock_bot, mock_member, "api-key", 42, "start") + await update_end_char_deaths(mock_bot, mock_member, "api-key", 42) mock_bot.log.error.assert_called_once() - assert "Error inserting start session character data" in mock_bot.log.error.call_args[0][0] + assert "Error updating end session character data" in mock_bot.log.error.call_args[0][0] class TestGetPvpRankTitle: diff --git a/uv.lock b/uv.lock index 15d4eb53..ca804e43 100644 --- a/uv.lock +++ b/uv.lock @@ -333,15 +333,15 @@ wheels = [ [[package]] name = "ddcdatabases" -version = "3.0.10" +version = "3.0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-settings" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/51/6daf0e685a660ed0d4651c56e8a2516ec38cc1daac11b0e3ec557d47177e/ddcdatabases-3.0.10.tar.gz", hash = "sha256:34423d5bed78653693b50d783356cc730dd45ef6625cac2f1dcc93180ca13872", size = 38238, upload-time = "2026-02-12T15:38:31.792Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/c2/4978087ad2f17c58c90a65c4060e58db7228ea11e85a583bf44fc02457d4/ddcdatabases-3.0.11.tar.gz", hash = "sha256:67904e6fe84effbc8ce8a73fea94619288ce58a4ffc0bc2a9d329d644cf3333a", size = 38268, upload-time = "2026-02-25T22:37:54.071Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/cf/c6458599d4d52b14944cd901958be2979fbbf55dc121acdf3ee9387df542/ddcdatabases-3.0.10-py3-none-any.whl", hash = "sha256:3687f33c6918e70f506e7aef7c0d101b6d3ae5692423eceb88cd3d8aaf4b2e50", size = 42144, upload-time = "2026-02-12T15:38:30.918Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/c9a052fa29036b6386da00910f7318a921a59f0c783ee20a1b5c2f620dc6/ddcdatabases-3.0.11-py3-none-any.whl", hash = "sha256:5d4cd4ade1439044ff79308f9e59fe11fae2b0334941be5b1625beb8ba98c426", size = 42277, upload-time = "2026-02-25T22:37:52.798Z" }, ] [package.optional-dependencies] @@ -377,6 +377,7 @@ dependencies = [ { name = "openai" }, { name = "pynacl" }, { name = "pythonlogs" }, + { name = "uuid-utils" }, ] [package.dev-dependencies] @@ -393,12 +394,13 @@ requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "better-profanity", specifier = ">=0.7.0" }, - { name = "ddcdatabases", extras = ["postgres"], specifier = ">=3.0.10" }, + { name = "ddcdatabases", extras = ["postgres"], specifier = ">=3.0.11" }, { name = "discord-py", specifier = ">=2.6.4" }, { name = "gtts", specifier = ">=2.5.4" }, { name = "openai", specifier = ">=2.24.0" }, { name = "pynacl", specifier = ">=1.6.2" }, - { name = "pythonlogs", specifier = ">=6.0.2" }, + { name = "pythonlogs", specifier = ">=6.0.3" }, + { name = "uuid-utils", specifier = ">=0.14.1" }, ] [package.metadata.requires-dev] @@ -980,14 +982,14 @@ wheels = [ [[package]] name = "pythonlogs" -version = "6.0.2" +version = "6.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-settings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/28/b9888b6f2f608bf0872833e05bdfc9ecf6e8ddfc29683d5495bc9789d311/pythonlogs-6.0.2.tar.gz", hash = "sha256:5e2d07df30a41537c7353ffe5c258c992cd3f6b8f73b75908871f62fc3882253", size = 21364, upload-time = "2026-02-09T17:24:33.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/22/4cc8022d2cde5933a3c634e25b7facf6b1f520130ce6f74ffe3629c2c43b/pythonlogs-6.0.3.tar.gz", hash = "sha256:144da03ca5f555f1f4aae8e298d1c465b73fc15e3b6fe141cf4e2b20f2f52ba8", size = 21278, upload-time = "2026-02-25T22:30:12.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/60/fdafb69e7f6b99455c1f74d835484844d297a9f1027cbb6a76a11f5ef9b7/pythonlogs-6.0.2-py3-none-any.whl", hash = "sha256:ff227f60bdc7aa091ade76e14f41b3b706f3ef1e2260373e5e50f610fda773c4", size = 25299, upload-time = "2026-02-09T17:24:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/a3/35/bf851f6443797d1d0ae7fcd1b808a94093eb26e294261386f1d0ecd92116/pythonlogs-6.0.3-py3-none-any.whl", hash = "sha256:3227497d61412c90ca17a56537373c1fa346f460b940a43337f0349650c8fb71", size = 25224, upload-time = "2026-02-25T22:30:11.167Z" }, ] [[package]] @@ -1182,6 +1184,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, +] + [[package]] name = "wrapt" version = "2.1.1"